@kontourai/flow-agents 3.6.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/build/src/builder-flow-run-adapter.d.ts +22 -2
  3. package/build/src/builder-flow-run-adapter.js +93 -28
  4. package/build/src/builder-flow-runtime.d.ts +8 -3
  5. package/build/src/builder-flow-runtime.js +407 -51
  6. package/build/src/cli/assignment-provider.d.ts +4 -7
  7. package/build/src/cli/assignment-provider.js +80 -14
  8. package/build/src/cli/builder-run.js +14 -18
  9. package/build/src/cli/workflow-sidecar.d.ts +28 -4
  10. package/build/src/cli/workflow-sidecar.js +825 -103
  11. package/build/src/cli/workflow.js +301 -43
  12. package/build/src/flow-kit/validate.d.ts +17 -0
  13. package/build/src/flow-kit/validate.js +340 -2
  14. package/build/src/index.js +5 -5
  15. package/build/src/lib/observed-command.d.ts +7 -0
  16. package/build/src/lib/observed-command.js +100 -0
  17. package/build/src/tools/build-universal-bundles.js +53 -3
  18. package/build/src/tools/generate-context-map.js +5 -3
  19. package/context/scripts/hooks/lib/kit-catalog.js +1 -1
  20. package/context/scripts/hooks/stop-goal-fit.js +78 -9
  21. package/docs/context-map.md +22 -20
  22. package/docs/developer-architecture.md +1 -1
  23. package/docs/getting-started.md +9 -1
  24. package/docs/public-workflow-cli.md +76 -7
  25. package/docs/skills-map.md +51 -27
  26. package/docs/spec/builder-flow-runtime.md +75 -40
  27. package/docs/workflow-usage-guide.md +109 -42
  28. package/evals/fixtures/hook-influence/cases.json +2 -2
  29. package/evals/integration/test_builder_entry_enforcement.sh +52 -41
  30. package/evals/integration/test_builder_step_producers.sh +330 -6
  31. package/evals/integration/test_bundle_install.sh +318 -65
  32. package/evals/integration/test_bundle_lifecycle.sh +4 -6
  33. package/evals/integration/test_critique_supersession_roundtrip.sh +21 -8
  34. package/evals/integration/test_current_json_per_actor.sh +12 -0
  35. package/evals/integration/test_dual_emit_flow_step.sh +62 -43
  36. package/evals/integration/test_flowdef_session_activation.sh +145 -25
  37. package/evals/integration/test_flowdef_session_history_preservation.sh +23 -21
  38. package/evals/integration/test_gate_lockdown.sh +3 -5
  39. package/evals/integration/test_goal_fit_hook.sh +60 -2
  40. package/evals/integration/test_install_merge.sh +18 -8
  41. package/evals/integration/test_liveness_verdict.sh +14 -17
  42. package/evals/integration/test_phase_map_and_gate_claim.sh +63 -38
  43. package/evals/integration/test_public_workflow_cli.sh +334 -14
  44. package/evals/integration/test_pull_work_liveness_preflight.sh +22 -66
  45. package/evals/integration/test_sidecar_field_preservation.sh +36 -11
  46. package/evals/integration/test_workflow_sidecar_writer.sh +277 -44
  47. package/evals/integration/test_workflow_steering_hook.sh +15 -38
  48. package/evals/run.sh +2 -0
  49. package/evals/static/test_builder_skill_coherence.sh +247 -0
  50. package/evals/static/test_library_exports.sh +5 -2
  51. package/evals/static/test_universal_bundles.sh +44 -1
  52. package/evals/static/test_workflow_skills.sh +13 -325
  53. package/kits/builder/flows/build.flow.json +22 -0
  54. package/kits/builder/flows/shape.flow.json +9 -9
  55. package/kits/builder/kit.json +70 -16
  56. package/kits/builder/skills/builder-shape/SKILL.md +75 -75
  57. package/kits/builder/skills/continue-work/SKILL.md +45 -106
  58. package/kits/builder/skills/deliver/SKILL.md +96 -442
  59. package/kits/builder/skills/design-probe/SKILL.md +40 -139
  60. package/kits/builder/skills/evidence-gate/SKILL.md +59 -201
  61. package/kits/builder/skills/execute-plan/SKILL.md +54 -125
  62. package/kits/builder/skills/fix-bug/SKILL.md +42 -132
  63. package/kits/builder/skills/gate-review/SKILL.md +60 -211
  64. package/kits/builder/skills/idea-to-backlog/SKILL.md +63 -244
  65. package/kits/builder/skills/learning-review/SKILL.md +63 -170
  66. package/kits/builder/skills/pickup-probe/SKILL.md +54 -111
  67. package/kits/builder/skills/plan-work/SKILL.md +51 -185
  68. package/kits/builder/skills/pull-work/SKILL.md +136 -485
  69. package/kits/builder/skills/release-readiness/SKILL.md +66 -107
  70. package/kits/builder/skills/review-work/SKILL.md +89 -176
  71. package/kits/builder/skills/tdd-workflow/SKILL.md +53 -147
  72. package/kits/builder/skills/verify-work/SKILL.md +101 -113
  73. package/package.json +2 -2
  74. package/packaging/README.md +1 -1
  75. package/scripts/README.md +1 -1
  76. package/scripts/hooks/lib/kit-catalog.js +1 -1
  77. package/scripts/hooks/stop-goal-fit.js +78 -9
  78. package/scripts/install-codex-home.sh +63 -23
  79. package/scripts/install-owned-files.js +18 -2
  80. package/src/builder-flow-run-adapter.ts +118 -32
  81. package/src/builder-flow-runtime.ts +426 -64
  82. package/src/cli/assignment-provider-lock.test.mjs +83 -0
  83. package/src/cli/assignment-provider.ts +75 -14
  84. package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
  85. package/src/cli/builder-flow-runtime.test.mjs +436 -18
  86. package/src/cli/builder-run.ts +3 -9
  87. package/src/cli/kit-metadata-security.test.mjs +232 -6
  88. package/src/cli/public-api.test.mjs +15 -0
  89. package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
  90. package/src/cli/workflow-sidecar.ts +746 -103
  91. package/src/cli/workflow.ts +288 -41
  92. package/src/flow-kit/validate.ts +320 -2
  93. package/src/index.ts +6 -5
  94. package/src/lib/observed-command.ts +96 -0
  95. package/src/tools/build-universal-bundles.ts +51 -3
  96. package/src/tools/generate-context-map.ts +5 -3
