@kontourai/flow-agents 3.5.0 → 3.7.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 (111) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +15 -0
  3. package/build/src/builder-flow-run-adapter.d.ts +32 -3
  4. package/build/src/builder-flow-run-adapter.js +113 -20
  5. package/build/src/builder-flow-runtime.d.ts +26 -3
  6. package/build/src/builder-flow-runtime.js +502 -49
  7. package/build/src/builder-lifecycle-authority.d.ts +35 -0
  8. package/build/src/builder-lifecycle-authority.js +219 -0
  9. package/build/src/cli/assignment-provider.d.ts +14 -7
  10. package/build/src/cli/assignment-provider.js +128 -65
  11. package/build/src/cli/builder-run.js +46 -9
  12. package/build/src/cli/workflow-artifact-cleanup-audit.js +3 -0
  13. package/build/src/cli/workflow-sidecar.d.ts +30 -3
  14. package/build/src/cli/workflow-sidecar.js +825 -99
  15. package/build/src/cli/workflow.d.ts +2 -0
  16. package/build/src/cli/workflow.js +769 -0
  17. package/build/src/cli.js +2 -0
  18. package/build/src/flow-kit/validate.d.ts +17 -0
  19. package/build/src/flow-kit/validate.js +340 -2
  20. package/build/src/index.d.ts +4 -0
  21. package/build/src/index.js +7 -5
  22. package/build/src/lib/observed-command.d.ts +7 -0
  23. package/build/src/lib/observed-command.js +100 -0
  24. package/build/src/lib/package-version.d.ts +2 -0
  25. package/build/src/lib/package-version.js +13 -0
  26. package/build/src/lib/pinned-cli-command.d.ts +6 -0
  27. package/build/src/lib/pinned-cli-command.js +21 -0
  28. package/build/src/tools/generate-context-map.js +5 -3
  29. package/context/contracts/artifact-contract.md +1 -1
  30. package/context/scripts/hooks/config-protection.js +8 -1
  31. package/context/scripts/hooks/lib/config-protection-remedies.js +3 -0
  32. package/context/scripts/hooks/lib/kit-catalog.js +1 -1
  33. package/context/scripts/hooks/stop-goal-fit.js +79 -10
  34. package/docs/context-map.md +24 -20
  35. package/docs/developer-architecture.md +1 -1
  36. package/docs/public-workflow-cli.md +132 -0
  37. package/docs/skills-map.md +51 -27
  38. package/docs/spec/builder-flow-runtime.md +78 -40
  39. package/docs/workflow-usage-guide.md +110 -38
  40. package/evals/ci/run-baseline.sh +2 -0
  41. package/evals/fixtures/hook-influence/cases.json +2 -2
  42. package/evals/integration/test_builder_entry_enforcement.sh +57 -45
  43. package/evals/integration/test_builder_step_producers.sh +297 -6
  44. package/evals/integration/test_bundle_install.sh +258 -55
  45. package/evals/integration/test_critique_supersession_roundtrip.sh +21 -8
  46. package/evals/integration/test_current_json_per_actor.sh +12 -0
  47. package/evals/integration/test_dual_emit_flow_step.sh +62 -43
  48. package/evals/integration/test_flowdef_session_activation.sh +145 -25
  49. package/evals/integration/test_flowdef_session_history_preservation.sh +23 -21
  50. package/evals/integration/test_gate_lockdown.sh +3 -5
  51. package/evals/integration/test_goal_fit_hook.sh +60 -2
  52. package/evals/integration/test_liveness_verdict.sh +14 -17
  53. package/evals/integration/test_phase_map_and_gate_claim.sh +63 -38
  54. package/evals/integration/test_public_workflow_cli.sh +573 -0
  55. package/evals/integration/test_pull_work_liveness_preflight.sh +22 -66
  56. package/evals/integration/test_sidecar_field_preservation.sh +36 -11
  57. package/evals/integration/test_workflow_sidecar_writer.sh +277 -44
  58. package/evals/integration/test_workflow_steering_hook.sh +15 -38
  59. package/evals/run.sh +2 -0
  60. package/evals/static/test_builder_skill_coherence.sh +247 -0
  61. package/evals/static/test_library_exports.sh +5 -2
  62. package/evals/static/test_workflow_skills.sh +13 -325
  63. package/kits/builder/flows/build.flow.json +22 -0
  64. package/kits/builder/flows/shape.flow.json +9 -9
  65. package/kits/builder/kit.json +70 -16
  66. package/kits/builder/skills/builder-shape/SKILL.md +75 -75
  67. package/kits/builder/skills/continue-work/SKILL.md +45 -106
  68. package/kits/builder/skills/deliver/SKILL.md +96 -442
  69. package/kits/builder/skills/design-probe/SKILL.md +40 -139
  70. package/kits/builder/skills/evidence-gate/SKILL.md +59 -201
  71. package/kits/builder/skills/execute-plan/SKILL.md +54 -125
  72. package/kits/builder/skills/fix-bug/SKILL.md +42 -132
  73. package/kits/builder/skills/gate-review/SKILL.md +60 -211
  74. package/kits/builder/skills/idea-to-backlog/SKILL.md +63 -244
  75. package/kits/builder/skills/learning-review/SKILL.md +63 -170
  76. package/kits/builder/skills/pickup-probe/SKILL.md +54 -111
  77. package/kits/builder/skills/plan-work/SKILL.md +51 -185
  78. package/kits/builder/skills/pull-work/SKILL.md +136 -485
  79. package/kits/builder/skills/release-readiness/SKILL.md +66 -107
  80. package/kits/builder/skills/review-work/SKILL.md +89 -176
  81. package/kits/builder/skills/tdd-workflow/SKILL.md +53 -147
  82. package/kits/builder/skills/verify-work/SKILL.md +101 -113
  83. package/package.json +2 -2
  84. package/schemas/builder-lifecycle-authorization.schema.json +57 -0
  85. package/schemas/lifecycle-authority-keys.schema.json +25 -0
  86. package/schemas/workflow-state.schema.json +1 -1
  87. package/scripts/hooks/config-protection.js +8 -1
  88. package/scripts/hooks/lib/config-protection-remedies.js +3 -0
  89. package/scripts/hooks/lib/kit-catalog.js +1 -1
  90. package/scripts/hooks/stop-goal-fit.js +79 -10
  91. package/src/builder-flow-run-adapter.ts +156 -23
  92. package/src/builder-flow-runtime.ts +535 -53
  93. package/src/builder-lifecycle-authority.ts +218 -0
  94. package/src/cli/assignment-provider-lock.test.mjs +83 -0
  95. package/src/cli/assignment-provider.ts +91 -22
  96. package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
  97. package/src/cli/builder-flow-runtime.test.mjs +597 -8
  98. package/src/cli/builder-run.ts +54 -9
  99. package/src/cli/kit-metadata-security.test.mjs +232 -6
  100. package/src/cli/public-api.test.mjs +15 -0
  101. package/src/cli/workflow-artifact-cleanup-audit.ts +3 -0
  102. package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
  103. package/src/cli/workflow-sidecar.ts +748 -99
  104. package/src/cli/workflow.ts +708 -0
  105. package/src/cli.ts +2 -0
  106. package/src/flow-kit/validate.ts +320 -2
  107. package/src/index.ts +20 -5
  108. package/src/lib/observed-command.ts +96 -0
  109. package/src/lib/package-version.ts +15 -0
  110. package/src/lib/pinned-cli-command.ts +23 -0
  111. package/src/tools/generate-context-map.ts +5 -3
