From b78f52801e3d94b996d46c08f85a1f6eeedb56f9 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Mon, 20 Jul 2026 07:02:24 +0800 Subject: [PATCH 1/2] feat(pipeline): config schema (off by default) + daemon boot wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a PipelineSchema to config (enabled=false default, defaultPack=null) with a CODEOID_PIPELINE_ENABLED env switch, and construct a PipelineManager in SessionManager when enabled — sharing the daemon DB and rehydrating non-terminal pipelines on boot (resume). Undefined when disabled, so the daemon stays dark by default. Pure createPipelineManagerFromConfig() factory keeps the enable/disable + share-DB + restart-survival behavior unit-tested without a full SessionManager. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config.ts | 30 +++++++++++++++++ src/daemon/pipeline/index.ts | 1 + src/daemon/pipeline/wiring.test.ts | 53 ++++++++++++++++++++++++++++++ src/daemon/pipeline/wiring.ts | 28 ++++++++++++++++ src/daemon/session-manager.ts | 14 ++++++++ 5 files changed, 126 insertions(+) create mode 100644 src/daemon/pipeline/wiring.test.ts create mode 100644 src/daemon/pipeline/wiring.ts diff --git a/src/config.ts b/src/config.ts index fff524f..09d9273 100644 --- a/src/config.ts +++ b/src/config.ts @@ -523,6 +523,20 @@ const McpServersSchema = z.record(z.string(), McpServerSchema).default({}); * `mcpServers` block. Normalized into an `McpServerSpec` by `McpRegistry`. */ export type RawMcpServerConfig = z.infer; +/** + * SDLC pipeline (docs/sdlc-pipeline.md). OFF by default — the daemon stays + * methodology-agnostic; nothing runs until this is enabled. When enabled, the + * daemon builds a PipelineManager sharing its DB and rehydrates non-terminal + * pipelines on boot. `defaultPack` is the methodology pack a pipeline runs when + * created without one (null = none / freestyle). + */ +const PipelineSchema = z + .object({ + enabled: z.boolean().default(false), + defaultPack: z.string().nullable().default(null), + }) + .default({ enabled: false, defaultPack: null }); + const RootSchema = z.object({ daemonUrl: z.string().default("ws://127.0.0.1:7400"), dbPath: z.string().default("codeoid.db"), @@ -544,6 +558,7 @@ const RootSchema = z.object({ session: SessionSchema, conductor: ConductorSchema, dispatch: DispatchSchema, + pipeline: PipelineSchema, providers: ProvidersSchema, mcpServers: McpServersSchema, hooks: HooksSchema, @@ -678,6 +693,16 @@ export interface CodeoidConfig { workerToolBudget: number; retryBaseMs: number; }; + /** + * SDLC pipeline — OFF by default (docs/sdlc-pipeline.md). When enabled, a + * PipelineManager is constructed at boot sharing the daemon DB, and + * non-terminal pipelines are rehydrated (resume). Optional in the type so + * hand-built test configs stay minimal; loadConfig always populates it. + */ + pipeline?: { + enabled: boolean; + defaultPack: string | null; + }; /** * Per-backend provider settings. Optional in the type so hand-built test * configs stay minimal; loadConfig always populates it (schema defaults). @@ -777,6 +802,10 @@ const ENV_OVERRIDES: readonly EnvOverride[] = [ // without touching config.json. Other dispatch knobs are file-config only, // matching the conductor block's convention. { env: "CODEOID_DISPATCH_ENABLED", path: "dispatch.enabled", kind: "boolean" }, + // Pipeline enable/kill switch — turn the SDLC pipeline on/off per-invocation + // without touching config.json (off by default). Other pipeline knobs are + // file-config only, matching the dispatch/conductor convention. + { env: "CODEOID_PIPELINE_ENABLED", path: "pipeline.enabled", kind: "boolean" }, { env: "CODEOID_FALLBACK_MODEL", path: "session.fallbackModel", kind: "string" }, // Hooks kill switch — disable every configured hook per-invocation without // touching config.json. Entries themselves are file-config only. @@ -978,6 +1007,7 @@ export function loadConfig(opts: LoadOptions = {}): CodeoidConfig { session: parsed.session, conductor: parsed.conductor, dispatch: parsed.dispatch, + pipeline: parsed.pipeline, providers: parsed.providers, mcpServers: parsed.mcpServers, hooks: parsed.hooks, diff --git a/src/daemon/pipeline/index.ts b/src/daemon/pipeline/index.ts index aeb5642..e68005e 100644 --- a/src/daemon/pipeline/index.ts +++ b/src/daemon/pipeline/index.ts @@ -14,3 +14,4 @@ export { alwaysGate, manualGate, noopPhaseKind, registerBuiltins } from "./built export { PipelineEngine } from "./engine"; export { PipelineStore } from "./store"; export { type CreatePipelineOpts, PipelineManager } from "./manager"; +export { createPipelineManagerFromConfig, type PipelineWiringConfig } from "./wiring"; diff --git a/src/daemon/pipeline/wiring.test.ts b/src/daemon/pipeline/wiring.test.ts new file mode 100644 index 0000000..7738133 --- /dev/null +++ b/src/daemon/pipeline/wiring.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { rmSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { PhaseDef } from "./interface"; +import { createPipelineManagerFromConfig } from "./wiring"; + +const phases: PhaseDef[] = [{ id: "one", kind: "noop", gate: "always" }]; +const tenant = { accountId: "acct", projectId: "proj", createdBy: "user" }; + +const dbFiles: string[] = []; +function tmpDb(): string { + const p = join(tmpdir(), `codeoid-pipeline-${randomUUID()}.db`); + dbFiles.push(p); + return p; +} + +afterEach(() => { + for (const p of dbFiles.splice(0)) { + for (const f of [p, `${p}-wal`, `${p}-shm`]) rmSync(f, { force: true }); + } +}); + +describe("createPipelineManagerFromConfig", () => { + test("returns undefined when config is absent", () => { + expect(createPipelineManagerFromConfig(undefined)).toBeUndefined(); + }); + + test("returns undefined when the pipeline is disabled (the default)", () => { + expect(createPipelineManagerFromConfig({ dbPath: tmpDb() })).toBeUndefined(); + expect(createPipelineManagerFromConfig({ dbPath: tmpDb(), pipeline: { enabled: false } })).toBeUndefined(); + }); + + test("returns a manager when enabled", () => { + const mgr = createPipelineManagerFromConfig({ dbPath: tmpDb(), pipeline: { enabled: true } }); + expect(mgr).toBeDefined(); + const p = mgr?.create({ name: "REQ-1", phases, ...tenant }); + expect(mgr?.get(p?.id ?? "")?.name).toBe("REQ-1"); + }); + + test("shares the daemon DB file so a fresh manager resumes (restart survival)", async () => { + const dbPath = tmpDb(); + const mgr = createPipelineManagerFromConfig({ dbPath, pipeline: { enabled: true } }); + const halting: PhaseDef[] = [{ id: "one", kind: "noop", gate: "manual" }]; // halts + const p = mgr?.create({ name: "REQ-1", phases: halting, ...tenant }); + expect((await mgr?.advance(p?.id ?? ""))?.status).toBe("halted"); + + // Simulate a daemon restart: a brand-new manager over the same DB file. + const revived = createPipelineManagerFromConfig({ dbPath, pipeline: { enabled: true } }); + expect(revived?.get(p?.id ?? "")?.status).toBe("halted"); + }); +}); diff --git a/src/daemon/pipeline/wiring.ts b/src/daemon/pipeline/wiring.ts new file mode 100644 index 0000000..7203090 --- /dev/null +++ b/src/daemon/pipeline/wiring.ts @@ -0,0 +1,28 @@ +/** + * Boot wiring for the SDLC pipeline. Builds a PipelineManager from config, + * returning `undefined` when the feature is disabled (the default) so the daemon + * stays dark. Kept a pure factory so the enable/disable + share-the-daemon-DB + * behavior is unit-testable without standing up a full SessionManager. + */ + +import { PipelineManager } from "./manager"; +import { PipelineStore } from "./store"; + +/** The minimal structural slice of CodeoidConfig this factory needs — kept + * narrow so the pipeline package doesn't depend on the full config type. */ +export interface PipelineWiringConfig { + dbPath: string; + pipeline?: { enabled: boolean }; +} + +/** + * Construct a PipelineManager when the pipeline is enabled, sharing the daemon + * DB file so pipeline state persists and resumes alongside sessions. Returns + * `undefined` when disabled — the daemon then holds no pipeline manager at all. + */ +export function createPipelineManagerFromConfig( + config: PipelineWiringConfig | undefined, +): PipelineManager | undefined { + if (!config?.pipeline?.enabled) return undefined; + return new PipelineManager(new PipelineStore(config.dbPath)); +} diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 96f9896..ec97e67 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -46,6 +46,8 @@ import { NonRetryableDispatchError, type DispatcherHost, } from "./dispatch.js"; +import { createPipelineManagerFromConfig } from "./pipeline/wiring.js"; +import type { PipelineManager } from "./pipeline/manager.js"; import type { DispatchEventRow, DispatchTaskRow } from "./store.js"; import { type MemoryEngine, type MemoryMcpMount, workspaceIdFromPath } from "./memory/index.js"; import type { McpRegistry } from "./mcp/registry.js"; @@ -163,6 +165,8 @@ export class SessionManager { #config?: CodeoidConfig; #compressionRegistry?: CompressionRegistry; #dispatcher: Dispatcher; + /** SDLC pipeline manager — undefined when the pipeline is disabled (default). */ + #pipelines?: PipelineManager; /** The daemon's provider catalog — one registry, shared by every session. */ #providers: ProviderRegistry; /** The daemon's hook bus — one instance, shared by every session. */ @@ -216,6 +220,10 @@ export class SessionManager { this.#makeDispatcherHost(), opts?.config?.dispatch, ); + // SDLC pipeline (docs/sdlc-pipeline.md) — off by default; when enabled, the + // manager shares the daemon DB and rehydrates non-terminal pipelines on + // construction (resume). Undefined when disabled ⇒ the daemon stays dark. + this.#pipelines = createPipelineManagerFromConfig(opts?.config); } /** The dispatch queue driver (P4). Exposed for server lifecycle + tests. */ @@ -223,6 +231,12 @@ export class SessionManager { return this.#dispatcher; } + /** The SDLC pipeline manager, or undefined when the pipeline is disabled + * (the default). Exposed for the pipeline control surface + tests. */ + get pipelines(): PipelineManager | undefined { + return this.#pipelines; + } + /** * Registered provider ids, default first — advertised on `auth.ok` so * clients can populate the new-session provider picker. From f7a2f3f62b381e6b255464b1786bcd2e23a7b80c Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Mon, 20 Jul 2026 09:37:26 +0800 Subject: [PATCH 2/2] feat(pipeline): skill phase kind + PhaseRunner seam + halt/answer/resume (#206) Add the runtime layer. A 'skill' PhaseKind runs fn skills natively and drives prompt/slash skills through an injectable PhaseRunner (the backend seam; a SessionManager-backed adapter lands in a follow-up). PipelineManager.answer(id, requestId, {approved, value}) is the daemon side of halt -> answer-from-a-frontend -> resume: it resolves a halted phase (pass/fail) and continues advancing to the next halt or terminal. Fully unit-tested with fakes; no live backend required. Still dark (pipeline off by default). Co-authored-by: Claude Opus 4.8 (1M context) --- src/daemon/pipeline/answer.test.ts | 79 +++++++++++++++++++++++ src/daemon/pipeline/index.ts | 2 + src/daemon/pipeline/manager.ts | 41 ++++++++++++ src/daemon/pipeline/runner.ts | 29 +++++++++ src/daemon/pipeline/skill-kind.test.ts | 86 ++++++++++++++++++++++++++ src/daemon/pipeline/skill-kind.ts | 58 +++++++++++++++++ 6 files changed, 295 insertions(+) create mode 100644 src/daemon/pipeline/answer.test.ts create mode 100644 src/daemon/pipeline/runner.ts create mode 100644 src/daemon/pipeline/skill-kind.test.ts create mode 100644 src/daemon/pipeline/skill-kind.ts diff --git a/src/daemon/pipeline/answer.test.ts b/src/daemon/pipeline/answer.test.ts new file mode 100644 index 0000000..aebc627 --- /dev/null +++ b/src/daemon/pipeline/answer.test.ts @@ -0,0 +1,79 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; +import type { PhaseDef } from "./interface"; +import { PipelineManager } from "./manager"; +import { PipelineStore } from "./store"; + +const tenant = { accountId: "acct", projectId: "proj", createdBy: "user" }; + +function mgr(): PipelineManager { + return new PipelineManager(new PipelineStore(new Database(":memory:"))); +} + +/** A pipeline whose first phase halts on the `manual` gate, followed by a phase + * that passes — so answering resumes into the tail. */ +const halting: PhaseDef[] = [ + { id: "gate", kind: "noop", gate: "manual" }, + { id: "tail", kind: "noop", gate: "always" }, +]; + +describe("PipelineManager.answer (halt → resume)", () => { + test("approve resolves the halted phase and resumes to done", async () => { + const m = mgr(); + const p = m.create({ name: "REQ-1", phases: halting, ...tenant }); + const halted = await m.advance(p.id); + expect(halted.status).toBe("halted"); + expect(halted.phases[0].state.status).toBe("halted"); + + const done = await m.answer(p.id, "gate:gate", { approved: true, value: "LGTM" }); + expect(done.status).toBe("done"); + expect(done.phases[0].state).toMatchObject({ status: "passed", summary: "LGTM" }); + expect(done.phases[1].state.status).toBe("passed"); + }); + + test("reject fails the phase and the pipeline", async () => { + const m = mgr(); + const p = m.create({ name: "REQ-1", phases: halting, ...tenant }); + await m.advance(p.id); + const failed = await m.answer(p.id, "gate:gate", { approved: false, value: "nope" }); + expect(failed.status).toBe("failed"); + expect(failed.phases[0].state).toMatchObject({ status: "failed", reason: "nope" }); + }); + + test("approving the final phase completes without a further advance", async () => { + const m = mgr(); + const p = m.create({ name: "REQ-1", phases: [{ id: "only", kind: "noop", gate: "manual" }], ...tenant }); + await m.advance(p.id); + const done = await m.answer(p.id, "gate:only", { approved: true }); + expect(done.status).toBe("done"); + expect(done.phases[0].state).toMatchObject({ status: "passed", summary: "approved" }); + }); + + test("rejects a stale requestId", async () => { + const m = mgr(); + const p = m.create({ name: "REQ-1", phases: halting, ...tenant }); + await m.advance(p.id); + await expect(m.answer(p.id, "gate:wrong", { approved: true })).rejects.toThrow("stale requestId"); + }); + + test("throws when the pipeline is not halted", async () => { + const m = mgr(); + const p = m.create({ name: "REQ-1", phases: [{ id: "one", kind: "noop", gate: "always" }], ...tenant }); + await m.advance(p.id); // runs straight to done + await expect(m.answer(p.id, "gate:one", { approved: true })).rejects.toThrow("not halted"); + }); + + test("throws on an unknown pipeline id", async () => { + await expect(mgr().answer("nope", "gate:x", { approved: true })).rejects.toThrow("not found"); + }); + + test("the resolved state persists across a restart", async () => { + const store = new PipelineStore(new Database(":memory:")); + const m = new PipelineManager(store); + const p = m.create({ name: "REQ-1", phases: halting, ...tenant }); + await m.advance(p.id); + await m.answer(p.id, "gate:gate", { approved: true }); + // Fresh manager over the same store sees the completed pipeline. + expect(new PipelineManager(store).get(p.id)?.status).toBe("done"); + }); +}); diff --git a/src/daemon/pipeline/index.ts b/src/daemon/pipeline/index.ts index e68005e..1fed009 100644 --- a/src/daemon/pipeline/index.ts +++ b/src/daemon/pipeline/index.ts @@ -15,3 +15,5 @@ export { PipelineEngine } from "./engine"; export { PipelineStore } from "./store"; export { type CreatePipelineOpts, PipelineManager } from "./manager"; export { createPipelineManagerFromConfig, type PipelineWiringConfig } from "./wiring"; +export { makeSkillPhaseKind } from "./skill-kind"; +export type { PhaseRunner, PhaseRunRequest, PhaseRunOutput } from "./runner"; diff --git a/src/daemon/pipeline/manager.ts b/src/daemon/pipeline/manager.ts index 6bdad65..dbb8174 100644 --- a/src/daemon/pipeline/manager.ts +++ b/src/daemon/pipeline/manager.ts @@ -78,6 +78,47 @@ export class PipelineManager { }); } + /** + * Resolve a halted phase with a human decision, then resume the pipeline. + * `approved` marks the phase passed (its `value` becomes the summary) and + * advances; otherwise the phase — and the pipeline — fail. This is the + * daemon-side of the halt → answer-from-a-frontend → resume path (§4.1, §5.3): + * a `ui_response` lands here to unblock a pipeline that a gate parked. + */ + async answer( + id: string, + requestId: string, + opts: { approved: boolean; value?: string }, + ): Promise { + const s = this.get(id); + if (!s) throw new Error(`pipeline "${id}" not found`); + if (s.status !== "halted") throw new Error(`pipeline "${id}" is not halted (status: ${s.status})`); + const current = s.phases[s.cursor]; + if (!current || current.state.status !== "halted") { + throw new Error(`pipeline "${id}" has no halted phase at the cursor`); + } + if (current.state.requestId !== requestId) { + throw new Error(`stale requestId "${requestId}" for pipeline "${id}"`); + } + + const next = cloneState(s); + const phase = next.phases[next.cursor]; + if (opts.approved) { + phase.state = { status: "passed", summary: opts.value ?? "approved" }; + next.cursor += 1; + next.status = next.cursor >= next.phases.length ? "done" : "running"; + } else { + phase.state = { status: "failed", reason: opts.value ?? "rejected by human", attempts: 1 }; + next.status = "failed"; + } + next.updatedAt = Date.now(); + this.#store.save(next); + this.#cache.set(id, next); + + // Approving a non-final phase resumes the run to the next halt / terminal. + return next.status === "running" ? this.advance(id) : next; + } + /** Mark a pipeline abandoned (terminal). Returns undefined if unknown. */ abort(id: string): PipelineState | undefined { const s = this.get(id); diff --git a/src/daemon/pipeline/runner.ts b/src/daemon/pipeline/runner.ts new file mode 100644 index 0000000..9fb77f7 --- /dev/null +++ b/src/daemon/pipeline/runner.ts @@ -0,0 +1,29 @@ +/** + * The seam between a phase and a backend. A `PhaseRunner` runs a phase's prompt + * on a (worker) session and returns its result. The concrete SessionManager- + * backed adapter lands in a later slice; abstracting it here keeps the pipeline + * package free of Session dependencies and lets the skill phase kind be tested + * with a fake runner. + */ + +import type { PhaseDef, PipelineState } from "./interface"; + +export interface PhaseRunRequest { + /** the resolved prompt / slash command to run for this phase. */ + prompt: string; + /** per-phase backend override — enables cross-provider-per-phase routing. */ + provider?: string; + /** per-phase model override. */ + model?: string; + pipeline: PipelineState; + phase: PhaseDef; +} + +export interface PhaseRunOutput { + summary?: string; + artifacts?: string[]; +} + +export interface PhaseRunner { + runPrompt(req: PhaseRunRequest): Promise; +} diff --git a/src/daemon/pipeline/skill-kind.test.ts b/src/daemon/pipeline/skill-kind.test.ts new file mode 100644 index 0000000..17b9844 --- /dev/null +++ b/src/daemon/pipeline/skill-kind.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import type { PhaseCtx, PhaseDef, PipelineState, SkillPlugin } from "./interface"; +import { createRegistries } from "./registry"; +import type { PhaseRunner, PhaseRunRequest } from "./runner"; +import { makeSkillPhaseKind } from "./skill-kind"; + +function ctxFor(phase: PhaseDef, skills: SkillPlugin[]): PhaseCtx { + const registries = createRegistries(); + for (const s of skills) registries.skills.register(s); + const pipeline: PipelineState = { + id: "p", + name: "p", + phases: [{ def: phase, state: { status: "running", startedAt: 1, attempts: 0 } }], + cursor: 0, + status: "running", + accountId: "a", + projectId: "p", + createdBy: "u", + createdAt: 1, + updatedAt: 1, + }; + return { pipeline, phase, registries }; +} + +describe("skill phase kind", () => { + test("runs an fn skill natively and passes with its summary", async () => { + const skill: SkillPlugin = { + id: "hello", + kind: "fn", + async run() { + return { summary: "did the thing", artifacts: ["out.md"] }; + }, + }; + const kind = makeSkillPhaseKind(); + const res = await kind.run(ctxFor({ id: "one", kind: "skill", skill: "hello" }, [skill])); + expect(res).toEqual({ outcome: "passed", summary: "did the thing", artifacts: ["out.md"] }); + }); + + test("a throwing fn skill fails gracefully", async () => { + const skill: SkillPlugin = { + id: "boom", + kind: "fn", + async run() { + throw new Error("fn-boom"); + }, + }; + const res = await makeSkillPhaseKind().run(ctxFor({ id: "one", kind: "skill", skill: "boom" }, [skill])); + expect(res.outcome).toBe("failed"); + if (res.outcome === "failed") expect(res.reason).toContain("fn-boom"); + }); + + test("fails when the phase declares no skill id", async () => { + const res = await makeSkillPhaseKind().run(ctxFor({ id: "one", kind: "skill" }, [])); + expect(res.outcome).toBe("failed"); + if (res.outcome === "failed") expect(res.reason).toContain("no skill id"); + }); + + test("fails on an unknown skill", async () => { + const res = await makeSkillPhaseKind().run(ctxFor({ id: "one", kind: "skill", skill: "nope" }, [])); + expect(res.outcome).toBe("failed"); + if (res.outcome === "failed") expect(res.reason).toContain("unknown skill"); + }); + + test("a prompt/slash skill fails cleanly when no runner is configured", async () => { + const skill: SkillPlugin = { id: "spec", kind: "slash", command: "/spec" }; + const res = await makeSkillPhaseKind().run(ctxFor({ id: "one", kind: "skill", skill: "spec" }, [skill])); + expect(res.outcome).toBe("failed"); + if (res.outcome === "failed") expect(res.reason).toContain("needs a phase runner"); + }); + + test("drives a prompt/slash skill through the runner with per-phase provider/model", async () => { + const seen: PhaseRunRequest[] = []; + const runner: PhaseRunner = { + async runPrompt(req) { + seen.push(req); + return { summary: "ran on backend" }; + }, + }; + const skill: SkillPlugin = { id: "spec", kind: "slash", command: "/spec" }; + const phase: PhaseDef = { id: "one", kind: "skill", skill: "spec", provider: "gemini", model: "flash" }; + const res = await makeSkillPhaseKind(runner).run(ctxFor(phase, [skill])); + expect(res).toEqual({ outcome: "passed", summary: "ran on backend", artifacts: undefined }); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ prompt: "/spec", provider: "gemini", model: "flash" }); + }); +}); diff --git a/src/daemon/pipeline/skill-kind.ts b/src/daemon/pipeline/skill-kind.ts new file mode 100644 index 0000000..a301a74 --- /dev/null +++ b/src/daemon/pipeline/skill-kind.ts @@ -0,0 +1,58 @@ +/** + * The "skill" phase kind: resolve the phase's `skill` from the registry and run + * it. A `fn` skill runs natively (no backend); a `prompt` / `slash` skill is + * driven through an injected PhaseRunner (the daemon backend seam). Without a + * runner, a prompt/slash skill fails with a clear reason — so the kind stays + * usable in pure tests and degrades safely when no backend is wired. + */ + +import type { PhaseCtx, PhaseKind, PhaseRunResult, SkillPlugin } from "./interface"; +import type { PhaseRunner } from "./runner"; + +export function makeSkillPhaseKind(runner?: PhaseRunner): PhaseKind { + return { + id: "skill", + async run(ctx: PhaseCtx): Promise { + const skillId = ctx.phase.skill; + if (!skillId) { + return { outcome: "failed", reason: `phase "${ctx.phase.id}" has kind:"skill" but no skill id` }; + } + const skill = ctx.registries.skills.resolve(skillId); + if (!skill) return { outcome: "failed", reason: `unknown skill "${skillId}"` }; + return runSkill(skill, ctx, runner); + }, + }; +} + +async function runSkill( + skill: SkillPlugin, + ctx: PhaseCtx, + runner: PhaseRunner | undefined, +): Promise { + if (skill.kind === "fn") { + try { + const res = await skill.run(ctx); + return { outcome: "passed", summary: res.summary, artifacts: res.artifacts }; + } catch (err) { + // A throwing native skill is a phase failure, not a crash. (The engine + // also guards this, but catching here attributes the error to the skill.) + const reason = err instanceof Error ? err.message : String(err); + return { outcome: "failed", reason: `skill "${skill.id}" threw: ${reason}` }; + } + } + if (!runner) { + return { + outcome: "failed", + reason: `skill "${skill.id}" (${skill.kind}) needs a phase runner, but none is configured`, + }; + } + const prompt = skill.kind === "slash" ? skill.command : skill.template; + const res = await runner.runPrompt({ + prompt, + provider: ctx.phase.provider, + model: ctx.phase.model, + pipeline: ctx.pipeline, + phase: ctx.phase, + }); + return { outcome: "passed", summary: res.summary, artifacts: res.artifacts }; +}