@@ -3,13 +3,17 @@ import * as path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { createRequire } from "node:module";
5
5
  import { isDeepStrictEqual } from "node:util";
6
- import { loadBuilderBuildRun } from "../builder-flow-run-adapter.js";
6
+ import { fileURLToPath } from "node:url";
7
+ import { validateDefinition } from "@kontourai/flow";
8
+ import { loadBuilderFlowRun } from "../builder-flow-run-adapter.js";
9
+ import { inspectBuilderFlowSession, recoverBuilderFlowSession, syncBuilderFlowSession } from "../builder-flow-runtime.js";
7
10
  import { flowAgentsPackageRoot, flowAgentsPackageVersion } from "../lib/package-version.js";
8
11
  import { pinnedFlowAgentsCommand } from "../lib/pinned-cli-command.js";
9
12
  import { defaultArtifactRootForRead, flowAgentsArtifactRoot } from "../lib/local-artifact-root.js";
10
- import { flagBool, flagString, parseArgs } from "../lib/args.js";
13
+ import { flagBool, flagList, flagString, parseArgs } from "../lib/args.js";
11
14
  import { main as builderRun } from "./builder-run.js";
12
- import { currentWorkflowSessionDir, main as workflowWriter, WORKFLOW_WRITER_CONTRACT_VERSION } from "./workflow-sidecar.js";
15
+ import { currentWorkflowSessionDir, isMeaningfulTestCommand, mainFromPublicWorkflow, WORKFLOW_WRITER_CONTRACT_VERSION } from "./workflow-sidecar.js";
16
+ import { resolveCurrentAssignmentActor, withSubjectLock } from "./assignment-provider.js";
13
17
 
14
18
  type JsonRecord = Record<string, unknown>;
15
19
 
@@ -18,7 +22,7 @@ const PACKAGE_ROOT = flowAgentsPackageRoot();
18
22
  const REQUIRE = createRequire(import.meta.url);
19
23
  const PACKAGE_METADATA = readJsonFile(path.join(PACKAGE_ROOT, "package.json"), "Flow Agents package metadata");
20
24
  const CLI_VERSION = flowAgentsPackageVersion();
21
- const PUBLIC_VERBS = ["start", "status", "evidence", "pause", "resume", "release", "cancel", "archive", "doctor"] as const;
25
+ const PUBLIC_VERBS = ["start", "status", "evidence", "critique", "pause", "resume", "release", "cancel", "archive", "doctor"] as const;
22
26
 