@@ -1,36 +1,81 @@
1
1
  import { flagString, parseArgs } from "../lib/args.js";
2
- import { recoverBuilderFlowSession, startBuilderFlowSession, syncBuilderFlowSession } from "../builder-flow-runtime.js";
2
+ import {
3
+ cancelBuilderFlowSession,
4
+ archiveBuilderFlowSession,
5
+ pauseBuilderFlowSession,
6
+ recoverBuilderFlowSession,
7
+ releaseBuilderFlowAssignment,
8
+ resumeBuilderFlowSession,
9
+ } from "../builder-flow-runtime.js";
10
+
11
+ const USAGE = "Usage: flow-agents builder-run <recover|pause|resume|cancel|release-assignment|archive> --session-dir <path> [--reason <text> | --authorization-file <path>]";
3
12
 
4
13
  export async function main(argv: string[]): Promise<number> {
5
14
  const parsed = parseArgs(argv);
6
15
  const action = parsed.positionals[0];
7
16
  const sessionDir = flagString(parsed.flags, "session-dir");
17
+ const authorizationFile = flagString(parsed.flags, "authorization-file");
18
+ const reason = flagString(parsed.flags, "reason");
8
19
  const validRecoveryArguments = parsed.positionals.length === 1
9
20
  && Object.keys(parsed.flags).length === 1
10
21
  && typeof parsed.flags["session-dir"] === "string";
11
22
  if (action === "recover" && !validRecoveryArguments) {
12
- console.error("Usage: flow-agents builder-run <start|sync|recover> --session-dir <path>");
23
+ console.error(USAGE);
13
24
  return 64;
14
25
  }
15
26
  if (!sessionDir) {
16
27
  console.error("builder-run requires --session-dir .kontourai/flow-agents/<slug>");
17
28
  return 64;
18
29
  }
19
- if (action !== "start" && action !== "sync" && action !== "recover") {
20
- console.error("Usage: flow-agents builder-run <start|sync|recover> --session-dir <path>");
30
+ if (!action || !["recover", "pause", "resume", "cancel", "release-assignment", "archive"].includes(action)) {
31
+ console.error(USAGE);
32
+ return 64;
33
+ }
34
+ const agentLifecycle = action === "pause" || action === "resume" || action === "release-assignment";
35
+ const authorizedLifecycle = action === "cancel" || action === "archive";
36
+ const lifecycle = agentLifecycle || authorizedLifecycle;
37
+ const allowedLifecycleFlag = (name: string) => name === "session-dir" || (agentLifecycle ? name === "reason" : name === "authorization-file");
38
+ if (lifecycle && (parsed.positionals.length !== 1 || Object.keys(parsed.flags).some((name) => !allowedLifecycleFlag(name)))) {
39
+ console.error(USAGE);
40
+ return 64;
41
+ }
42
+ if (agentLifecycle && !reason) {
43
+ console.error(`builder-run ${action} requires --reason <text>`);
44
+ return 64;
45
+ }
46
+ if (authorizedLifecycle && !authorizationFile) {
47
+ console.error(`builder-run ${action} requires a signed --authorization-file <path>`);
48
+ return 64;
49
+ }
50
+ if (!lifecycle && (authorizationFile || reason)) {
51
+ console.error(USAGE);
21
52
  return 64;
22
53
  }
23
- const result = action === "start"
24
- ? await startBuilderFlowSession({ sessionDir })
25
- : action === "sync"
26
- ? await syncBuilderFlowSession({ sessionDir })
27
- : await recoverBuilderFlowSession({ sessionDir });
54
+ const result = action === "recover"
55
+ ? await recoverBuilderFlowSession({ sessionDir })
56
+ : action === "pause"
57
+ ? await pauseBuilderFlowSession({ sessionDir, reason: reason! })
58
+ : action === "resume"
59
+ ? await resumeBuilderFlowSession({ sessionDir, reason: reason! })
60
+ : action === "cancel"
61
+ ? await cancelBuilderFlowSession({ sessionDir, authorizationFile: authorizationFile! })
62
+ : action === "release-assignment"
63
+ ? await releaseBuilderFlowAssignment({ sessionDir, reason: reason! })
64
+ : await archiveBuilderFlowSession({ sessionDir, authorizationFile: authorizationFile! });
28
65
  console.log(JSON.stringify({
29
66
  run_id: result.run.runId,
30
67
  definition_id: result.run.definitionId,
31
68
  current_step: result.run.state.current_step,
32
69
  status: result.run.state.status,
33
70
  attached: result.attached,
71
+ ...(action === "cancel" ? {
72
+ assignment_released: "assignmentReleased" in result ? result.assignmentReleased : false,
73
+ idempotent: "idempotent" in result ? result.idempotent : false,
74
+ } : action === "release-assignment" ? {
75
+ assignment_released: "assignmentReleased" in result ? result.assignmentReleased : false,
76
+ } : action === "archive" ? {
77
+ archive_dir: "archiveDir" in result ? result.archiveDir : null,
78
+ } : {}),
34
79
  next_action: result.projection.next_action,
35
80
  }));
36
81
  return 0;
@@ -3,11 +3,11 @@ import assert from "node:assert/strict";
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
- import { createRequire } from "node:module";
6
+ import { createRequire, syncBuiltinESMExports } from "node:module";
7
7
 
8
8
  import { readKitInventory } from "../../build/src/runtime-adapters.js";
9
9
  import { main as validateHookInfluence } from "../../build/src/cli/validate-hook-influence.js";
10
- import { parseKitFlowStepActions } from "../../build/src/flow-kit/validate.js";
10
+ import { parseKitFlowStepActions, parseKitSkillRoles, validateKitRepository } from "../../build/src/flow-kit/validate.js";
11
11
 
12
12
  const require = createRequire(import.meta.url);
13
13
  const { workflowTriggersFor } = require("../../scripts/hooks/lib/kit-catalog.js");
@@ -68,13 +68,15 @@ test("Builder flow step actions are structured, complete, and operation-aware",
68
68
  const result = parseKitFlowStepActions(manifest, "kits/builder/kit.json");
69
69
 
70
70
  assert.deepEqual(result.errors, []);
71
- assert.equal(result.entries.length, 10);
71
+ assert.equal(result.entries.length, 14);
72
72
  assert.deepEqual(result.entries.find((entry) => entry.step_id === "plan")?.skills, ["plan-work"]);
73
73
  assert.deepEqual(result.entries.find((entry) => entry.step_id === "pr-open"), {
74
74
  flow_id: "builder.build",
75
75
  step_id: "pr-open",
76
76
  skills: [],
77
77
  operations: ["publish-change"],
78
+ artifacts: ["release.json"],
79
+ expectation_ids: ["pull-request-opened"],
78
80
  });
79
81
  });
80
82
 
@@ -84,11 +86,231 @@ test("flow step action metadata rejects malformed and duplicate entries", () =>
84
86
  { flow_id: "builder.build", step_id: "plan", skills: ["plan-work"] },
85
87
  { flow_id: "builder.build", step_id: "plan", skills: [] },
86
88
  { flow_id: "builder.build", step_id: "verify", skills: "verify-work" },
89
+ { flow_id: "builder.build", step_id: "publish", skills: [], operations: ["publish-change"], expectation_ids: ["pull-request-opened", "pull-request-opened"] },
87
90
  ],
88
91
  }, "fixture/kit.json");
