From c64919981fc22abd03b8fb44a2fe271093bae226 Mon Sep 17 00:00:00 2001 From: mingchuno Date: Thu, 22 Oct 2026 09:43:13 +0100 Subject: [PATCH] feat(sdlc): resume pending work and recover from the monitor - start-work without a task lists pending Work Items via `work list --brief` and asks which to resume - work commands infer the project from the current directory, read contracts from stdin with `--file -`, print compact JSON, and `propose --publish` republishes in one step - the operator marks ready work in an escalation-blocked project as recoverable; `r` in the terminal monitor and "Resubmit after recovery" in the web monitor admit it after the checkout safety checks - the SDLC guide approves and recovers through the monitors, with CLI equivalents noted Co-Authored-By: Claude Opus 5.5 --- docs/engineering/sdlc.md | 41 +++---- packages/cli/src/cli.ts | 5 + packages/cli/src/tui/data.ts | 1 + packages/cli/src/tui/work-items.tsx | 64 +++++++++- packages/cli/src/web/contracts.ts | 3 + packages/cli/src/web/server.ts | 27 ++++- packages/cli/src/work-commands.ts | 139 +++++++++++++++------- packages/cli/web/work-items.tsx | 82 +++++++++++++ packages/core/src/work/operator.ts | 71 +++++++++-- skills/start-work/SKILL.md | 19 ++- skills/start-work/references/delegated.md | 9 +- skills/start-work/references/work-api.md | 34 +++--- tests/work-cli.test.ts | 72 +++++++++++ tests/work-console.test.ts | 76 ++++++++++++ tests/work-console.test.tsx | 45 +++++++ 15 files changed, 590 insertions(+), 98 deletions(-) diff --git a/docs/engineering/sdlc.md b/docs/engineering/sdlc.md index 7f967c8..20a4579 100644 --- a/docs/engineering/sdlc.md +++ b/docs/engineering/sdlc.md @@ -83,7 +83,7 @@ scope, verification and, for Track C, the design. The issue is its canonical hom `start-work` publishes it before asking for approval. ```sh -agent-workflows --config agent-workflows.json work publish WORK_ID --revision CURRENT_REVISION --project app --json +agent-workflows --config agent-workflows.json work publish WORK_ID --revision CURRENT_REVISION ``` | Work Item source | Effect | @@ -105,28 +105,23 @@ reassessment first. Local Work Items need publication before a `github` or ## Inspect and approve work -The intake skill returns a Work Item ID and effective track. Read its current -contract and decision before approving. - -```sh -agent-workflows --config agent-workflows.json work inspect WORK_ID --project app --json -``` +The intake skill returns a Work Item ID, its effective track and where to approve +it. Open the Work Item in the web monitor (`agent-workflows monitor --web WORK_ID`) +or the terminal monitor (`agent-workflows monitor`, then `w`) and read its +current contract and decision before approving. Track A can become ready after an expected outcome is recorded. Track B waits for scope approval. Track C waits for scope approval, then a complete design and design approval. An unresolved material uncertainty prevents admission on every track. Read the [contract reference](task-contract.md) for field and revision rules. -For a personal pilot using `approvalSource: local-operator`, run the operator -command yourself after inspecting the exact complete revision. - -```sh -agent-workflows --config agent-workflows.json work approve WORK_ID --revision CURRENT_REVISION --gate scope --project app -agent-workflows --config agent-workflows.json work approve WORK_ID --revision CURRENT_REVISION --gate design --project app -``` +For a personal pilot using `approvalSource: local-operator`, approve the exact +revision you inspected with **Review scope approval** in the web monitor or `a` +in the terminal monitor. The CLI equivalent is +`work approve WORK_ID --revision CURRENT_REVISION --gate scope`. Use only the gate currently required. Design approval requires a design. The -intake skill never runs these commands. Conversational agreement and issue labels +intake skill never approves. Conversational agreement and issue labels do not create approval records. The worker admits a ready Work Item on its next poll. Final approval can therefore @@ -138,7 +133,7 @@ remains persisted. A later worker start resumes intake. Explicit submission uses the same eligibility checks and returns an existing admission for the same revision. ```sh -agent-workflows --config agent-workflows.json work submit WORK_ID --revision CURRENT_REVISION --project app --json +agent-workflows --config agent-workflows.json work submit WORK_ID --revision CURRENT_REVISION agent-workflows --config agent-workflows.json status --json agent-workflows --config agent-workflows.json inspect RUN_ID ``` @@ -184,7 +179,7 @@ The worker imports permitted approval during polling. To import existing comment without waiting for another poll, run the sync command. ```sh -agent-workflows --config agent-workflows.json work sync-approvals WORK_ID --project service --json +agent-workflows --config agent-workflows.json work sync-approvals WORK_ID ``` Keep approval credentials separate from implementation credentials. Configure the @@ -206,11 +201,13 @@ source comments and credential separation before unattended company execution. When source or scope escalation blocks a project, preserve unfinished work and clean the checkout before starting another attempt. Reassess the Work Item and -record its required approvals. Submit the new revision explicitly with -`work submit WORK_ID --revision CURRENT_REVISION --project app --recover`. -This checks that recorded processes have stopped and the checkout is clean before -atomically clearing the block and admitting the revised work. It preserves an -operator pause. Automatic polling never clears a recovery block. +record its required approvals. The monitor then marks the ready item +recoverable: press `r` in the terminal monitor, or choose **Resubmit after +recovery** in the web monitor. The CLI equivalent is +`work submit WORK_ID --revision CURRENT_REVISION --recover`. Each checks that +recorded processes have stopped and the checkout is clean before atomically +clearing the block and admitting the revised work. It preserves an operator +pause. Automatic polling never clears a recovery block. Inspect workflow-executed validation, independent review, limitations, and final readiness. Outstanding manual checks, missing configured validation, or incomplete diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 1ca0de8..37d17ba 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -35,6 +35,7 @@ import { } from "./cli-runner-control.js"; import { resolveConfigPath } from "./config-discovery.js"; import { registerDoctor } from "./doctor.js"; +import { applyCliTokens } from "./hosting-token.js"; import { inferProject } from "./repository-inference.js"; import { createTerminalNotificationWriter, Monitor } from "./tui/index.js"; import { defaultWebPort } from "./web/contracts.js"; @@ -474,6 +475,7 @@ program } if (control?.envFile) await loadEnvironmentFile(control.envFile); const config = control?.configuration ?? (await configuration()); + applyCliTokens(config.projects); if (control && sha256(databaseUrl(config)) !== control.databaseUrlHash) throw new Error( "Database connection differs from the running server; use its startup environment", @@ -485,6 +487,7 @@ program await withConfiguredStore(config, async (store) => { const work = workOperator({ store, + stateDirectory: resolve(configBaseDirectory(), config.stateDirectory), projects: async () => ( await readActiveConfiguration(configPath(), config.id) @@ -497,6 +500,7 @@ program source: { workItems: () => work.list(), approveWork: (id, approval) => work.approve(id, approval), + resubmitWork: (id, revision) => work.resubmit(id, revision), projects: async () => (await store.projects()).filter((project) => config.projects.some((active) => active.id === project.id), @@ -522,6 +526,7 @@ program }); }); registerWorkCommands(program, { + launchDirectory, pathBaseDirectory: configBaseDirectory, configuration, monitorUrl: async () => { diff --git a/packages/cli/src/tui/data.ts b/packages/cli/src/tui/data.ts index aed9755..4d01993 100644 --- a/packages/cli/src/tui/data.ts +++ b/packages/cli/src/tui/data.ts @@ -21,6 +21,7 @@ import { export interface MonitorSource { workItems?: () => Promise; approveWork?: (id: string, approval: WorkApproval) => Promise; + resubmitWork?: (id: string, revision: string) => Promise; configuration?: () => Promise; projects: Store["projects"]; runs: Store["runs"]; diff --git a/packages/cli/src/tui/work-items.tsx b/packages/cli/src/tui/work-items.tsx index 07f974e..9b1bdda 100644 --- a/packages/cli/src/tui/work-items.tsx +++ b/packages/cli/src/tui/work-items.tsx @@ -33,6 +33,11 @@ export function WorkItemsView({ title: string; approval: WorkApproval; }>(); + const [resubmission, setResubmission] = useState<{ + id: string; + title: string; + revision: string; + }>(); const refresh = useCallback(async () => { if (!source.workItems) throw new Error( @@ -81,6 +86,10 @@ export function WorkItemsView({ !pending && selected?.approvalSource === "local-operator" && Boolean(gate && source.approveWork); + const recoverable = + connected && + !pending && + Boolean(selected?.recoverable && source.resubmitWork); const height = rows - 8; const lines = selected ? [ @@ -91,6 +100,11 @@ export function WorkItemsView({ selected.work.revision, "", ...selected.decision.reasons.map((reason) => reason.message), + ...(selected.recoverable + ? [ + "The project is blocked by an earlier attempt. Preserve and clean the checkout, then press r to resubmit.", + ] + : []), ...(selected.approvalSource !== "local-operator" ? [ `Approval requires an authenticated allowed human through ${selected.approvalSource}.`, @@ -115,7 +129,13 @@ export function WorkItemsView({ const maximum = Math.max(0, wrapLines(lines, columns - 4).length - height); const currentOffset = Math.min(offset, maximum); useInput((input, key) => { - if (key.ctrl || key.meta || key.eventType === "release" || confirmation) + if ( + key.ctrl || + key.meta || + key.eventType === "release" || + confirmation || + resubmission + ) return; if (key.escape || input === "w" || input === "q") onClose(); else if (key.tab && visible.length) { @@ -135,6 +155,12 @@ export function WorkItemsView({ title: selected.work.contract.title, approval: { revision: selected.work.revision, gate }, }); + else if (input === "r" && recoverable && selected) + setResubmission({ + id: selected.work.id, + title: selected.work.contract.title, + revision: selected.work.revision, + }); else if (key.home) setOffset(0); else if (key.end) setOffset(maximum); else if (key.upArrow || key.pageUp) @@ -164,6 +190,40 @@ export function WorkItemsView({ setPending(false); } }; + const resubmit = async () => { + if (!resubmission || !source.resubmitWork || pendingRef.current) return; + pendingRef.current = true; + setPending(true); + setResubmission(undefined); + setMessage("Checking the checkout and resubmitting..."); + try { + await source.resubmitWork(resubmission.id, resubmission.revision); + setMessage("Resubmitted; the runner starts it on its next scan."); + setItems(await refresh()); + } catch (error) { + setMessage(error instanceof Error ? error.message : String(error)); + } finally { + pendingRef.current = false; + setPending(false); + } + }; + if (resubmission) + return ( + setResubmission(undefined)} + onConfirm={() => void resubmit()} + /> + ); if (confirmation) return ( {chromeCells(message, columns - 4)} {chromeCells( - `${allowed ? `a approve ${gate} / ` : ""}Tab select / Up/Down scroll / PgUp/PgDn / Esc close`, + `${allowed ? `a approve ${gate} / ` : ""}${recoverable ? "r resubmit / " : ""}Tab select / Up/Down scroll / PgUp/PgDn / Esc close`, columns - 4, )} diff --git a/packages/cli/src/web/contracts.ts b/packages/cli/src/web/contracts.ts index a2bd56a..9aac54c 100644 --- a/packages/cli/src/web/contracts.ts +++ b/packages/cli/src/web/contracts.ts @@ -27,6 +27,9 @@ export const workApprovalSchema = z.strictObject({ gate: z.enum(["scope", "design"]), }); export type WorkApproval = z.infer; +export const workResubmissionSchema = workApprovalSchema.pick({ + revision: true, +}); export type { WorkItemView }; export interface WorkItemsSnapshot { items: WorkItemView[]; diff --git a/packages/cli/src/web/server.ts b/packages/cli/src/web/server.ts index 110e89e..9b2997b 100644 --- a/packages/cli/src/web/server.ts +++ b/packages/cli/src/web/server.ts @@ -20,7 +20,11 @@ import { } from "../cli-runner-control.js"; import { discoverRuns, runFilters } from "../monitor/discovery.js"; import { webAssetDirectory } from "./assets.js"; -import { monitorCommandSchema, workApprovalSchema } from "./contracts.js"; +import { + monitorCommandSchema, + workApprovalSchema, + workResubmissionSchema, +} from "./contracts.js"; import { MonitorEvidence } from "./evidence.js"; import type { WebStartup } from "./lifecycle.js"; @@ -171,6 +175,7 @@ export function monitorServer(store: Store, startup: WebStartup) { ); const work = workOperator({ store, + stateDirectory: startup.stateDirectory, projects: async () => ( await readActiveConfiguration(startup.configPath, startup.scope) @@ -196,6 +201,26 @@ export function monitorServer(store: Store, startup: WebStartup) { await work.approve(context.req.param("id"), parsed.data), ); }); + app.use("/api/work-items/:id/resubmit", bodyLimit({ maxSize: 4096 })); + app.post("/api/work-items/:id/resubmit", async (context) => { + const parsed = workResubmissionSchema.safeParse( + await context.req.json().catch(() => null), + ); + if (!parsed.success) + return context.json({ error: "Provide the inspected revision" }, 400); + try { + return context.json( + await work.resubmit(context.req.param("id"), parsed.data.revision), + ); + } catch (error) { + if (error instanceof WorkItemNotFoundError) throw error; + // Safety and policy refusals tell the operator what to fix first. + return context.json( + { error: error instanceof Error ? error.message : String(error) }, + 409, + ); + } + }); app.get("/api/runs/:id/log", async (context) => { const parsed = logQuerySchema.safeParse(context.req.query()); if (!parsed.success) diff --git a/packages/cli/src/work-commands.ts b/packages/cli/src/work-commands.ts index 190afa9..a3b7094 100644 --- a/packages/cli/src/work-commands.ts +++ b/packages/cli/src/work-commands.ts @@ -20,8 +20,10 @@ import { workOperator, } from "@mingchuno/agent-workflows-core"; import type { Command } from "commander"; +import { withinCheckout } from "./config-discovery.js"; interface WorkCommandDependencies { + launchDirectory: string; pathBaseDirectory(): string; configuration(): Promise; /** The running web monitor address without its token, if any. */ @@ -31,6 +33,14 @@ interface WorkCommandDependencies { action: (store: Store) => Promise, ): Promise; } +/** Read JSON from a file, or from standard input for `-`. */ +async function readJson(path: string): Promise { + if (path !== "-") return JSON.parse(await readFile(path, "utf8")); + let text = ""; + for await (const chunk of process.stdin) text += chunk; + return JSON.parse(text); +} + export function registerWorkCommands( program: Command, dependencies: WorkCommandDependencies, @@ -38,7 +48,7 @@ export function registerWorkCommands( const work = program .command("work") .description("Assess, approve and submit revision-bound engineering work"); - const resolveProject = async (projectId: string) => { + const resolveProject = async (projectId: string | undefined) => { const authored = await dependencies.configuration(); const base = dependencies.pathBaseDirectory(); const config = { @@ -51,14 +61,22 @@ export function registerWorkCommands( })), ), }; - const project = config.projects.find( - (candidate) => candidate.id === projectId, + // Without --project, use the project whose checkout holds this directory. + const project = config.projects.find((candidate) => + projectId === undefined + ? withinCheckout(dependencies.launchDirectory, candidate.checkout) + : candidate.id === projectId, ); - if (!project) throw new Error(`Unknown project: ${projectId}`); + if (!project) + throw new Error( + projectId === undefined + ? `No configured project checkout contains ${dependencies.launchDirectory}; pass --project` + : `Unknown project: ${projectId}`, + ); return { config, project }; }; const execute = async ( - projectId: string, + projectId: string | undefined, action: ( store: Store, project: Project, @@ -80,22 +98,28 @@ export function registerWorkCommands( monitorUrl: `${monitor}#work=${encodeURIComponent((result as WorkInspection).work.id)}`, } : result; - console.log(JSON.stringify(linked, null, 2)); + console.log(JSON.stringify(linked)); }); }; const command = (syntax: string) => work .command(syntax) - .requiredOption("-p, --project ", "configured project ID") + .option( + "-p, --project ", + "configured project ID (default: the project containing the current directory)", + ) .option("--json", "machine-readable output (default)"); command("create") .option("--issue ", "fetch an issue from the configured host") .option("--title ", "local task title and initial intent") .option("--key ", "stable local intake key; reuse to resume") - .option("--file ", "initial WorkContract JSON") + .option( + "--file ", + "initial WorkContract JSON, or - for standard input", + ) .action( async (options: { - project: string; + project?: string; issue?: string; title?: string; key?: string; @@ -111,7 +135,7 @@ export function registerWorkCommands( const issue = await createHosting(project).getIssue(number); if (!issue.open) throw new Error("Issue is closed"); const contract = options.file - ? JSON.parse(await readFile(options.file, "utf8")) + ? await readJson(options.file) : { title: issue.title, intent: issue.body || issue.title }; const work = await store.workItems.create( project.id, @@ -125,7 +149,7 @@ export function registerWorkCommands( if (!options.title && !options.file) throw new Error("Provide --title or --file for local work"); const contract = options.file - ? JSON.parse(await readFile(options.file, "utf8")) + ? await readJson(options.file) : { title: options.title, intent: options.title }; const created = await store.workItems.create( project.id, @@ -139,65 +163,100 @@ export function registerWorkCommands( .description( "Classify a contract without the database; Track A runs inline in the current session", ) - .requiredOption("--file ", "WorkContract JSON") + .requiredOption( + "--file ", + "WorkContract JSON, or - for standard input", + ) .option( "--changed-from ", "also check the checkout's changes since this revision", ) .action( async (options: { - project: string; + project?: string; file: string; changedFrom?: string; }) => { const { project } = await resolveProject(options.project); - const contract = workContractSchema.parse( - JSON.parse(await readFile(options.file, "utf8")), - ); + const contract = workContractSchema.parse(await readJson(options.file)); const changed = options.changedFrom === undefined ? undefined : await changedPathsSince(project.checkout, options.changedFrom); console.log( - JSON.stringify( - classifyWork(contract, project.sdlc, changed), - null, - 2, - ), + JSON.stringify(classifyWork(contract, project.sdlc, changed)), ); }, ); - command("list").action(async (options: { project: string }) => - execute(options.project, async (store, project) => - Promise.all( - (await store.workItems.list(project.id)).map((item) => - store.workItems.inspect(item.id, project), - ), - ), - ), - ); + command("list") + .option("--brief", "one summary line of state per Work Item") + .action(async (options: { project?: string; brief?: boolean }) => + execute(options.project, async (store, project) => { + const items = await Promise.all( + (await store.workItems.list(project.id)).map((item) => + store.workItems.inspect(item.id, project), + ), + ); + return options.brief + ? items.map(({ work, decision, approvalComment }) => ({ + id: work.id, + title: work.contract.title, + source: + work.source.kind === "local" + ? `local:${work.source.key}` + : work.source.url, + track: decision.track, + state: decision.state, + ...(approvalComment ? { approvalComment } : {}), + })) + : items; + }), + ); for (const verb of ["inspect", "assess"]) command(`${verb} `).action( - async (id: string, options: { project: string }) => + async (id: string, options: { project?: string }) => execute(options.project, (store, project) => store.workItems.inspect(id, project), ), ); command("propose ") .requiredOption("--revision ", "revision being replaced") - .requiredOption("--file ", "complete authored WorkContract JSON") + .option( + "--publish", + "then write the contract to its issue; only after the human confirmed publishing", + ) + .requiredOption( + "--file ", + "complete authored WorkContract JSON, or - for standard input", + ) .action( async ( id: string, - options: { project: string; revision: string; file: string }, + options: { + project?: string; + revision: string; + file: string; + publish?: boolean; + }, ) => execute(options.project, async (store, project) => { await store.workItems.inspect(id, project); const contract = workContractSchema.parse( - JSON.parse(await readFile(options.file, "utf8")), + await readJson(options.file), + ); + const proposed = await store.workItems.propose( + id, + options.revision, + contract, ); - await store.workItems.propose(id, options.revision, contract); - return store.workItems.inspect(id, project); + return options.publish + ? publishWork( + id, + project, + { store, hosting: createHosting(project) }, + { revision: proposed.revision }, + ) + : store.workItems.inspect(id, project); }), ); command("approve ") @@ -212,7 +271,7 @@ export function registerWorkCommands( .action( async ( id: string, - options: { project: string; revision: string; gate: string }, + options: { project?: string; revision: string; gate: string }, ) => execute(options.project, (store, project) => workOperator({ store, projects: async () => [project] }).approve(id, { @@ -227,7 +286,7 @@ export function registerWorkCommands( ) .requiredOption("--revision ", "contract revision to publish") .action( - async (id: string, options: { project: string; revision: string }) => + async (id: string, options: { project?: string; revision: string }) => execute(options.project, (store, project) => publishWork( id, @@ -238,7 +297,7 @@ export function registerWorkCommands( ), ); command("sync-approvals ").action( - async (id: string, options: { project: string }) => + async (id: string, options: { project?: string }) => execute(options.project, async (store, project) => ({ imported: await syncWorkApprovals( id, @@ -258,7 +317,7 @@ export function registerWorkCommands( .action( async ( id: string, - options: { project: string; revision: string; recover?: boolean }, + options: { project?: string; revision: string; recover?: boolean }, ) => execute(options.project, async (store, project, config) => { const admission = await admitWork( diff --git a/packages/cli/web/work-items.tsx b/packages/cli/web/work-items.tsx index 336a97d..24bd163 100644 --- a/packages/cli/web/work-items.tsx +++ b/packages/cli/web/work-items.tsx @@ -25,6 +25,10 @@ export function WorkItemsView({ title: string; approval: WorkApproval; }>(); + const [resubmitting, setResubmitting] = useState<{ + id: string; + revision: string; + }>(); const [pending, setPending] = useState(false); const pendingRef = useRef(false); const [notice, setNotice] = useState(""); @@ -94,6 +98,32 @@ export function WorkItemsView({ setPending(false); } }; + const resubmit = async () => { + if (!resubmitting || unavailable || pendingRef.current) return; + pendingRef.current = true; + setPending(true); + setNotice("Checking the checkout and resubmitting…"); + try { + const response = await fetch( + `/api/work-items/${encodeURIComponent(resubmitting.id)}/resubmit`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ revision: resubmitting.revision }), + }, + ); + const result = await response.json(); + if (!response.ok) throw new Error(result.error ?? "Resubmission failed."); + setNotice("Resubmitted; the runner starts it on its next scan."); + setResubmitting(undefined); + await client.invalidateQueries({ queryKey: ["work-items"] }); + } catch (error) { + setNotice(error instanceof Error ? error.message : String(error)); + } finally { + pendingRef.current = false; + setPending(false); + } + }; return (