23
27
  function usage(): void {
24
28
  console.log(`Usage: flow-agents workflow <verb> [options]
@@ -27,6 +31,7 @@ Public workflow verbs:
27
31
  start Start or resume a workflow for a Work Item.
28
32
  status Show the current canonical run and projected next action.
29
33
  evidence Record evidence for the current Flow gate and synchronize it.
34
+ critique Record review critique directly into the current trust bundle.
30
35
  pause Pause the current run as its assignment actor.
31
36
  resume Resume the current paused run as its assignment actor.
32
37
  release Release the current assignment without canceling the run.
@@ -55,6 +60,7 @@ export async function main(argv: string[]): Promise<number> {
55
60
  const sessionDir = resolveSessionDir(parsed.flags);
56
61
  if (verb === "status") return status(sessionDir, flagBool(parsed.flags, "json"));
57
62
  if (verb === "evidence") return evidence(sessionDir, argv.slice(1), flagBool(parsed.flags, "json"));
63
+ if (verb === "critique") return critique(sessionDir, argv.slice(1), flagBool(parsed.flags, "json"));
58
64
 
59
65
  const forwarded = stripPublicFlags(argv.slice(1), new Set(["artifact-root", "session-dir", "json"]));
60
66
  if (verb === "release" && !flagString(parsed.flags, "reason")) throw new Error("workflow release requires --reason <text>");
@@ -63,14 +69,30 @@ export async function main(argv: string[]): Promise<number> {
63
69
 
64
70
  async function start(argv: string[]): Promise<number> {
65
71
  const parsed = parseArgs(argv);
66
- assertOnlyFlags(parsed.flags, new Set(["flow", "work-item", "task-slug", "artifact-root", "source-request", "summary", "title", "criterion"]), "workflow start");
72
+ assertOnlyFlags(parsed.flags, new Set(["flow", "work-item", "task-slug", "artifact-root", "source-request", "summary", "title", "criterion", "assignment-provider", "effective-state-json"]), "workflow start");
67
73
  const flow = flagString(parsed.flags, "flow", "builder.build")!;
68
- if (flow !== "builder.build") throw new Error("workflow start currently supports only --flow builder.build");
74
+ if (flow !== "builder.build" && flow !== "builder.shape") throw new Error("workflow start supports only --flow builder.build or builder.shape");
69
75
  const workItem = flagString(parsed.flags, "work-item");
70
- if (!workItem) throw new Error("workflow start requires --work-item <owner/repo#id>");
71
76
  const artifactRoot = path.resolve(flagString(parsed.flags, "artifact-root", flowAgentsArtifactRoot())!);
72
77
  const taskSlug = flagString(parsed.flags, "task-slug");
73
- if (workItem.startsWith("local:")) {
78
+ if (flow === "builder.shape") {
79
+ if (!taskSlug || !isSafeSlug(taskSlug)) throw new Error("workflow start --flow builder.shape requires an explicit safe --task-slug");
80
+ if (workItem) throw new Error("workflow start --flow builder.shape creates a local Work Item; omit --work-item");
81
+ } else if (!workItem) {
82
+ throw new Error("workflow start requires --work-item <provider-ref>");
83
+ }
84
+ const assignmentProvider = flagString(parsed.flags, "assignment-provider", flow === "builder.shape" || workItem?.startsWith("local:") ? "local-file" : undefined);
85
+ const effectiveStateJson = flagString(parsed.flags, "effective-state-json");
86
+ if (flow === "builder.build" && workItem && !workItem.startsWith("local:") && !assignmentProvider) {
87
+ throw new Error("workflow start requires --assignment-provider <kind> for a provider-backed Work Item; provider identity is never inferred from its reference");
88
+ }
89
+ if (assignmentProvider !== "local-file" && !effectiveStateJson) {
90
+ throw new Error(`workflow start requires --effective-state-json <path> for assignment provider ${assignmentProvider}`);
91
+ }
92
+ if (assignmentProvider === "local-file" && effectiveStateJson) {
93
+ throw new Error("workflow start --effective-state-json is only valid for a non-local assignment provider");
94
+ }
95
+ if (workItem?.startsWith("local:")) {
74
96
  const localSlug = workItem.slice("local:".length);
75
97
  if (!taskSlug || taskSlug !== localSlug || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(localSlug)) {
76
98
  throw new Error("local Work Item retries require the exact existing --task-slug binding");
@@ -80,29 +102,56 @@ async function start(argv: string[]): Promise<number> {
80
102
  if (localRecord.id !== taskSlug || (localRecord.source_provider as JsonRecord | undefined)?.kind !== "local") {
81
103
  throw new Error("local Work Item retry does not match the existing session binding");
82
104
  }
83
- } else if (taskSlug) {
105
+ } else if (taskSlug && flow !== "builder.shape") {
84
106
  throw new Error("--task-slug is reserved for an existing local Work Item retry");
85
107
  }
86
- const sourceRequest = flagString(parsed.flags, "source-request", `Start ${flow} for ${workItem}`)!;
87
- const summary = flagString(parsed.flags, "summary", `Deliver ${workItem} through ${flow}.`)!;
108
+ const existingSlug = taskSlug ?? (workItem ? workItemSlug(workItem) : null);
109
+ if (existingSlug && fs.existsSync(path.join(artifactRoot, existingSlug, "state.json"))) {
110
+ try {
111
+ const existing = await loadBuilderFlowRun({ cwd: path.dirname(path.dirname(artifactRoot)), runId: existingSlug });
112
+ if (existing.definitionId !== flow) {
113
+ throw new Error(`workflow start cannot resume ${existing.definitionId} as ${flow}; local shape sessions are not build retries. Create or claim a provider Work Item for the builder.build handoff.`);
114
+ }
115
+ } catch (error) {
116
+ if ((error as { code?: string }).code !== "flow.run_location.not_found") throw error;
117
+ }
118
+ }
119
+ if (flow === "builder.build" && workItem && !workItem.startsWith("local:")) {
120
+ const slug = workItemSlug(workItem);
121
+ const report = path.join(artifactRoot, slug, `${slug}--pull-work.md`);
122
+ try {
123
+ const stat = fs.lstatSync(report);
124
+ if (stat.isSymbolicLink() || !stat.isFile() || !fs.readFileSync(report, "utf8").includes(workItem)) {
125
+ throw new Error("invalid");
126
+ }
127
+ } catch {
128
+ throw new Error(`workflow start requires concrete pull-work selection evidence at ${report} naming ${workItem} before it can produce selected-work`);
129
+ }
130
+ }
131
+ const sourceRequest = flagString(parsed.flags, "source-request", `Start ${flow} for ${workItem ?? `local:${taskSlug}`}`)!;
132
+ const summary = flagString(parsed.flags, "summary", `Deliver ${workItem ?? `local:${taskSlug}`} through ${flow}.`)!;
88
133
  const forwarded = keepFlags(argv, new Set(["title", "criterion"]));
89
- return workflowWriter([
134
+ return mainFromPublicWorkflow([
90
135
  "ensure-session",
91
136
  "--artifact-root", artifactRoot,
92
137
  "--flow-id", flow,
93
- "--work-item", workItem,
138
+ ...(workItem ? ["--work-item", workItem] : []),
94
139
  ...(taskSlug ? ["--task-slug", taskSlug] : []),
140
+ ...(assignmentProvider ? ["--assignment-provider", assignmentProvider] : []),
141
+ ...(effectiveStateJson ? ["--effective-state-json", path.resolve(effectiveStateJson)] : []),
95
142
  "--source-request", sourceRequest,
96
143
  "--summary", summary,
97
144
  ...forwarded,
98
145
  ]);
99
146
  }
100
147
 
148
+ function workItemSlug(workItem: string): string {
149
+ return workItem.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
150
+ }
151
+
101
152
  async function status(sessionDir: string, json: boolean): Promise<number> {
102
- const sidecar = readJsonFile(path.join(sessionDir, "state.json"), "workflow state");
103
- const slug = String(sidecar.task_slug ?? path.basename(sessionDir));
104
- const projectRoot = path.dirname(path.dirname(path.dirname(sessionDir)));
105
- const result = await loadBuilderBuildRun({ cwd: projectRoot, runId: slug });
153
+ const inspected = await inspectBuilderFlowSession({ sessionDir });
154
+ const result = inspected.run;
106
155
  const report = {
107
156
  run_id: result.runId,
108
157
  definition_id: result.definitionId,
@@ -110,7 +159,7 @@ async function status(sessionDir: string, json: boolean): Promise<number> {
110
159
  status: result.state.status,
111
160
  current_step: result.state.current_step,
112
161
  session_dir: sessionDir,
113
- next_action: sidecar.next_action ?? null,
162
+ next_action: inspected.projection.next_action ?? null,
114
163
  };
115
164
  if (json) console.log(JSON.stringify(report));
116
165
  else {
@@ -124,27 +173,114 @@ async function status(sessionDir: string, json: boolean): Promise<number> {
124
173
 
125
174
  async function evidence(sessionDir: string, argv: string[], json: boolean): Promise<number> {
126
175
  const parsed = parseArgs(argv);
127
- assertOnlyFlags(parsed.flags, new Set(["artifact-root", "session-dir", "json", "expectation", "status", "summary", "evidence-ref-json", "accepted-gap-reason", "waived-by"]), "workflow evidence");
176
+ assertOnlyFlags(parsed.flags, new Set(["artifact-root", "session-dir", "json", "expectation", "status", "summary", "route-reason", "evidence-ref-json", "criterion-json", "accepted-gap-reason", "waived-by", "command"]), "workflow evidence");
128
177
  if (!flagString(parsed.flags, "expectation")) throw new Error("workflow evidence requires --expectation <gate-expectation-id>");
129
178
  if (!flagString(parsed.flags, "status")) throw new Error("workflow evidence requires --status <pass|fail|not_verified>");
130
179
  if (!flagString(parsed.flags, "summary")) throw new Error("workflow evidence requires --summary <text>");
131
180
  const forwarded = stripPublicFlags(argv, new Set(["artifact-root", "session-dir", "json"]));
132
- await workflowWriter(["record-gate-claim", sessionDir, ...forwarded]);
133
- const sidecar = readJsonFile(path.join(sessionDir, "state.json"), "workflow state");
134
- const projectRoot = path.dirname(path.dirname(path.dirname(sessionDir)));
135
- const result = await loadBuilderBuildRun({ cwd: projectRoot, runId: String(sidecar.task_slug ?? path.basename(sessionDir)) });
136
- const report = {
137
- run_id: result.runId,
138
- status: result.state.status,
139
- current_step: result.state.current_step,
140
- attached: result.attachedEvidence.length > 0,
141
- next_action: sidecar.next_action ?? null,
142
- };
181
+ const { slug, projectRoot } = readBoundSession(sessionDir);
182
+ const commands = flagList(parsed.flags, "command");
183
+ if (Object.hasOwn(parsed.flags, "command") && commands.length === 0) {
184
+ throw new Error("workflow evidence --command requires a shell command value");
185
+ }
186
+ const requiresTestEvidence = flagString(parsed.flags, "expectation") === "tests-evidence" && flagString(parsed.flags, "status") === "pass";
187
+ // Argument and command-shape rejection must be read-only. Recovery below may
188
+ // repair stale projections, so it runs only after every command is accepted.
189
+ assertRunnableEvidenceCommands(commands, projectRoot, requiresTestEvidence);
190
+ const report = await withSubjectLock(path.dirname(sessionDir), slug, async () => {
191
+ // Validate the owner after the lock is held, then keep the lock through command
192
+ // execution, evidence recording, and postcondition capture so assignment and
193
+ // session state cannot change mid-invocation.
194
+ const caller = assertMatchingAssignmentActor(sessionDir, slug);
195
+ const repaired = await recoverBuilderFlowSession({ sessionDir });
196
+ const beforeEvidence = manifestEvidenceIdentity(repaired.run.manifest);
197
+ await mainFromPublicWorkflow([
198
+ "record-gate-claim",
199
+ sessionDir,
200
+ ...forwarded,
201
+ "--actor",
202
+ caller.actorKey,
203
+ ]);
204
+
205
+ const synchronized = await syncBuilderFlowSession({ sessionDir });
206
+
207
+ const digest = fileSha256(path.join(sessionDir, "trust.bundle"));
208
+ const run = await loadBuilderFlowRun({ cwd: repaired.projectRoot, runId: slug });
209
+ const afterEvidence = manifestEvidenceIdentity(run.manifest);
210
+ const beforeIds = new Set(beforeEvidence.map((entry) => entry.id));
211
+ const newEvidence = afterEvidence.filter((entry) => !beforeIds.has(entry.id));
212
+ if (synchronized.attached && (newEvidence.length !== 1 || newEvidence[0]?.sha256 !== digest)) {
213
+ throw new Error("workflow evidence did not attach exactly this invocation's resulting trust.bundle digest");
214
+ }
215
+ if (!synchronized.attached && newEvidence.length !== 0) {
216
+ throw new Error("workflow evidence changed the canonical manifest while synchronization reported no attachment");
217
+ }
218
+ const updatedSidecar = readBoundSession(sessionDir).sidecar;
219
+ return immutableReport({
220
+ run_id: run.runId,
221
+ status: run.state.status,
222
+ current_step: run.state.current_step,
223
+ attached: synchronized.attached,
224
+ awaiting_evidence: !synchronized.attached,
225
+ next_action: updatedSidecar.next_action ?? null,
226
+ });
227
+ });
143
228
  if (json) console.log(JSON.stringify(report));
144
- else console.log(`Recorded evidence; canonical run is ${report.status} at ${report.current_step}.`);
229
+ else console.log(report.attached
230
+ ? `Recorded evidence; canonical run is ${report.status} at ${report.current_step}.`
231
+ : `Recorded evidence; canonical run is awaiting the remaining gate expectations at ${report.current_step}.`);
145
232
  return 0;
146
233
  }
147
234
 
235
+ async function critique(sessionDir: string, argv: string[], json: boolean): Promise<number> {
236
+ const parsed = parseArgs(argv);
237
+ assertOnlyFlags(parsed.flags, new Set(["artifact-root", "session-dir", "json", "id", "verdict", "summary", "artifact-ref", "finding-json", "lane-json", "timestamp"]), "workflow critique");
238
+ if (!flagString(parsed.flags, "summary")) throw new Error("workflow critique requires --summary <text>");
239
+ if (Object.hasOwn(parsed.flags, "reviewer")) throw new Error("workflow critique derives reviewer identity from the authenticated assignment actor; --reviewer is not accepted");
240
+ const { slug, projectRoot } = readBoundSession(sessionDir);
241
+ const forwarded = stripPublicFlags(argv, new Set(["artifact-root", "session-dir", "json"]));
242
+ const report = await withSubjectLock(path.dirname(sessionDir), slug, async () => {
243
+ const caller = assertDistinctReviewActor(sessionDir, slug);
244
+ const current = await loadBuilderFlowRun({ cwd: projectRoot, runId: slug });
245
+ if (current.definitionId !== "builder.build" || current.state.current_step !== "verify") throw new Error("workflow critique is allowed only for the canonical builder.build verify step");
246
+ const beforeManifest = JSON.parse(JSON.stringify(current.manifest)) as JsonRecord;
247
+ const beforeTrustBundle = optionalFileDigest(path.join(sessionDir, "trust.bundle"));
248
+ const legacySidecars = ["critique.json", "evidence.json"].map((name) => ({ name, digest: optionalFileDigest(path.join(sessionDir, name)) }));
249
+ await mainFromPublicWorkflow(["record-critique", sessionDir, ...forwarded, "--reviewer", caller.actorKey]);
250
+ const result = await recoverBuilderFlowSession({ sessionDir });
251
+ const digest = fileSha256(path.join(sessionDir, "trust.bundle"));
252
+ if (!isDeepStrictEqual(result.run.manifest, beforeManifest)) {
253
+ throw new Error("workflow critique must not attach or otherwise mutate the Flow manifest");
254
+ }
255
+ if (beforeTrustBundle === digest) {
256
+ throw new Error("workflow critique did not change trust.bundle");
257
+ }
258
+ if (legacySidecars.some(({ name, digest: legacyDigest }) => optionalFileDigest(path.join(sessionDir, name)) !== legacyDigest)) {
259
+ throw new Error("workflow critique must persist only through trust.bundle");
260
+ }
261
+ return immutableReport({ run_id: slug, recorded: true });
262
+ });
263
+ if (json) console.log(JSON.stringify(report));
264
+ else console.log("Recorded critique in the trust bundle.");
265
+ return 0;
266
+ }
267
+
268
+ function assertRunnableEvidenceCommands(commands: string[], projectRoot: string, requiresTestEvidence: boolean): void {
269
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/runnable-command.js");
270
+ const { isRunnableCommandText } = REQUIRE(helperPath) as { isRunnableCommandText: (text: string) => boolean };
271
+ if (commands.length > 1 && new Set(commands).size !== commands.length) {
272
+ throw new Error("workflow evidence --command values must be unique because observations are matched by exact command text");
273
+ }
274
+ for (const command of commands) {
275
+ if (!isRunnableCommandText(command)) {
276
+ throw new Error(`workflow evidence --command ${JSON.stringify(command)} is not a runnable shell command — prose belongs in --summary, which is never executed.`);
277
+ }
278
+ if (requiresTestEvidence && !isMeaningfulTestCommand(command, projectRoot)) {
279
+ throw new Error("workflow evidence tests-evidence command must resolve through a non-vacuous package script or a known test/check/verify/eval runner or project-local test path; shell wrappers, no-ops, version/help commands, and arbitrary node -e commands are not evidence");
280
+ }
281
+ }
282
+ }
283
+
148
284
  function resolveSessionDir(flags: ReturnType<typeof parseArgs>["flags"]): string {
149
285
  const explicit = flagString(flags, "session-dir");
150
286
  if (explicit) return validateCanonicalSessionDir(path.resolve(explicit));
@@ -167,11 +303,16 @@ function doctor(argv: string[]): number {
167
303
  const install = readOptionalJson(installFile);
168
304
  const state = readCurrentState(artifactRoot);
169
305
  const packageKit = readOptionalJson(path.join(packageRoot, "kits", "builder", "kit.json"));
170
- const packageFlow = readOptionalJson(path.join(packageRoot, "kits", "builder", "flows", "build.flow.json"));
171
306
  const installedKitFile = path.join(projectRoot, "kits", "builder", "kit.json");
172
- const installedFlowFile = path.join(projectRoot, "kits", "builder", "flows", "build.flow.json");
173
307
  const installedKit = readOptionalJson(installedKitFile);
174
- const installedFlow = readOptionalJson(installedFlowFile);
308
+ const definitions = [
309
+ { id: "builder.build", file: "build.flow.json" },
310
+ { id: "builder.shape", file: "shape.flow.json" },
311
+ ].map(({ id, file }) => {
312
+ const packageFile = path.join(packageRoot, "kits", "builder", "flows", file);
313
+ const installedFile = path.join(projectRoot, "kits", "builder", "flows", file);
314
+ return { id, packageFile, installedFile, packageDefinition: readOptionalJson(packageFile), installedDefinition: readOptionalJson(installedFile) };
315
+ });
175
316
  const resolvedFlowPackage = readOptionalJson(resolveDependencyPackageJson("@kontourai/flow"));
176
317
  const installedVersion = typeof install?.version === "string" ? install.version : null;
177
318
  const staleInstall = installedVersion !== null && installedVersion !== cliVersion;
@@ -187,18 +328,35 @@ function doctor(argv: string[]): number {
187
328
  if (!installIntegrity.ok) warnings.push(`Installed hook/writer assets failed integrity verification: ${installIntegrity.problems.join("; ")}. Run: ${remediation}`);
188
329
  if (localDependency?.version && localDependency.version !== cliVersion) warnings.push(`Repository-local Flow Agents ${String(localDependency.version)} differs from executing CLI ${cliVersion}; keep automation explicitly versioned.`);
189
330
  if (activeKitIds.includes("builder") && !installedKit) warnings.push(`Activated Builder Kit is missing at ${installedKitFile}. Run: ${remediation}`);
190
- if (activeKitIds.includes("builder") && !installedFlow) warnings.push(`Activated builder.build definition is missing at ${installedFlowFile}. Run: ${remediation}`);
331
+ for (const definition of definitions) {
332
+ if (activeKitIds.includes("builder") && !definition.installedDefinition) {
333
+ warnings.push(`Activated ${definition.id} definition is missing at ${definition.installedFile}. Run: ${remediation}`);
334
+ continue;
335
+ }
336
+ if (!definition.installedDefinition) continue;
337
+ const validationError = validateFlowDefinition(definition.id, definition.installedDefinition);
338
+ if (validationError) warnings.push(`Installed ${definition.id} definition is invalid: ${validationError}. Run: ${remediation}`);
339
+ if (definition.installedDefinition.id !== definition.packageDefinition?.id) {
340
+ warnings.push(`Installed ${definition.id} definition id ${String(definition.installedDefinition.id)} differs from CLI definition ${String(definition.packageDefinition?.id)}. Run: ${remediation}`);
341
+ }
342
+ if (definition.installedDefinition.version !== definition.packageDefinition?.version) {
343
+ warnings.push(`Installed ${definition.id} version ${String(definition.installedDefinition.version)} differs from CLI version ${String(definition.packageDefinition?.version)}. Run: ${remediation}`);
344
+ } else if (fileSha256(definition.installedFile) !== fileSha256(definition.packageFile)) {
345
+ warnings.push(`Installed ${definition.id} content differs from Flow Agents ${cliVersion}. Run: ${remediation}`);
346
+ }
347
+ }
191
348
  if (installedKit && installedKit.schema_version !== packageKit?.schema_version) {
192
349
  warnings.push(`Installed Builder Kit schema ${String(installedKit.schema_version)} differs from CLI schema ${String(packageKit?.schema_version)}. Run: ${remediation}`);
193
350
  }
194
351
  if (installedKit && fileSha256(installedKitFile) !== fileSha256(path.join(packageRoot, "kits", "builder", "kit.json"))) {
195
352
  warnings.push(`Installed Builder Kit content differs from Flow Agents ${cliVersion}. Run: ${remediation}`);
196
353
  }
197
- if (installedFlow && installedFlow.version !== packageFlow?.version) {
198
- warnings.push(`Installed builder.build version ${String(installedFlow.version)} differs from CLI version ${String(packageFlow?.version)}. Run: ${remediation}`);
199
- }
200
- if (state?.flow_run && (state.flow_run as JsonRecord).definition_version !== packageFlow?.version) {
201
- warnings.push(`Current run uses builder.build@${String((state.flow_run as JsonRecord).definition_version)} while CLI resolves ${String(packageFlow?.version)}; recover or migrate before mutation.`);
354
+ const currentRun = state?.flow_run && typeof state.flow_run === "object" ? state.flow_run as JsonRecord : null;
355
+ const currentDefinition = definitions.find((definition) => definition.id === currentRun?.definition_id);
356
+ if (currentRun?.definition_id && !currentDefinition) {
357
+ warnings.push(`Current run uses unsupported Flow definition ${String(currentRun.definition_id)}; recover or migrate before mutation.`);
358
+ } else if (currentRun && currentDefinition && currentRun.definition_version !== currentDefinition.packageDefinition?.version) {
359
+ warnings.push(`Current run uses ${currentDefinition.id}@${String(currentRun.definition_version)} while CLI resolves ${currentDefinition.id}@${String(currentDefinition.packageDefinition?.version)}; recover or migrate before mutation.`);
202
360
  }
203
361
  if (state && state.schema_version !== "1.0") warnings.push(`Artifact schema ${String(state.schema_version)} is unsupported; recreate or migrate the session with CLI ${cliVersion}.`);
204
362
  const trustBundleSchema = readCurrentTrustBundleSchema(artifactRoot);
@@ -234,7 +392,13 @@ function doctor(argv: string[]): number {
234
392
  source: installedKit ? installedKitFile : path.join(packageRoot, "kits", "builder", "kit.json"),
235
393
  },
236
394
  flow_runtime: { package_version: resolvedFlowVersion, expected_range: expectedFlowRange, compatible: resolvedFlowVersion ? flowVersionCompatible(resolvedFlowVersion, expectedFlowRange) : false },
237
- definition: { id: (state?.flow_run as JsonRecord | undefined)?.definition_id ?? packageFlow?.id ?? null, version: (state?.flow_run as JsonRecord | undefined)?.definition_version ?? packageFlow?.version ?? null },
395
+ definition: { id: currentRun?.definition_id ?? definitions[0]!.packageDefinition?.id ?? null, version: currentRun?.definition_version ?? definitions[0]!.packageDefinition?.version ?? null },
396
+ definitions: definitions.map((definition) => ({
397
+ id: definition.id,
398
+ resolved_version: definition.packageDefinition?.version ?? null,
399
+ installed_version: definition.installedDefinition?.version ?? null,
400
+ installed_valid: definition.installedDefinition ? validateFlowDefinition(definition.id, definition.installedDefinition) === null : false,
401
+ })),
238
402
  artifact: {
239
403
  state_schema_version: state?.schema_version ?? null,
240
404
  trust_bundle_schema_version: trustBundleSchema,
@@ -288,6 +452,89 @@ function readOptionalJson(file: string): JsonRecord | null {
288
452
  }
289
453
  }
290
454
 
455
+ function validateFlowDefinition(expectedId: string, definition: JsonRecord): string | null {
456
+ if (definition.id !== expectedId) return `expected id ${expectedId}, received ${String(definition.id)}`;
457
+ try {
458
+ validateDefinition(definition as never);
459
+ return null;
460
+ } catch (error) {
461
+ return error instanceof Error ? error.message : String(error);
462
+ }
463
+ }
464
+
465
+ function isSafeSlug(value: string): boolean {
466
+ return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
467
+ }
468
+
469
+ function readBoundSession(sessionDir: string): { sidecar: JsonRecord; slug: string; projectRoot: string } {
470
+ const slug = path.basename(sessionDir);
471
+ if (!isSafeSlug(slug)) throw new Error("workflow session basename must be a safe task slug");
472
+ const sidecar = readJsonFile(path.join(sessionDir, "state.json"), "workflow state");
473
+ if (sidecar.task_slug !== slug) throw new Error("workflow state task_slug must exactly match the safe session basename");
474
+ return { sidecar, slug, projectRoot: path.dirname(path.dirname(path.dirname(sessionDir))) };
475
+ }
476
+
477
+ function readAssignment(sessionDir: string, slug: string): JsonRecord {
478
+ const artifactRoot = path.dirname(sessionDir);
479
+ const assignmentRoot = path.join(artifactRoot, "assignment");
480
+ const stat = fs.lstatSync(assignmentRoot);
481
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("workflow assignment directory must be a non-symlink directory");
482
+ const file = path.join(assignmentRoot, `${slug}.json`);
483
+ const fileStat = fs.lstatSync(file);
484
+ if (fileStat.isSymbolicLink() || !fileStat.isFile()) throw new Error("workflow assignment must be a non-symlink regular file");
485
+ return readJsonFile(file, "workflow assignment");
486
+ }
487
+
488
+ function assertMatchingAssignmentActor(sessionDir: string, slug: string): ReturnType<typeof resolveCurrentAssignmentActor> {
489
+ const assignment = readActiveAssignment(sessionDir, slug);
490
+ const caller = resolveCurrentAssignmentActor();
491
+ const normalizeActor = (value: unknown): JsonRecord | null => value && typeof value === "object" ? { ...(value as JsonRecord), human: (value as JsonRecord).human ?? null } : null;
492
+ if (assignment.actor_key !== caller.actorKey || !isDeepStrictEqual(normalizeActor(assignment.actor), normalizeActor(caller.actor))) {
493
+ throw new Error("workflow mutation requires the session's active, matching assignment actor");
494
+ }
495
+ return caller;
496
+ }
497
+
498
+ function readActiveAssignment(sessionDir: string, slug: string): JsonRecord {
499
+ const assignment = readAssignment(sessionDir, slug);
500
+ if (assignment.status !== "claimed" || assignment.artifact_dir !== slug || typeof assignment.actor_key !== "string" || !assignment.actor_key || !assignment.actor || typeof assignment.actor !== "object" || Array.isArray(assignment.actor)) {
501
+ throw new Error("workflow mutation requires the session's active implementation assignment");
502
+ }
503
+ return assignment;
504
+ }
505
+
506
+ function assertDistinctReviewActor(sessionDir: string, slug: string): ReturnType<typeof resolveCurrentAssignmentActor> {
507
+ const assignment = readActiveAssignment(sessionDir, slug);
508
+ const caller = resolveCurrentAssignmentActor();
509
+ if (assignment.actor_key === caller.actorKey) {
510
+ throw new Error("workflow critique requires a reviewer identity distinct from the active implementation assignment actor");
511
+ }
512
+ return caller;
513
+ }
514
+
515
+ function immutableReport<T>(value: T): T {
516
+ if (!value || typeof value !== "object") return value;
517
+ for (const nested of Object.values(value as Record<string, unknown>)) immutableReport(nested);
518
+ return Object.freeze(value);
519
+ }
520
+
521
+ function manifestEvidenceIdentity(manifest: JsonRecord): Array<{ id: string; sha256: string }> {
522
+ const evidence = Array.isArray(manifest.evidence) ? manifest.evidence : [];
523
+ return evidence
524
+ .flatMap((entry) => entry && typeof entry === "object"
525
+ && typeof (entry as JsonRecord).id === "string"
526
+ && typeof (entry as JsonRecord).sha256 === "string"
527
+ ? [{ id: String((entry as JsonRecord).id), sha256: String((entry as JsonRecord).sha256) }]
528
+ : []);
529
+ }
530
+
531
+ function optionalFileDigest(file: string): string | null {
532
+ try { return fileSha256(file); } catch (error) {
533
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
534
+ throw error;
535
+ }
536
+ }
537
+
291
538
  function stripPublicFlags(argv: string[], removed: Set<string>): string[] {
292
539
  const result: string[] = [];
293
540
  for (let index = 0; index < argv.length; index += 1) {