89
92
 
90
93
  assert.match(result.errors.join("\n"), /duplicates 'builder\.build\/plan'/);
91
94
  assert.match(result.errors.join("\n"), /skills must be an identifier list/);
95
+ assert.match(result.errors.join("\n"), /expectation_ids must be a unique identifier list when present/);
96
+ });
97
+
98
+ test("flow step action metadata preserves explicit operation expectation ownership", () => {
99
+ const result = parseKitFlowStepActions({
100
+ flow_step_actions: [
101
+ { flow_id: "builder.build", step_id: "pr-open", skills: [], operations: ["publish-change"], expectation_ids: ["pull-request-opened"] },
102
+ ],
103
+ }, "fixture/kit.json");
104
+
105
+ assert.deepEqual(result.errors, []);
106
+ assert.deepEqual(result.entries[0]?.expectation_ids, ["pull-request-opened"]);
107
+ assert.deepEqual(result.entries[0]?.artifacts, []);
108
+ });
109
+
110
+ test("Builder skill roles form one complete role and producer matrix", async () => {
111
+ const manifest = JSON.parse(fs.readFileSync("kits/builder/kit.json", "utf8"));
112
+ const result = parseKitSkillRoles(manifest, "kits/builder/kit.json");
113
+
114
+ assert.deepEqual(result.errors, []);
115
+ assert.equal(result.entries.length, 17);
116
+ assert.deepEqual(Object.fromEntries([...new Set(result.entries.map((entry) => entry.role))].map((role) => [role, result.entries.filter((entry) => entry.role === role).length])), {
117
+ entrypoint: 2,
118
+ profile: 2,
119
+ step: 10,
120
+ "shared-primitive": 1,
121
+ extension: 2,
122
+ });
123
+ assert.deepEqual(result.entries.find((entry) => entry.skill_id === "builder.review-work")?.expectation_ids, ["clean-critique"]);
124
+ assert.deepEqual(result.entries.find((entry) => entry.skill_id === "builder.verify-work")?.expectation_ids, ["acceptance-criteria", "tests-evidence", "policy-compliance"]);
125
+ assert.deepEqual(await validateKitRepository("kits/builder"), []);
126
+ });
127
+
128
+ test("skill role metadata rejects hidden semantics and role boundary violations", () => {
129
+ const result = parseKitSkillRoles({
130
+ skill_roles: [
131
+ { skill_id: "builder.deliver", role: "entrypoint", flow_id: "builder.build", step_ids: [], artifacts: [], expectation_ids: [], command: "private-bypass" },
132
+ { skill_id: "builder.deliver", role: "step", flow_id: "builder.build", step_ids: ["execute"], artifacts: ["state.json"], expectation_ids: [] },
133
+ { skill_id: "builder.profile", role: "profile", flow_id: "builder.build", step_ids: [], artifacts: [], expectation_ids: ["tests-evidence"] },
134
+ { skill_id: "builder.extension", role: "extension", flow_id: "builder.build", step_ids: [], artifacts: ["report.json"], expectation_ids: [] },
135
+ { skill_id: "builder.empty-step", role: "step", flow_id: "builder.build", step_ids: ["execute"], artifacts: [], expectation_ids: [] },
136
+ { skill_id: "builder.unknown", role: "orchestrator", step_ids: [], artifacts: [], expectation_ids: [] },
137
+ ],
138
+ }, "fixture/kit.json");
139
+
140
+ const errors = result.errors.join("\n");
141
+ assert.match(errors, /unsupported field\(s\): command/);
142
+ assert.match(errors, /duplicates 'builder\.deliver'/);
143
+ assert.match(errors, /profile must select one flow and own no steps, artifacts, or expectations/);
144
+ assert.match(errors, /extension must own no Builder flow, steps, or expectations/);
145
+ assert.match(errors, /step must bind one flow, at least one step, and at least one artifact/);
146
+ assert.match(errors, /role must be entrypoint, profile, step, shared-primitive, or extension/);
147
+ });
148
+
149
+ test("skill role repository validation rejects unknown steps and misplaced expectations", async () => {
150
+ const root = tempRoot("flow-agents-skill-role-cross-reference-");
151
+ const kit = path.join(root, "builder");
152
+ fs.cpSync("kits/builder", kit, { recursive: true });
153
+ const manifestFile = path.join(kit, "kit.json");
154
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
155
+ const execute = manifest.skill_roles.find((entry) => entry.skill_id === "builder.execute-plan");
156
+ execute.step_ids = ["missing-step"];
157
+ execute.expectation_ids = ["tests-evidence"];
158
+ writeJson(manifestFile, manifest);
159
+
160
+ const errors = (await validateKitRepository(kit)).join("\n");
161
+ assert.match(errors, /references unknown step 'builder\.build\/missing-step'/);
162
+ assert.match(errors, /expectation 'tests-evidence' is not owned by its bound step/);
163
+ assert.match(errors, /flow_step_actions 'builder\.build\/execute' skill 'execute-plan' must match one step-role binding/);
164
+ });
165
+
166
+ test("skill role repository validation requires exactly one producer per skill-owned expectation", async () => {
167
+ const root = tempRoot("flow-agents-skill-role-producer-completeness-");
168
+ const kit = path.join(root, "builder");
169
+ fs.cpSync("kits/builder", kit, { recursive: true });
170
+ const manifestFile = path.join(kit, "kit.json");
171
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
172
+ const verify = manifest.skill_roles.find((entry) => entry.skill_id === "builder.verify-work");
173
+ const review = manifest.skill_roles.find((entry) => entry.skill_id === "builder.review-work");
174
+ const shape = manifest.skill_roles.find((entry) => entry.skill_id === "builder.idea-to-backlog");
175
+
176
+ verify.expectation_ids = ["policy-compliance"];
177
+ review.expectation_ids = ["policy-compliance"];
178
+ shape.expectation_ids = shape.expectation_ids.filter((id) => id !== "shaped-problem");
179
+ manifest.flow_step_actions.find((entry) => entry.flow_id === "builder.build" && entry.step_id === "verify").skills = ["verify-work"];
180
+ writeJson(manifestFile, manifest);
181
+
182
+ const errors = (await validateKitRepository(kit)).join("\n");
183
+ assert.match(errors, /flow expectation 'builder\.build\/verify\/tests-evidence' must have exactly one producer owner; found 0/);
184
+ assert.match(errors, /flow expectation 'builder\.build\/verify\/policy-compliance' must have exactly one producer owner; found 2/);
185
+ assert.match(errors, /flow expectation 'builder\.shape\/shape\/shaped-problem' must have exactly one producer owner; found 0/);
186
+ });
187
+
188
+ test("flow expectation ownership cannot be bypassed by reclassifying a producer and emptying its action", async () => {
189
+ const root = tempRoot("flow-agents-reclassified-producer-");
190
+ const kit = path.join(root, "builder");
191
+ fs.cpSync("kits/builder", kit, { recursive: true });
192
+ const manifestFile = path.join(kit, "kit.json");
193
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
194
+ const producer = manifest.skill_roles.find((entry) => entry.skill_id === "builder.evidence-gate");
195
+ producer.role = "extension";
196
+ delete producer.flow_id;
197
+ producer.step_ids = [];
198
+ producer.expectation_ids = [];
199
+ producer.artifacts = [];
200
+ const action = manifest.flow_step_actions.find((entry) => entry.flow_id === "builder.build" && entry.step_id === "merge-ready");
201
+ action.skills = [];
202
+ delete action.operations;
203
+ writeJson(manifestFile, manifest);
204
+
205
+ const errors = (await validateKitRepository(kit)).join("\n");
206
+ assert.doesNotMatch(errors, /extension must own artifacts/);
207
+ assert.match(errors, /flow expectation 'builder\.build\/merge-ready\/merge-readiness' must have exactly one producer owner; found 0/);
208
+ });
209
+
210
+ test("operation-only composed actions must explicitly and exclusively own expectations", async () => {
211
+ async function errorsFor(name, mutate) {
212
+ const root = tempRoot(`flow-agents-operation-owner-${name}-`);
213
+ const kit = path.join(root, "builder");
214
+ fs.cpSync("kits/builder", kit, { recursive: true });
215
+ const manifestFile = path.join(kit, "kit.json");
216
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
217
+ mutate(manifest);
218
+ writeJson(manifestFile, manifest);
219
+ return (await validateKitRepository(kit)).join("\n");
220
+ }
221
+
222
+ const missing = await errorsFor("missing", (manifest) => {
223
+ const action = manifest.flow_step_actions.find((entry) => entry.flow_id === "builder.build" && entry.step_id === "pr-open");
224
+ action.skills = [];
225
+ action.operations = ["publish-change"];
226
+ delete action.expectation_ids;
227
+ });
228
+ assert.match(missing, /operation-only action must explicitly declare expectation_ids/);
229
+ assert.match(missing, /flow expectation 'builder\.publish-learn\/pr-open\/pull-request-opened' must have exactly one producer owner; found 0/);
230
+
231
+ const duplicate = await errorsFor("duplicate", (manifest) => {
232
+ const action = manifest.flow_step_actions.find((entry) => entry.flow_id === "builder.build" && entry.step_id === "pr-open");
233
+ action.skills = ["release-readiness"];
234
+ action.operations = ["publish-change"];
235
+ action.expectation_ids = ["pull-request-opened"];
236
+ const role = manifest.skill_roles.find((entry) => entry.skill_id === "builder.release-readiness");
237
+ role.step_ids = ["pr-open", "merge-ready-ci"];
238
+ role.expectation_ids = ["pull-request-opened", "ci-merge-readiness"];
239
+ });
240
+ assert.match(duplicate, /flow expectation 'builder\.publish-learn\/pr-open\/pull-request-opened' must have exactly one producer owner; found 2/);
241
+ });
242
+
243
+ test("skill role cross-reference never reads Flow definitions through traversal, symlinks, or oversized files", async () => {
244
+ async function errorsFor(name, mutate) {
245
+ const root = tempRoot(`flow-agents-skill-role-safe-flow-${name}-`);
246
+ const kit = path.join(root, "builder");
247
+ fs.cpSync("kits/builder", kit, { recursive: true });
248
+ const manifestFile = path.join(kit, "kit.json");
249
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
250
+ mutate({ root, kit, manifest });
251
+ writeJson(manifestFile, manifest);
252
+ return (await validateKitRepository(kit)).join("\n");
253
+ }
254
+
255
+ const traversal = await errorsFor("traversal", ({ root, manifest }) => {
256
+ writeJson(path.join(root, "outside.flow.json"), { id: "outside", steps: [], gates: {} });
257
+ manifest.flows[0].path = "../outside.flow.json";
258
+ });
259
+ assert.match(traversal, /path must stay inside the kit directory/);
260
+
261
+ const symlink = await errorsFor("symlink", ({ kit, manifest }) => {
262
+ const link = path.join(kit, "flows", "linked.flow.json");
263
+ fs.symlinkSync(path.join(kit, "flows", "build.flow.json"), link);
264
+ manifest.flows[0].path = "flows/linked.flow.json";
265
+ });
266
+ assert.match(symlink, /path must not traverse a symbolic link/);
267
+
268
+ const intermediateSymlink = await errorsFor("intermediate-symlink", ({ kit, manifest }) => {
269
+ const link = path.join(kit, "linked-flows");
270
+ fs.symlinkSync(path.join(kit, "flows"), link);
271
+ manifest.flows[0].path = "linked-flows/build.flow.json";
272
+ });
273
+ assert.match(intermediateSymlink, /path must not traverse a symbolic link/);
274
+
275
+ const oversized = await errorsFor("oversized", ({ kit, manifest }) => {
276
+ fs.writeFileSync(path.join(kit, "flows", "oversized.flow.json"), " ".repeat(1024 * 1024 + 1));
277
+ manifest.flows[0].path = "flows/oversized.flow.json";
278
+ });
279
+ assert.match(oversized, /file exceeds 1048576 bytes/);
280
+ });
281
+
282
+ test("flow definition descriptor identity changes fail closed", async () => {
283
+ const root = tempRoot("flow-agents-flow-identity-race-");
284
+ const kit = path.join(root, "builder");
285
+ fs.cpSync("kits/builder", kit, { recursive: true });
286
+ const manifestFile = path.join(kit, "kit.json");
287
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
288
+ const target = path.join(kit, "flows", "raced.flow.json");
289
+ const replacement = path.join(kit, "flows", "replacement.flow.json");
290
+ fs.copyFileSync(path.join(kit, "flows", "build.flow.json"), target);
291
+ fs.copyFileSync(path.join(kit, "flows", "shape.flow.json"), replacement);
292
+ manifest.flows[0].path = "flows/raced.flow.json";
293
+ writeJson(manifestFile, manifest);
294
+ const realTarget = path.join(fs.realpathSync(path.dirname(target)), path.basename(target));
295
+
296
+ const originalOpen = fs.openSync;
297
+ let swapped = false;
298
+ fs.openSync = (file, flags, mode) => {
299
+ if (!swapped && file === realTarget) {
300
+ fs.renameSync(replacement, target);
301
+ swapped = true;
302
+ }
303
+ return originalOpen(file, flags, mode);
304
+ };
305
+ syncBuiltinESMExports();
306
+ try {
307
+ const errors = (await validateKitRepository(kit)).join("\n");
308
+ assert.equal(swapped, true);
309
+ assert.match(errors, /flow definition identity changed while opening/);
310
+ } finally {
311
+ fs.openSync = originalOpen;
312
+ syncBuiltinESMExports();
313
+ }
92
314
  });