Work Items

@@ -228,6 +258,58 @@ export function WorkItemsView({ Review {gate} approval ) : null} + {selected.recoverable && + (resubmitting?.id === selected.work.id ? ( +
+

Resubmit {selected.work.contract.title}?

+

+ This clears the project block and runs the revision once + earlier processes stopped and the checkout is clean. +

+ {resubmitting.revision} +
+ + +
+
+ ) : ( +
+

+ The project is blocked by an earlier attempt. Preserve and + clean the checkout, then resubmit. +

+ +
+ ))} {confirmation && (
; @@ -18,6 +27,11 @@ export interface WorkOperator { id: string, approval: { gate: ApprovalGate; revision: string }, ): Promise; + /** + * Admit the inspected ready revision into a blocked project after checking + * that earlier processes stopped and the checkout is clean. + */ + resubmit(id: string, revision: string): Promise; } /** @@ -32,21 +46,30 @@ export function workOperator(options: { projects: () => Promise; /** Approver identity; defaults to the local OS user. */ operator?: string; + /** Absolute state directory; required for `resubmit`. */ + stateDirectory?: string; + hosting?: (project: Project) => HostingAdapter; }): WorkOperator { const { store } = options; const view = async ( id: string, project: Project, runs?: RunRecord[], - ): Promise => ({ - ...(await store.workItems.inspect(id, project)), - approvalSource: project.sdlc.approvalSource, - runIds: (runs ?? (await store.runs())) - .filter( - (run) => run.projectId === project.id && run.work?.workItemId === id, - ) - .map((run) => run.id), - }); + ): Promise => { + const inspection = await store.workItems.inspect(id, project); + return { + ...inspection, + approvalSource: project.sdlc.approvalSource, + runIds: (runs ?? (await store.runs())) + .filter( + (run) => run.projectId === project.id && run.work?.workItemId === id, + ) + .map((run) => run.id), + recoverable: + inspection.decision.state === "ready" && + (await store.project(project.id)).blocked !== null, + }; + }; const projectFor = async (id: string): Promise => { const work = await store.workItems.get(id); const project = (await options.projects()).find( @@ -87,5 +110,33 @@ export function workOperator(options: { ); return view(id, project); }, + async resubmit(id, revision) { + const project = await projectFor(id); + const stateDirectory = options.stateDirectory; + if (!stateDirectory) + throw new Error("Resubmission is unavailable from this surface"); + const admission = await admitWork( + id, + project, + { + store, + hosting: () => (options.hosting ?? createHosting)(project), + }, + { + revision, + recovery: { + checkSafety: (related) => + assertCheckoutReleased({ + project, + stateDirectory, + runIds: related.map((run) => run.id), + }), + }, + }, + ); + if (admission.state === "waiting") + throw new BlockedError(admission.reason); + return view(id, project); + }, }; } diff --git a/skills/start-work/SKILL.md b/skills/start-work/SKILL.md index ee8649f..79a078f 100644 --- a/skills/start-work/SKILL.md +++ b/skills/start-work/SKILL.md @@ -15,10 +15,11 @@ Run `agent-workflows doctor --json` from the repository. If the command is missing, treat the backend as absent. Read the JSON result. When doctor passes the `configuration` and `project` checks, record its -`configPath` and `project`. Pass `--config CONFIG_PATH` before the subcommand -and `--project PROJECT` to every later `agent-workflows` command, for example -`agent-workflows --config CONFIG_PATH work list --project PROJECT`. Never guess -another configuration file. If doctor finds no configuration but the human keeps +`configPath`. Pass `--config CONFIG_PATH` before the subcommand of every later +`agent-workflows` command, for example +`agent-workflows --config CONFIG_PATH work list --brief`. Work commands select +the project containing the current directory; pass `--project PROJECT` only +when running from elsewhere. Never guess another configuration file. If doctor finds no configuration but the human keeps one elsewhere, rerun doctor with `--config PATH`. | Result | Meaning | @@ -33,6 +34,11 @@ offer to [set it up](references/setup.md) before continuing. ## 2. Resume or resolve the task +Without a task, run `work list --brief` and show each Work Item as one line: +title, track, state and its next action (the human's approval, your +clarification, or the worker). Ask which to resume. With no Work Items, ask for +a task. + If the input is a Work Item ID, or `work list` shows a Work Item with the same issue or local key, follow [the delegated protocol](references/delegated.md) to resume it. Similar titles alone do not establish identity. @@ -43,13 +49,14 @@ when behavior is unclear or a defect needs a root cause. ## 3. Classify -Write a compact WorkContract JSON file outside the checkout. The fields are in +Write a compact WorkContract JSON file outside the checkout, or pass it on +standard input with `--file -`. The fields are in [the work API](references/work-api.md). Ask only questions whose answers change acceptance, scope, safety or feasibility; record reversible defaults as `assumptions`. If the CLI and a configured project are available, run -`agent-workflows --config CONFIG work classify --project ID --file CONTRACT` and use its +`agent-workflows --config CONFIG work classify --file CONTRACT` and use its `track`. Otherwise classify by hand: Track A only for a small change with a clear expected outcome, no declared risk, no open questions, and about ten files or fewer. Migrations, security, public API, production, infrastructure and diff --git a/skills/start-work/references/delegated.md b/skills/start-work/references/delegated.md index 08d08c3..11f9459 100644 --- a/skills/start-work/references/delegated.md +++ b/skills/start-work/references/delegated.md @@ -30,7 +30,8 @@ there. Before presenting a gate, publish the current revision with `work publish`. The first time for a Work Item, ask the human first: "Create an issue holding this contract?" for local work, or "Update issue #N with this contract?" for issue work. Publish only after an explicit yes. Once confirmed, -republish without asking whenever a proposal changes the contract. If the human +republish without asking whenever a proposal changes the contract, in one step +with `work propose --publish`. If the human declines, continue unpublished and do not ask again for that Work Item. Publishing returns a new revision and resets approvals; present that revision. @@ -57,6 +58,10 @@ the Work Item after an approval, a source edit or a fresh session. Report the Work Item ID, effective track, state and one next action. Report a Run ID only after the backend returns one. An escalated Run needs a human -decision before scope grows. +decision before scope grows. After the human agrees to the revised scope, +propose it and present the gate as usual. Once the Work Item is ready again, +tell the human to preserve and clean the checkout, then resubmit from the +monitor (`r` in the terminal monitor, or "Resubmit after recovery" in the web +monitor). It checks that earlier processes stopped and the checkout is clean. After Track C delivery, follow [capture knowledge](capture-knowledge.md). diff --git a/skills/start-work/references/work-api.md b/skills/start-work/references/work-api.md index 2318331..88bc779 100644 --- a/skills/start-work/references/work-api.md +++ b/skills/start-work/references/work-api.md @@ -4,23 +4,25 @@ `AGENT_WORKFLOWS_CONFIG`, `agent-workflows.json` in the current directory, then the one file in `$AGENT_WORKFLOWS_HOME` (default `~/.config/agent-workflows`) whose project manages this checkout or one of its worktrees. It reports the -resolved `configPath` and `project`. Pass both explicitly to every other command -so a changed directory or environment cannot select another runner's -configuration. Work commands print JSON. Discover options with -`agent-workflows work --help`. +resolved `configPath` and `project`. Pass `--config` explicitly to every other +command so a changed environment cannot select another runner's configuration. +Work commands use the project containing the current directory unless given +`--project`. They print compact JSON. `--file -` reads the contract from +standard input. Discover options with `agent-workflows work --help`. ```sh agent-workflows doctor --json -agent-workflows --config CONFIG work classify --project app --file /tmp/work-contract.json -agent-workflows --config CONFIG work classify --project app --file /tmp/work-contract.json --changed-from BASE -agent-workflows --config CONFIG work list --project app -agent-workflows --config CONFIG work inspect WORK_ID --project app -agent-workflows --config CONFIG work create --issue 123 --project app -agent-workflows --config CONFIG work create --title "Fix missing-size sorting" --key missing-size-sort --project app -agent-workflows --config CONFIG work propose WORK_ID --revision CURRENT_REVISION --file /tmp/work-contract.json --project app -agent-workflows --config CONFIG work publish WORK_ID --revision CURRENT_REVISION --project app -agent-workflows --config CONFIG work sync-approvals WORK_ID --project app -agent-workflows --config CONFIG work submit WORK_ID --revision CURRENT_REVISION --project app +agent-workflows --config CONFIG work classify --file /tmp/work-contract.json +agent-workflows --config CONFIG work classify --file /tmp/work-contract.json --changed-from BASE +agent-workflows --config CONFIG work list --brief +agent-workflows --config CONFIG work inspect WORK_ID +agent-workflows --config CONFIG work create --issue 123 +agent-workflows --config CONFIG work create --title "Fix missing-size sorting" --key missing-size-sort +agent-workflows --config CONFIG work propose WORK_ID --revision CURRENT_REVISION --file /tmp/work-contract.json +agent-workflows --config CONFIG work propose WORK_ID --revision CURRENT_REVISION --file /tmp/work-contract.json --publish +agent-workflows --config CONFIG work publish WORK_ID --revision CURRENT_REVISION +agent-workflows --config CONFIG work sync-approvals WORK_ID +agent-workflows --config CONFIG work submit WORK_ID --revision CURRENT_REVISION ``` `classify` needs only the configuration: no database, worker or Work Item. It @@ -33,7 +35,9 @@ argument. Do not interpolate untrusted task text into shell source. `inspect`, `assess`, `create`, `propose`, and `publish` return `work`, `approvals`, and `decision`, plus `approvalComment` when an issue comment approves the awaiting -gate and `monitorUrl`, a link to the Work Item, while the web monitor runs. `list` returns those inspections as an array. `submit` returns +gate and `monitorUrl`, a link to the Work Item, while the web monitor runs. `list` returns those inspections as an array; +`list --brief` returns `id`, `title`, `source`, `track`, `state` and any +`approvalComment` per item. `submit` returns `run`. `work.revision` identifies the complete current contract and observed source. A fresh agent session resumes through these persisted values. diff --git a/tests/work-cli.test.ts b/tests/work-cli.test.ts index c382799..4127eba 100644 --- a/tests/work-cli.test.ts +++ b/tests/work-cli.test.ts @@ -503,6 +503,42 @@ test("work publish creates one GitHub issue for local work and rebinds it", { ); const intake = JSON.parse((await work("create", "--issue", "1")).stdout); assert.equal(intake.work.id, id); + + // From the checkout, the project is implied and a proposal can republish. + await writeFile( + file, + JSON.stringify({ + ...contract, + acceptanceCriteria: ["Retries stop after five attempts"], + }), + ); + const republished = await cli( + root, + env, + "work", + "propose", + id, + "--revision", + result.work.revision, + "--file", + file, + "--publish", + ); + assert.equal(republished.exitCode, 0, republished.stderr); + assert.equal(republished.stdout.trim().split("\n").length, 1); + assert.match(String(issues[0]!.body), /Retries stop after five attempts/); + const brief = JSON.parse( + (await cli(root, env, "work", "list", "--brief")).stdout, + ); + assert.deepEqual(brief, [ + { + id, + title: "Retry webhooks", + source: "https://github.test/fixture/fixture/issues/1", + track: "B", + state: "awaiting-scope-approval", + }, + ]); } finally { server.close(); await once(server, "close"); @@ -637,3 +673,39 @@ test("a missing hosting token falls back to the gh sign-in only for local approv assert.equal(missing.status, "fail"); assert.match(missing.detail, /hosting.tokenFromCli/); }); + +test("work classify reads the contract from standard input", async () => { + const { root, cli } = await cliFixture({ enabled: true }); + const { spawnSync } = await import("node:child_process"); + const { configPath } = await cli(root, {}, "doctor", "--json").then( + (result) => JSON.parse(result.stdout), + ); + const result = spawnSync( + process.execPath, + [ + "--import", + import.meta.resolve("tsx"), + cliPath, + "--config-base-directory", + root, + "--config", + configPath, + "work", + "classify", + "--file", + "-", + ], + { + cwd: root, + encoding: "utf8", + input: JSON.stringify({ + title: "Fix sorting", + intent: "Missing sizes sort last", + acceptanceCriteria: ["Missing sizes sort last"], + assessment: { proposedTrack: "A", complexity: "small" }, + }), + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).track, "A"); +}); diff --git a/tests/work-console.test.ts b/tests/work-console.test.ts index 195a9e7..d90147d 100644 --- a/tests/work-console.test.ts +++ b/tests/work-console.test.ts @@ -9,7 +9,9 @@ import { publishRunnerControl } from "../packages/cli/src/cli-runner-control.js" import { monitorServer } from "../packages/cli/src/web/server.js"; import { configSchema } from "../packages/core/src/config.js"; import { Store } from "../packages/core/src/store.js"; +import { workOperator } from "../packages/core/src/work/operator.js"; import { sdlcPolicySchema } from "../packages/core/src/work/schema.js"; +import { repository } from "./fixtures.js"; import { waitFor } from "./runner-fixtures.js"; import { webRunner } from "./web-fixtures.js"; @@ -139,6 +141,15 @@ test("monitor inspects pending Work Items, approves exact revisions and rejects assert.equal(inspection.decision.state, "awaiting-scope-approval"); assert.equal(inspection.work.revision, visible.revision); assert.equal((await store.runs()).length, 0); + const refused = await api(`/api/work-items/${visible.id}/resubmit`, { + revision: visible.revision, + }); + assert.equal(refused.status, 409); + assert.match((await refused.json()).error, /scope approval/); + assert.equal( + (await api(`/api/work-items/${visible.id}/resubmit`, {})).status, + 400, + ); const endpoint = `/api/work-items/${visible.id}/approve`; assert.equal( ( @@ -367,3 +378,68 @@ test("a #work link opens the browser monitor at that Work Item", { await browser.close(); } }); + +test("the operator resubmits ready work into a project blocked by escalation", { + skip: !databaseUrl, +}, async () => { + const { project } = await repository(); + project.sdlc = sdlcPolicySchema.parse({ enabled: true }); + const store = new Store(databaseUrl!, randomUUID().replaceAll("-", "")); + const state = await mkdtemp(join(tmpdir(), "aw-resubmit-state-")); + try { + await store.initialize(); + await store.registerProject(project.id); + const operator = workOperator({ + store, + stateDirectory: state, + projects: async () => [project], + }); + const contract = { + title: "Sort missing sizes", + intent: "Place missing sizes last", + acceptanceCriteria: ["Missing sizes follow numbers"], + scopePaths: ["src/**"], + assessment: { proposedTrack: "B" as const, complexity: "small" as const }, + verification: { strategy: "Regression test" }, + }; + const work = await store.workItems.create( + project.id, + { kind: "local", key: "resubmit" }, + contract, + ); + await operator.approve(work.id, { gate: "scope", revision: work.revision }); + const first = await store.workItems.submit(work.id, work.revision, project); + await store.patchRun(first.id, { outcome: "blocked" }); + await store.blockProject(project.id, "Scope needs reassessment"); + const revised = await store.workItems.propose(work.id, work.revision, { + ...contract, + acceptanceCriteria: ["Missing sizes follow numbers in both directions"], + }); + assert.equal((await operator.inspect(work.id)).recoverable, false); + await operator.approve(work.id, { + gate: "scope", + revision: revised.revision, + }); + assert.equal((await operator.inspect(work.id)).recoverable, true); + await writeFile(join(project.checkout, "unsaved.txt"), "dirty"); + await assert.rejects( + operator.resubmit(work.id, revised.revision), + /unsaved\.txt|clean|untracked/i, + ); + await rm(join(project.checkout, "unsaved.txt")); + const resubmitted = await operator.resubmit(work.id, revised.revision); + assert.equal(resubmitted.recoverable, false); + assert.equal(resubmitted.runIds.length, 2); + assert.equal((await store.project(project.id)).blocked, null); + await assert.rejects( + workOperator({ store, projects: async () => [project] }).resubmit( + work.id, + revised.revision, + ), + /unavailable/, + ); + } finally { + await store.close(); + await rm(state, { recursive: true, force: true }); + } +}); diff --git a/tests/work-console.test.tsx b/tests/work-console.test.tsx index aef9c6b..61eb85c 100644 --- a/tests/work-console.test.tsx +++ b/tests/work-console.test.tsx @@ -41,6 +41,7 @@ function workItem(): WorkItemView { }, approvalSource: "local-operator", runIds: [], + recoverable: false, }; } @@ -179,3 +180,47 @@ test("terminal Work Items cannot locally approve enterprise gates", async () => view.unmount(); } }); + +test("terminal Work Items resubmit recoverable work after confirmation", async () => { + const { source } = monitorFixture(); + const item: WorkItemView = { + ...workItem(), + decision: { + track: "B", + state: "ready", + reasons: [], + requiredGates: ["scope"], + }, + runIds: ["run-one"], + recoverable: true, + }; + const resubmitted: string[] = []; + source.workItems = async () => [item]; + source.resubmitWork = async (id, revision) => { + resubmitted.push(`${id}@${revision}`); + item.recoverable = false; + return item; + }; + const view = render( + {}} + />, + ); + try { + await until(() => view.lastFrame()!.includes("r resubmit")); + assert.match(view.lastFrame()!.replace(/\s+/g, " "), /press r to resubmit/); + view.stdin.write("r"); + await until(() => view.lastFrame()!.includes("Resubmit after recovery")); + view.stdin.write("\t"); + await settle(); + view.stdin.write("\r"); + await until(() => view.lastFrame()!.includes("Resubmitted")); + assert.deepEqual(resubmitted, [`work-one@${"1".repeat(64)}`]); + } finally { + view.unmount(); + } +});