93
315
 
94
316
  test("hook influence: kit expectation cannot override engine-required case id", () => {
@@ -267,7 +489,9 @@ test("workflowTriggersFor uses one structured renderer for any id and provenance
267
489
  assert.match(trigger.steering, /^KIT WORKFLOW ROUTE:/);
268
490
  assert.match(trigger.steering, /use the `hostile-kit` kit's `hostile\.build` workflow/);
269
491
  assert.match(trigger.steering, /If user-requested-tdd, activate `hostile\.tdd`; otherwise activate `hostile\.run`/);
270
- assert.match(trigger.steering, /--flow-id hostile\.build/);
492
+ assert.match(trigger.steering, /Keep the session on `hostile\.build`/);
493
+ assert.match(trigger.steering, /public `flow-agents workflow` interface/);
494
+ assert.doesNotMatch(trigger.steering, /workflow:sidecar|ensure-session/);
271
495
  assert.match(trigger.steering, /hostile\.plan -> hostile\.verify/);
272
496
  assert.match(trigger.steering, /hostile\.release/);
273
497
  });
@@ -299,7 +523,8 @@ test("workflowTriggersFor ignores invalid workflow_triggers and still returns Bu
299
523
  assert.deepEqual(triggers.map((trigger) => trigger.kit_id), ["builder"]);
300
524
  assert.match(triggers[0].steering, /^KIT WORKFLOW ROUTE:/);
301
525
  assert.match(triggers[0].steering, /use the `builder` kit's `builder\.build` workflow/);
302
- assert.match(triggers[0].steering, /--flow-id builder\.build/);
526
+ assert.match(triggers[0].steering, /Keep the session on `builder\.build`/);
527
+ assert.match(triggers[0].steering, /public `flow-agents workflow` interface/);
303
528
  });
304
529
 
305
530
  test("workflowTriggersFor rejects duplicate trigger ids for a kit", () => {
@@ -439,5 +664,6 @@ test("workflowTriggersFor returns Knowledge's structured capture trigger", () =>
439
664
  assert.equal(trigger.kit_id, "knowledge");
440
665
  assert.match(trigger.steering, /use the `knowledge` kit's `knowledge\.ingest` workflow/);
441
666
  assert.match(trigger.steering, /`knowledge\.knowledge-capture`/);
442
- assert.match(trigger.steering, /--flow-id knowledge\.ingest/);
667
+ assert.match(trigger.steering, /Keep the session on `knowledge\.ingest`/);
668
+ assert.match(trigger.steering, /unsupported-runtime blocker/);
443
669
  });
@@ -31,6 +31,21 @@ test("public API exports local artifact root helpers", () => {
31
31
  assert.notEqual(defaultArtifactRootForRead(cwd), durableFlowAgentsRoot(cwd));
32
32
  });
33
33
 
34
+ test("public API retains the documented native-host compatibility surface", async () => {
35
+ const lib = await import("../../build/src/index.js");
36
+ for (const name of [
37
+ "startBuilderBuildRun", "evaluateBuilderBuildRun", "startBuilderFlowSession",
38
+ "pauseBuilderFlowSession", "resumeBuilderFlowSession", "cancelBuilderFlowSession",
39
+ "archiveBuilderFlowSession", "recoverBuilderFlowSession", "releaseBuilderFlowAssignment",
40
+ "writeJson", "appendJsonl", "sidecarBase", "writeState", "writeSidecar",
41
+ ]) {
42
+ assert.equal(typeof lib[name], "function", `${name} must remain package-root exported`);
43
+ }
44
+ assert.equal(typeof lib.loadJson, "function");
45
+ assert.equal(typeof lib.validateTrustBundle, "function");
46
+ assert.equal(typeof lib.builderLifecycleAuthorizationPayload, "function");
47
+ });
48
+
34
49
  test("TS and CJS artifact helpers stay in parity without durable-root fallback", () => {
35
50
  const require = createRequire(import.meta.url);
36
51
  const cjs = require("../../scripts/hooks/lib/local-artifact-paths.js");
@@ -264,6 +264,9 @@ function classifyWorkflow(slug: string, workflowPath: string): AuditItem {
264
264
  if (status === "verified" && nextStatus === "done") {
265
265
  return { ...base, classification: "cleanup_candidate", reasons: ["verified workflow has next_action.status done"] };
266
266
  }
267
+ if (status === "canceled" && phase === "done") {
268
+ return { ...base, classification: "terminal_done", reasons: ["canceled workflow retains its artifacts without requiring delivery promotion"] };
269
+ }
267
270
  if (["delivered", "accepted", "archived"].includes(status) && phase === "done") {
268
271
  if (status !== "archived" && !hasPromotionClaim(workflowPath)) {
269
272
  return { ...base, classification: "cleanup_candidate", reasons: [`${status} workflow reached phase done without a promotion claim; ${PROMOTE_REMEDY}`] };
@@ -0,0 +1,90 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ import { inferExecutedTestCount, isMeaningfulTestCommand, testExecutionProof } from "../../build/src/cli/workflow-sidecar.js";
8
+
9
+ function fixture(files) {
10
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-test-proof-"));
11
+ for (const [name, content] of Object.entries(files)) {
12
+ const file = path.join(root, name);
13
+ fs.mkdirSync(path.dirname(file), { recursive: true });
14
+ fs.writeFileSync(file, content);
15
+ }
16
+ return root;
17
+ }
18
+
19
+ test("fake Vitest-looking stdout is not test execution proof", () => {
20
+ const root = fixture({
21
+ "package.json": JSON.stringify({ scripts: { test: "node fake-vitest.mjs" } }),
22
+ "fake-vitest.mjs": 'console.log("Tests 999 passed");\n',
23
+ });
24
+
25
+ assert.equal(isMeaningfulTestCommand("npm test", root), false);
26
+ assert.equal(testExecutionProof("npm test", root), null);
27
+ assert.equal(inferExecutedTestCount("npm test", root, "Tests 999 passed\n"), 0);
28
+ });
29
+
30
+ test("package-script output cannot manufacture a positive test count", () => {
31
+ const root = fixture({
32
+ "package.json": JSON.stringify({ scripts: { test: 'echo "# tests 999"' } }),
33
+ });
34
+
35
+ assert.equal(isMeaningfulTestCommand("npm test", root), false);
36
+ assert.equal(inferExecutedTestCount("npm test", root, "# tests 999\n"), 0);
37
+ });
38
+
39
+ test("supported node test workflows produce source-derived local proof", () => {
40
+ const root = fixture({
41
+ "package.json": JSON.stringify({ scripts: { test: "node --test test/contract.test.mjs" } }),
42
+ "test/contract.test.mjs": 'import test from "node:test";\ntest("contract", () => {});\n',
43
+ });
44
+
45
+ const proof = testExecutionProof("npm test", root);
46
+ assert.deepEqual(proof, { kind: "local-process-exit", runner: "node --test", static_test_units: 1 });
47
+ assert.equal(inferExecutedTestCount("npm test", root, "# tests 0\n"), 0);
48
+ assert.equal(inferExecutedTestCount("npm test", root, "# tests 1\n"), 1);
49
+ assert.equal(inferExecutedTestCount("npm test", root, "ℹ tests 1\n"), 1);
50
+ });
51
+
52
+ test("empty suite declarations are not counted as executed test cases", () => {
53
+ const root = fixture({
54
+ "package.json": JSON.stringify({ scripts: { test: "node --test test/empty.test.mjs" } }),
55
+ "test/empty.test.mjs": 'import { describe } from "node:test";\ndescribe("empty", () => {});\n',
56
+ });
57
+
58
+ assert.equal(testExecutionProof("npm test", root), null);
59
+ assert.equal(inferExecutedTestCount("npm test", root, "# tests 0\n"), 0);
60
+ });
61
+
62
+ test("runner-shaped executable names require explicit files with test cases", () => {
63
+ const root = fixture({
64
+ "pytest": "#!/bin/sh\nexit 0\n",
65
+ "test/contract_test.py": "def test_contract():\n assert True\n",
66
+ });
67
+
68
+ assert.equal(testExecutionProof("./pytest", root), null);
69
+ assert.equal(testExecutionProof("./pytest test/contract_test.py", root), null);
70
+ assert.deepEqual(testExecutionProof("pytest test/contract_test.py", root), {
71
+ kind: "local-process-exit",
72
+ runner: "pytest",
73
+ static_test_units: 1,
74
+ });
75
+ });
76
+
77
+ test("cargo and go require substantive local test sources", () => {
78
+ const empty = fixture({ "Cargo.toml": "[package]\nname='empty'\nversion='0.1.0'\n", "go.mod": "module example.test/empty\n" });
79
+ assert.equal(testExecutionProof("cargo test", empty), null);
80
+ assert.equal(testExecutionProof("go test ./...", empty), null);
81
+
82
+ const covered = fixture({
83
+ "Cargo.toml": "[package]\nname='covered'\nversion='0.1.0'\n",
84
+ "src/lib.rs": "#[cfg(test)]\nmod tests { #[test] fn contract() { assert!(true); } }\n",
85
+ "go.mod": "module example.test/covered\n",
86
+ "contract_test.go": "package covered\nimport \"testing\"\nfunc TestContract(t *testing.T) {}\n",
87
+ });
88
+ assert.equal(testExecutionProof("cargo test", covered)?.static_test_units, 1);
89
+ assert.equal(testExecutionProof("go test ./...", covered)?.static_test_units, 1);
90
+ });