@kontourai/flow-agents 3.0.0 → 3.2.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 (158) hide show
  1. package/.github/workflows/ci.yml +12 -0
  2. package/CHANGELOG.md +32 -0
  3. package/CONTEXT.md +67 -1
  4. package/README.md +4 -0
  5. package/build/src/cli/assignment-provider.d.ts +188 -0
  6. package/build/src/cli/assignment-provider.js +193 -51
  7. package/build/src/cli/workflow-sidecar.d.ts +16 -6
  8. package/build/src/cli/workflow-sidecar.js +492 -45
  9. package/build/src/lib/flow-resolver.d.ts +12 -6
  10. package/build/src/lib/flow-resolver.js +30 -14
  11. package/build/src/tools/validate-source-tree.js +2 -1
  12. package/context/contracts/assignment-provider-contract.md +10 -1
  13. package/context/contracts/execution-contract.md +78 -0
  14. package/context/contracts/probe-docs-write-contract.md +187 -0
  15. package/context/scripts/hooks/config-protection.js +24 -4
  16. package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
  17. package/context/scripts/hooks/stop-goal-fit.js +262 -5
  18. package/context/scripts/hooks/workflow-steering.js +42 -0
  19. package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
  20. package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
  21. package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
  22. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  23. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  24. package/docs/adr/0006-typescript-first-source-policy.md +2 -0
  25. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  26. package/docs/adr/0007-skill-audit.md +2 -0
  27. package/docs/adr/0008-kit-operation-boundary.md +2 -0
  28. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
  29. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
  30. package/docs/adr/0011-mcp-posture.md +2 -0
  31. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
  32. package/docs/adr/0013-context-lifecycle.md +2 -0
  33. package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
  34. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
  35. package/docs/adr/0016-three-hard-boundary-model.md +2 -0
  36. package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
  37. package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
  38. package/docs/adr/0019-kit-dependency-ownership.md +2 -0
  39. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
  40. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
  41. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +113 -0
  42. package/docs/adr/README.md +49 -0
  43. package/docs/adr/index.md +34 -0
  44. package/docs/decisions/agent-coordination.md +20 -0
  45. package/docs/decisions/anti-gaming-trust-security.md +20 -0
  46. package/docs/decisions/context-lifecycle.md +18 -0
  47. package/docs/decisions/core-domain-kit-boundary.md +18 -0
  48. package/docs/decisions/flow-flow-agents-boundary.md +18 -0
  49. package/docs/decisions/flow-kit.md +20 -0
  50. package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
  51. package/docs/decisions/graph-knowledge-provider.md +63 -0
  52. package/docs/decisions/hook-core-kit-boundary.md +18 -0
  53. package/docs/decisions/index.md +19 -0
  54. package/docs/decisions/kit-dependency-ownership.md +18 -0
  55. package/docs/decisions/kit-operation-boundary.md +18 -0
  56. package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
  57. package/docs/decisions/kontour-resource-contract.md +18 -0
  58. package/docs/decisions/mcp-posture.md +18 -0
  59. package/docs/decisions/three-hard-boundary-model.md +18 -0
  60. package/docs/decisions/trust-reconcile.md +20 -0
  61. package/docs/decisions/typescript-source-policy.md +48 -0
  62. package/docs/decisions/workflow-enforcement.md +18 -0
  63. package/docs/decisions/workflow-trust-state.md +20 -0
  64. package/docs/fixture-ownership.md +1 -1
  65. package/docs/workflow-usage-guide.md +1 -1
  66. package/evals/ci/run-baseline.sh +6 -0
  67. package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
  68. package/evals/integration/test_checkpoint_signing.sh +4 -3
  69. package/evals/integration/test_current_json_per_actor.sh +516 -0
  70. package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
  71. package/evals/integration/test_gate_lockdown.sh +46 -0
  72. package/evals/integration/test_model_routing_escalation.sh +145 -0
  73. package/evals/integration/test_publish_delivery.sh +14 -6
  74. package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
  75. package/evals/integration/test_stop_hook_release.sh +552 -0
  76. package/evals/integration/test_trust_reconcile_negatives.sh +170 -0
  77. package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
  78. package/evals/run.sh +10 -0
  79. package/evals/static/test_knowledge_providers.sh +13 -4
  80. package/evals/static/test_model_routing_hints.sh +107 -0
  81. package/evals/static/test_workflow_skills.sh +15 -2
  82. package/kits/builder/skills/builder-shape/SKILL.md +10 -0
  83. package/kits/builder/skills/deliver/SKILL.md +69 -11
  84. package/kits/builder/skills/design-probe/SKILL.md +47 -0
  85. package/kits/builder/skills/execute-plan/SKILL.md +13 -0
  86. package/kits/builder/skills/fix-bug/SKILL.md +17 -0
  87. package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
  88. package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
  89. package/kits/builder/skills/plan-work/SKILL.md +9 -0
  90. package/kits/builder/skills/pull-work/SKILL.md +10 -0
  91. package/kits/builder/skills/review-work/SKILL.md +11 -0
  92. package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
  93. package/kits/builder/skills/verify-work/SKILL.md +11 -0
  94. package/kits/knowledge/adapters/default-store/index.js +147 -18
  95. package/kits/knowledge/adapters/flow-runner/index.js +912 -16
  96. package/kits/knowledge/adapters/obsidian-store/index.js +97 -14
  97. package/kits/knowledge/adapters/shared/codec.js +265 -0
  98. package/kits/knowledge/docs/README.md +121 -2
  99. package/kits/knowledge/docs/store-contract.md +517 -7
  100. package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
  101. package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
  102. package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
  103. package/kits/knowledge/evals/contract-suite/suite.test.js +212 -0
  104. package/kits/knowledge/evals/freshness/suite.test.js +339 -0
  105. package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
  106. package/kits/knowledge/evals/retirement/suite.test.js +1 -1
  107. package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
  108. package/kits/knowledge/flows/promote.flow.json +84 -0
  109. package/kits/knowledge/kit.json +15 -0
  110. package/kits/knowledge/promote/distill.js +96 -0
  111. package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
  112. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
  113. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
  114. package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
  115. package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
  116. package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
  117. package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
  118. package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
  119. package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
  120. package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
  121. package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
  122. package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
  123. package/kits/knowledge/promote/fixtures/session/state.json +19 -0
  124. package/kits/knowledge/promote/health.js +137 -0
  125. package/kits/knowledge/promote/index.js +176 -0
  126. package/kits/knowledge/promote/ingest.js +103 -0
  127. package/kits/knowledge/promote/lib.js +132 -0
  128. package/kits/knowledge/promote/link.js +84 -0
  129. package/kits/knowledge/promote/promote.test.js +174 -0
  130. package/kits/knowledge/providers/conformance/suite.test.js +18 -0
  131. package/kits/knowledge/providers/index.js +1 -0
  132. package/kits/knowledge/providers/neo4j/connection.js +121 -0
  133. package/kits/knowledge/providers/neo4j/cypher.js +190 -0
  134. package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
  135. package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
  136. package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
  137. package/kits/knowledge/providers/neo4j/index.js +280 -0
  138. package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
  139. package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
  140. package/kits/knowledge/providers/neo4j/sync.js +235 -0
  141. package/package.json +4 -2
  142. package/schemas/workflow-handoff.schema.json +6 -0
  143. package/scripts/README.md +1 -0
  144. package/scripts/ci/mint-attestation.js +33 -6
  145. package/scripts/ci/trust-reconcile.js +144 -26
  146. package/scripts/freeze-adrs.mjs +364 -0
  147. package/scripts/hooks/config-protection.js +24 -4
  148. package/scripts/hooks/evidence-capture.js +4 -1
  149. package/scripts/hooks/lib/config-protection-remedies.js +9 -0
  150. package/scripts/hooks/lib/current-pointer.js +123 -0
  151. package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
  152. package/scripts/hooks/stop-goal-fit.js +262 -5
  153. package/scripts/hooks/workflow-steering.js +42 -0
  154. package/scripts/statusline/flow-agents-statusline.js +3 -1
  155. package/src/cli/assignment-provider.ts +239 -59
  156. package/src/cli/workflow-sidecar.ts +529 -43
  157. package/src/lib/flow-resolver.ts +35 -14
  158. package/src/tools/validate-source-tree.ts +2 -1
@@ -64,22 +64,29 @@ function loadActorStructFromFile(file) {
64
64
  * fixture-friendly path (used by evals and any caller that already knows its own struct);
65
65
  * when omitted, auto-derive from the live environment via the shared resolver, mirroring
66
66
  * (never forking) the exact struct fields serializeActor() already defines.
67
+ *
68
+ * F1 fix (fix-plan iteration 1, HIGH): also returns `actorKey` — set to the canonical
69
+ * `resolveActor(env).actor` string ONLY on the auto-derive path (pull-work's real path, and any
70
+ * other caller with no --actor-json), so a claim made via `assignment-provider claim` and one
71
+ * made via ensure-session share the same canonical key. `--actor-json` explicit fixtures leave
72
+ * `actorKey` unset — `performLocalClaim`/`performLocalSupersede` then fall back to
73
+ * `serializeActor(actor)` for the record's `actor_key`, preserving existing fixture behavior.
67
74
  */
68
75
  function loadActorStruct(args) {
69
76
  const actorJsonPath = flagString(args.flags, "actor-json");
70
77
  if (actorJsonPath)
71
- return loadActorStructFromFile(actorJsonPath);
78
+ return { actor: loadActorStructFromFile(actorJsonPath) };
72
79
  const helper = loadActorIdentityHelper();
73
80
  const resolved = helper.resolveActor(process.env);
74
81
  if (helper.isUnresolvedActor(resolved.actor))
75
82
  throw new Error("could not resolve an actor identity (no --actor-json and no resolvable environment actor); pass --actor-json explicitly");
76
- return { runtime: helper.detectRuntime(process.env), session_id: resolved.actor, host: os.hostname(), human: null };
83
+ return { actor: { runtime: helper.detectRuntime(process.env), session_id: resolved.actor, host: os.hostname(), human: null }, actorKey: resolved.actor };
77
84
  }
78
- function assignmentFilePath(artifactRoot, subjectId) {
85
+ export function assignmentFilePath(artifactRoot, subjectId) {
79
86
  const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
80
87
  return path.join(artifactRoot, "assignment", `${sanitized}.json`);
81
88
  }
82
- function readLocalRecord(artifactRoot, subjectId) {
89
+ export function readLocalRecord(artifactRoot, subjectId) {
83
90
  const file = assignmentFilePath(artifactRoot, subjectId);
84
91
  if (!fs.existsSync(file))
85
92
  return null;
@@ -99,7 +106,7 @@ function readLocalRecord(artifactRoot, subjectId) {
99
106
  throw new Error(`${file}: unsupported schema_version ${String(record.schema_version)}`);
100
107
  return record;
101
108
  }
102
- function writeLocalRecord(artifactRoot, subjectId, record) {
109
+ export function writeLocalRecord(artifactRoot, subjectId, record) {
103
110
  // writeJson throws on any mkdir/writeFileSync failure; that error is intentionally allowed to
104
111
  // propagate to main()'s top-level try/catch and exit non-zero. Durable writes must fail loud,
105
112
  // never fail open (artifact-contract.md).
@@ -142,7 +149,7 @@ function subjectLockDir(artifactRoot, subjectId) {
142
149
  * the write) of all three local-file mutators in this, since all three mutate the same record
143
150
  * file for a given subject.
144
151
  */
145
- function withSubjectLock(artifactRoot, subjectId, body) {
152
+ export function withSubjectLock(artifactRoot, subjectId, body) {
146
153
  const lockDir = subjectLockDir(artifactRoot, subjectId);
147
154
  const staleMs = Number(process.env.FLOW_AGENTS_ASSIGNMENT_STALE_LOCK_MS ?? 5 * 60 * 1000);
148
155
  const deadline = Date.now() + 30000;
@@ -196,7 +203,7 @@ function withSubjectLock(artifactRoot, subjectId, body) {
196
203
  * through here too means `--now` deterministically governs idle_days as well as liveness
197
204
  * freshness, rather than idle_days silently reading the real wall clock regardless of `--now`.
198
205
  */
199
- function computeEffectiveState(assignment, freshHoldersList, selfActor, nowMs) {
206
+ export function computeEffectiveState(assignment, freshHoldersList, selfActor, nowMs) {
200
207
  const record = assignment.record && assignment.record.status === "claimed" ? assignment.record : null;
201
208
  const isAssigned = Boolean(assignment.assignee) || Boolean(record);
202
209
  if (!isAssigned) {
@@ -222,7 +229,14 @@ function computeEffectiveState(assignment, freshHoldersList, selfActor, nowMs) {
222
229
  // human's work (Design Decision 3 / ADR 0021 §6's "never auto-reclaim" non-goal).
223
230
  return { effective_state: "human-held", reason: "assignee_without_claim_record", holder: { assignee: assignment.assignee } };
224
231
  }
225
- const holderActorKey = loadActorIdentityHelper().serializeActor(record.actor);
232
+ // F1 fix (fix-plan iteration 1, HIGH): prefer the canonical actor_key over re-serializing
233
+ // record.actor. record.actor_key IS resolveActor(env).actor for records written by the fixed
234
+ // performLocalClaim/performLocalSupersede paths (below) — the same flat/bare string liveness
235
+ // whoami, `liveness claim --actor`, per-actor current.json, and pull-work's --self-actor all
236
+ // use — so for an explicit-override actor the self-check and the liveness join now agree with
237
+ // every other tool. BACK-COMPAT: records with no actor_key (every pre-fix record, every #290
238
+ // eval fixture) fall back to serializeActor(record.actor) exactly as before this fix.
239
+ const holderActorKey = record.actor_key || loadActorIdentityHelper().serializeActor(record.actor);
226
240
  if (selfActor && holderActorKey === selfActor)
227
241
  return { effective_state: "held", reason: "self_is_holder", holder: { actor: holderActorKey } };
228
242
  const fresh = freshHoldersList.find((holder) => holder.actor === holderActorKey);
@@ -349,22 +363,17 @@ function loadLivenessInputs(args) {
349
363
  }
350
364
  // ─── local-file: claim | release | supersede (the durable-write path; real I/O by design — no
351
365
  // external mutation to defer to a skill for this provider kind, per Design Decision 1) ─────────
352
- function claimLocalFile(argv) {
353
- const args = parseArgs(argv);
354
- const provider = flagString(args.flags, "provider", "local-file");
355
- if (provider !== "local-file")
356
- throw new Error(`claim: --provider must be local-file (use render-claim for github); got ${provider}`);
357
- const artifactRoot = requireFlag(args, "artifact-root");
358
- const subjectId = requireFlag(args, "subject-id");
359
- const actor = loadActorStruct(args);
366
+ /**
367
+ * Wave 1 (#291) extraction: the durable-write body previously inlined inside claimLocalFile's
368
+ * withSubjectLock() closure, now a parameter-driven pure function so ensure-session's ownership
369
+ * guard (workflow-sidecar.ts, Wave 2) can reuse the EXACT same claim logic — same-actor idempotent
370
+ * refresh, different-actor throw, atomic write under withSubjectLock rather than reimplementing
371
+ * a second, parallel claim path. claimLocalFile (CLI wrapper, below) is now a thin
372
+ * parse-args/print-envelope shell around this.
373
+ */
374
+ export function performLocalClaim(artifactRoot, subjectId, actor, opts) {
360
375
  const helper = loadActorIdentityHelper();
361
- const ttlSecondsRaw = flagString(args.flags, "ttl-seconds", "1800") ?? "1800";
362
- const ttlSeconds = Number(ttlSecondsRaw);
363
- if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0)
364
- throw new Error(`--ttl-seconds must be a positive number; got ${ttlSecondsRaw}`);
365
- const branch = requireFlag(args, "branch");
366
- const artifactDir = requireFlag(args, "artifact-dir");
367
- const reason = flagString(args.flags, "reason") ?? "claim";
376
+ const reason = opts.reason ?? "claim";
368
377
  // F1 fix (fix-plan iteration 1, CRITICAL): the existing-claim check AND the write must happen
369
378
  // atomically with respect to any other `assignment-provider` invocation on the same subject —
370
379
  // see withSubjectLock()'s doc comment for the full rationale.
@@ -384,57 +393,159 @@ function claimLocalFile(argv) {
384
393
  role: "AssignmentClaimRecord",
385
394
  subject_id: subjectId,
386
395
  actor,
396
+ ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
387
397
  claimed_at: isoNow(),
388
- ttl_seconds: ttlSeconds,
389
- branch,
390
- artifact_dir: artifactDir,
398
+ ttl_seconds: opts.ttlSeconds,
399
+ branch: opts.branch,
400
+ artifact_dir: opts.artifactDir,
391
401
  status: "claimed",
392
402
  audit_trail: [...(existing?.audit_trail ?? []), { at: isoNow(), transition: "claim", from_actor: null, to_actor: actor, reason }],
393
403
  };
394
404
  writeLocalRecord(artifactRoot, subjectId, record);
395
- console.log(JSON.stringify({ role: "AssignmentClaimResult", subject_id: subjectId, record }, null, 2));
396
- return 0;
405
+ return record;
397
406
  });
398
407
  }
399
- function releaseLocalFile(argv) {
408
+ function claimLocalFile(argv) {
400
409
  const args = parseArgs(argv);
401
410
  const provider = flagString(args.flags, "provider", "local-file");
402
411
  if (provider !== "local-file")
403
- throw new Error(`release: --provider must be local-file (use render-release for github); got ${provider}`);
412
+ throw new Error(`claim: --provider must be local-file (use render-claim for github); got ${provider}`);
404
413
  const artifactRoot = requireFlag(args, "artifact-root");
405
414
  const subjectId = requireFlag(args, "subject-id");
406
- const releasedBy = flagString(args.flags, "actor-json") ? loadActorStructFromFile(requireFlag(args, "actor-json")) : null;
407
- const reason = flagString(args.flags, "reason") ?? "released";
415
+ const { actor, actorKey } = loadActorStruct(args);
416
+ const ttlSecondsRaw = flagString(args.flags, "ttl-seconds", "1800") ?? "1800";
417
+ const ttlSeconds = Number(ttlSecondsRaw);
418
+ if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0)
419
+ throw new Error(`--ttl-seconds must be a positive number; got ${ttlSecondsRaw}`);
420
+ const branch = requireFlag(args, "branch");
421
+ const artifactDir = requireFlag(args, "artifact-dir");
422
+ const reason = flagString(args.flags, "reason") ?? "claim";
423
+ const record = performLocalClaim(artifactRoot, subjectId, actor, { ttlSeconds, branch, artifactDir, reason, actorKey });
424
+ console.log(JSON.stringify({ role: "AssignmentClaimResult", subject_id: subjectId, record }, null, 2));
425
+ return 0;
426
+ }
427
+ /**
428
+ * Wave 1 (#292) extraction: the durable-write body previously inlined inside releaseLocalFile's
429
+ * withSubjectLock() closure, now a parameter-driven pure function so the Stop hook's non-terminal
430
+ * release lifecycle (scripts/hooks/stop-goal-fit.js, #292 Wave 2) can reuse the EXACT same release
431
+ * logic — actor-ownership verification, audit-trail append, atomic write under withSubjectLock —
432
+ * rather than reimplementing a second, parallel release path. releaseLocalFile (CLI wrapper,
433
+ * below) is now a thin parse-args/print-envelope shell around this, mirroring the
434
+ * performLocalSupersede/supersedeLocalFile extraction shape exactly.
435
+ *
436
+ * Two behaviors are deliberately DIFFERENT from a naive inline release, both required for the
437
+ * Stop hook's idempotent, actor-scoped lifecycle release (never for the interactive CLI, which
438
+ * keeps `tolerateNoActiveClaim` unset/false and therefore 100% of its prior throw-on-error shape):
439
+ *
440
+ * - `opts.tolerateNoActiveClaim === true` and there is no existing record, or the existing
441
+ * record's status is not `"claimed"`: return `null` (a tolerated no-op) instead of throwing
442
+ * "no active claim to release". This is the one deliberate idempotency change vs today's
443
+ * releaseLocalFile — a second release call (e.g. a double Stop event) must be a safe no-op.
444
+ * - `releasedBy` is provided and does not match the existing record's holder: never force-release
445
+ * a claim held by a different actor — return `null` (if tolerateNoActiveClaim) or throw
446
+ * (otherwise), same as the no-active-claim case. The comparison mirrors computeEffectiveState()'s
447
+ * `record.actor_key || helper.serializeActor(record.actor)` canonical-key preference EXACTLY
448
+ * (actor_key-first, falling back to serializeActor only when actor_key is absent) — the read
449
+ * path (status/effective-state) and this write path (release) must use the identical
450
+ * canonical-key comparison, or a claim written under an explicit-override actor (`actor_key`
451
+ * bare, e.g. `"canonical-x"`, but `serializeActor(record.actor)` a DIFFERENT triple, e.g.
452
+ * `"explicit-override:canonical-x:host"`) can be self-recognized by computeEffectiveState() yet
453
+ * fail to release here because the releaser's canonical key was compared against the wrong
454
+ * (re-derived, triple) form instead of the stored actor_key. Comparing two serializeActor()
455
+ * calls unconditionally — as a prior version of this function did — is NOT correct for override
456
+ * actors and reintroduces the exact #291 seam on the release path.
457
+ *
458
+ * Contract: when `releasedBy` is provided AND the existing record is `actor_key`-stamped,
459
+ * `opts.actorKey` is REQUIRED (the canonical `resolveActor(env).actor` string) — otherwise
460
+ * ownership cannot be verified. A caller that passes `releasedBy` without `opts.actorKey` against
461
+ * an `actor_key`-stamped record would have its ownership compared as
462
+ * `existing.actor_key` (bare canonical) vs `serializeActor(releasedBy)` (re-derived triple), which
463
+ * can NEVER match even for the legitimate holder — a silent-failure trap, not a real ownership
464
+ * check. This is refused loudly (see the guard at the top of the `releasedBy` branch below) rather
465
+ * than allowed to silently no-op or wrongly refuse.
466
+ */
467
+ export function performLocalRelease(artifactRoot, subjectId, releasedBy, opts = {}) {
468
+ const helper = loadActorIdentityHelper();
469
+ const reason = opts.reason ?? "released";
470
+ const tolerateNoActiveClaim = opts.tolerateNoActiveClaim ?? false;
408
471
  // F1 fix (fix-plan iteration 1, CRITICAL): release mutates the same record file claim/supersede
409
472
  // do, under the same per-subject lock (see withSubjectLock()'s doc comment).
410
473
  return withSubjectLock(artifactRoot, subjectId, () => {
411
474
  const existing = readLocalRecord(artifactRoot, subjectId);
412
- if (!existing || existing.status !== "claimed")
475
+ if (!existing || existing.status !== "claimed") {
476
+ if (tolerateNoActiveClaim)
477
+ return null;
413
478
  throw new Error(`no active claim to release for subject: ${subjectId}`);
479
+ }
480
+ if (releasedBy) {
481
+ // Contract guard (hardening fix, #292 review): a caller that supplies `releasedBy` but NOT
482
+ // `opts.actorKey` against a record that already carries `actor_key` cannot reliably prove
483
+ // ownership — see this function's doc comment. This is the ONLY combination that fires: it
484
+ // does NOT fire when `existing.actor_key` is absent (the CLI/fixture path, where both sides
485
+ // fall back to serializeActor() and legitimately compare equal). Fail loudly rather than
486
+ // silently no-op (tolerant callers) or wrongly refuse (throwing callers) — never silent.
487
+ if (!opts.actorKey && existing.actor_key) {
488
+ if (tolerateNoActiveClaim) {
489
+ console.error(`[performLocalRelease] cannot verify ownership of an actor_key-stamped record without opts.actorKey; skipping release for ${subjectId}`);
490
+ return null;
491
+ }
492
+ throw new Error("performLocalRelease: pass opts.actorKey (the canonical resolveActor().actor string) when releasedBy is set and the record carries actor_key — serializeActor(releasedBy) is not a valid ownership key for actor_key-stamped records");
493
+ }
494
+ // AC6: never force-release a claim held by a different actor. Mirrors
495
+ // computeEffectiveState()'s canonical self-recognition comparison EXACTLY —
496
+ // `holderActorKey` prefers the stored `actor_key` (the canonical resolveActor(env).actor
497
+ // string, present on records written by the fixed performLocalClaim/performLocalSupersede
498
+ // paths) and only falls back to `serializeActor(existing.actor)` when `actor_key` is
499
+ // absent (every pre-fix record, every #290 eval fixture). The releaser's side must use the
500
+ // SAME canonical form: `opts.actorKey` (the caller's resolveActor(env).actor string, e.g.
501
+ // scripts/hooks/stop-goal-fit.js's Stop hook) when provided, else re-derived via
502
+ // serializeActor(releasedBy) — never serializeActor() unconditionally on both sides, which
503
+ // would compare the bare actor_key form against a re-derived triple form for an
504
+ // explicit-override actor and spuriously reject a legitimate same-actor release (the #291
505
+ // seam, relocated to this write path).
506
+ const holderActorKey = existing.actor_key || helper.serializeActor(existing.actor);
507
+ const releasedByActorKey = opts.actorKey || helper.serializeActor(releasedBy);
508
+ if (holderActorKey !== releasedByActorKey) {
509
+ if (tolerateNoActiveClaim)
510
+ return null;
511
+ throw new Error(`--actor-json does not match the current holder (${holderActorKey}); refusing to release a claim held by someone else`);
512
+ }
513
+ }
414
514
  const record = {
415
515
  ...existing,
516
+ ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
416
517
  status: "released",
417
518
  audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "release", from_actor: existing.actor, to_actor: releasedBy, reason }],
418
519
  };
419
520
  writeLocalRecord(artifactRoot, subjectId, record);
420
- console.log(JSON.stringify({ role: "AssignmentReleaseResult", subject_id: subjectId, record }, null, 2));
421
- return 0;
521
+ return record;
422
522
  });
423
523
  }
424
- function supersedeLocalFile(argv) {
524
+ function releaseLocalFile(argv) {
425
525
  const args = parseArgs(argv);
426
526
  const provider = flagString(args.flags, "provider", "local-file");
427
527
  if (provider !== "local-file")
428
- throw new Error(`supersede: --provider must be local-file (use render-supersede for github); got ${provider}`);
528
+ throw new Error(`release: --provider must be local-file (use render-release for github); got ${provider}`);
429
529
  const artifactRoot = requireFlag(args, "artifact-root");
430
530
  const subjectId = requireFlag(args, "subject-id");
531
+ const releasedBy = flagString(args.flags, "actor-json") ? loadActorStructFromFile(requireFlag(args, "actor-json")) : null;
532
+ const reason = flagString(args.flags, "reason") ?? "released";
533
+ const record = performLocalRelease(artifactRoot, subjectId, releasedBy, { reason, tolerateNoActiveClaim: false });
534
+ console.log(JSON.stringify({ role: "AssignmentReleaseResult", subject_id: subjectId, record }, null, 2));
535
+ return 0;
536
+ }
537
+ /**
538
+ * Wave 1 (#291) extraction: the durable-write body previously inlined inside supersedeLocalFile's
539
+ * withSubjectLock() closure, now a parameter-driven pure function so ensure-session's
540
+ * `--supersede-stale` takeover path (workflow-sidecar.ts, Wave 2) can reuse the EXACT same
541
+ * supersede logic — from-actor holder verification, ttl/branch/artifact_dir carry-forward,
542
+ * audit-trail append, atomic write under withSubjectLock — rather than reimplementing a second,
543
+ * parallel supersede path. supersedeLocalFile (CLI wrapper, below) is now a thin
544
+ * parse-args/print-envelope shell around this.
545
+ */
546
+ export function performLocalSupersede(artifactRoot, subjectId, fromActor, toActor, opts = {}) {
431
547
  const helper = loadActorIdentityHelper();
432
- const fromActor = loadActorStructFromFile(requireFlag(args, "from-actor-json"));
433
- const toActor = loadActorStructFromFile(requireFlag(args, "to-actor-json"));
434
- const reason = flagString(args.flags, "reason") ?? "supersede";
435
- const ttlSecondsOverride = flagString(args.flags, "ttl-seconds");
436
- const branchOverride = flagString(args.flags, "branch");
437
- const artifactDirOverride = flagString(args.flags, "artifact-dir");
548
+ const reason = opts.reason ?? "supersede";
438
549
  // F1 fix (fix-plan iteration 1, CRITICAL): supersede mutates the same record file claim/release
439
550
  // do, under the same per-subject lock (see withSubjectLock()'s doc comment).
440
551
  return withSubjectLock(artifactRoot, subjectId, () => {
@@ -444,7 +555,7 @@ function supersedeLocalFile(argv) {
444
555
  if (helper.serializeActor(existing.actor) !== helper.serializeActor(fromActor)) {
445
556
  throw new Error(`--from-actor-json does not match the current holder (${helper.serializeActor(existing.actor)}); refusing to supersede a claim held by someone else`);
446
557
  }
447
- const ttlSecondsRaw = ttlSecondsOverride ?? String(existing.ttl_seconds);
558
+ const ttlSecondsRaw = opts.ttlSeconds != null ? String(opts.ttlSeconds) : String(existing.ttl_seconds);
448
559
  const ttlSeconds = Number(ttlSecondsRaw);
449
560
  if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0)
450
561
  throw new Error(`--ttl-seconds must be a positive number; got ${ttlSecondsRaw}`);
@@ -453,17 +564,39 @@ function supersedeLocalFile(argv) {
453
564
  role: "AssignmentClaimRecord",
454
565
  subject_id: subjectId,
455
566
  actor: toActor,
567
+ ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
456
568
  claimed_at: isoNow(),
457
569
  ttl_seconds: ttlSeconds,
458
- branch: branchOverride ?? existing.branch,
459
- artifact_dir: artifactDirOverride ?? existing.artifact_dir,
570
+ branch: opts.branch ?? existing.branch,
571
+ artifact_dir: opts.artifactDir ?? existing.artifact_dir,
460
572
  status: "claimed",
461
573
  audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "supersede", from_actor: fromActor, to_actor: toActor, reason }],
462
574
  };
463
575
  writeLocalRecord(artifactRoot, subjectId, record);
464
- console.log(JSON.stringify({ role: "AssignmentSupersedeResult", subject_id: subjectId, record }, null, 2));
465
- return 0;
576
+ return record;
577
+ });
578
+ }
579
+ function supersedeLocalFile(argv) {
580
+ const args = parseArgs(argv);
581
+ const provider = flagString(args.flags, "provider", "local-file");
582
+ if (provider !== "local-file")
583
+ throw new Error(`supersede: --provider must be local-file (use render-supersede for github); got ${provider}`);
584
+ const artifactRoot = requireFlag(args, "artifact-root");
585
+ const subjectId = requireFlag(args, "subject-id");
586
+ const fromActor = loadActorStructFromFile(requireFlag(args, "from-actor-json"));
587
+ const toActor = loadActorStructFromFile(requireFlag(args, "to-actor-json"));
588
+ const reason = flagString(args.flags, "reason") ?? "supersede";
589
+ const ttlSecondsOverride = flagString(args.flags, "ttl-seconds");
590
+ const branchOverride = flagString(args.flags, "branch");
591
+ const artifactDirOverride = flagString(args.flags, "artifact-dir");
592
+ const record = performLocalSupersede(artifactRoot, subjectId, fromActor, toActor, {
593
+ ttlSeconds: ttlSecondsOverride != null ? Number(ttlSecondsOverride) : undefined,
594
+ branch: branchOverride ?? undefined,
595
+ artifactDir: artifactDirOverride ?? undefined,
596
+ reason,
466
597
  });
598
+ console.log(JSON.stringify({ role: "AssignmentSupersedeResult", subject_id: subjectId, record }, null, 2));
599
+ return 0;
467
600
  }
468
601
  // ─── GitHub: render-claim | render-release | render-supersede (render, don't execute — Design
469
602
  // Decision 1). Pure functions: no I/O beyond reading --input-json/--actor-json. Never invoke
@@ -624,6 +757,17 @@ function renderSupersede(argv) {
624
757
  return 0;
625
758
  }
626
759
  // ─── status | list (both provider kinds) ────────────────────────────────────────────────────
760
+ /**
761
+ * Wave 1 (#291) extraction: the local-file branch of statusCommand's assignment-layer read,
762
+ * mirrored exactly so ensure-session's ownership guard (workflow-sidecar.ts, Wave 2) derives an
763
+ * AssignmentStatus identically to the `assignment-provider status` CLI command — a single
764
+ * implementation, not a second parallel local-file read.
765
+ */
766
+ export function readLocalAssignmentStatus(artifactRoot, subjectId) {
767
+ const record = readLocalRecord(artifactRoot, subjectId);
768
+ const active = record && record.status === "claimed" ? record : null;
769
+ return { subject_id: subjectId, provider: "local-file", assignee: active ? loadActorIdentityHelper().serializeActor(active.actor) : null, record: active };
770
+ }
627
771
  function statusCommand(argv) {
628
772
  const args = parseArgs(argv);
629
773
  const provider = requireFlag(args, "provider");
@@ -633,9 +777,7 @@ function statusCommand(argv) {
633
777
  const artifactRoot = requireFlag(args, "artifact-root");
634
778
  if (!requestedSubjectId)
635
779
  throw new Error("--subject-id is required for status --provider local-file");
636
- const record = readLocalRecord(artifactRoot, requestedSubjectId);
637
- const active = record && record.status === "claimed" ? record : null;
638
- assignment = { subject_id: requestedSubjectId, provider: "local-file", assignee: active ? loadActorIdentityHelper().serializeActor(active.actor) : null, record: active };
780
+ assignment = readLocalAssignmentStatus(artifactRoot, requestedSubjectId);
639
781
  }
640
782
  else if (provider === "github") {
641
783
  const issueJsonPath = requireFlag(args, "issue-json");
@@ -145,7 +145,7 @@ type SurfaceModule = {
145
145
  * @param critiques Critique objects (from critique.json .critiques array)
146
146
  * @param commandLog Optional parsed command-log.jsonl entries (capture-authoritative fold)
147
147
  */
148
- export declare function buildTrustBundle(slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], commandLog?: AnyObj[], flowAgentsDir?: string): Promise<AnyObj | null>;
148
+ export declare function buildTrustBundle(slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], commandLog?: AnyObj[], flowAgentsDir?: string, actorKey?: string): Promise<AnyObj | null>;
149
149
  /**
150
150
  * Fail-open wrapper: builds (via Surface), validates, and writes a trust.bundle.
151
151
  * Accepts raw check/criterion/critique inputs directly (ADR 0010 Phase 4a).
@@ -159,7 +159,7 @@ export declare function buildTrustBundle(slug: string, timestamp: string, checks
159
159
  * @param criteria Acceptance criteria objects (same as buildTrustBundle)
160
160
  * @param critiques Critique objects (same as buildTrustBundle)
161
161
  */
162
- export declare function writeTrustBundle(dir: string, slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[]): Promise<{
162
+ export declare function writeTrustBundle(dir: string, slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], actorKey?: string): Promise<{
163
163
  written: boolean;
164
164
  errors: string[];
165
165
  }>;
@@ -204,19 +204,28 @@ export declare function sealTrustCheckpoint(dir: string, slug: string, sealedAt:
204
204
  /**
205
205
  * Publish the session's trust artifacts to the committed delivery/ path.
206
206
  *
207
+ * #379: writes to a PER-SESSION path `<repoRoot>/delivery/<slug>/` (slug = the session
208
+ * artifact dir's basename) rather than the shared flat `delivery/`. A shared path guarantees
209
+ * a git conflict between ANY two concurrent deliveries, and a conflicting (DIRTY) PR gets NO
210
+ * pull_request workflows — the required Trust Reconcile check silently never runs (field
211
+ * incidents #330/#358/#378). Per-session paths make concurrent deliveries write DISTINCT
212
+ * files that never contend. trust-reconcile.js reads both the flat (back-compat) and
213
+ * per-session layouts via resolveDeliveryCandidates() and selects the owning candidate by
214
+ * commit ancestry.
215
+ *
207
216
  * Copies trust.bundle, trust.checkpoint.json, and (if present)
208
- * trust.checkpoint.intoto.json / trust.checkpoint.sig.json from the
209
- * session artifact dir to <repoRoot>/delivery/.
217
+ * trust.checkpoint.intoto.json / trust.checkpoint.sig.json / trust.checkpoint.attestation.json
218
+ * from the session artifact dir to <repoRoot>/delivery/<slug>/.
210
219
  *
211
220
  * Fail-soft on a missing bundle: if trust.bundle is absent, returns without throwing.
212
221
  * Fail-CLOSED on repo-root resolution: repoRoot must be a real, resolved kits/ ancestor
213
222
  * (see findRepoRootFromDirStrict) — null (no ancestor found) skips the publish with a
214
223
  * visible warning instead of writing to whatever process.cwd() happens to be. This
215
224
  * prevents a scratch/test session dir (no kits/ ancestor) from silently clobbering an
216
- * unrelated real repo's delivery/trust.bundle when invoked with that repo as cwd (see
225
+ * unrelated real repo's delivery/ seal when invoked with that repo as cwd (see
217
226
  * evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings at
218
227
  * .kontourai/flow-agents/ws5-governance-kit-slice1 for the root cause this fixes).
219
- * Idempotent: overwrites on re-delivery.
228
+ * Idempotent: overwrites on re-delivery to the same slug.
220
229
  */
221
230
  export declare function publishDelivery(dir: string, repoRoot: string | null): Promise<void>;
222
231
  export declare function validateLearningCorrection(record: AnyObj): void;
@@ -301,5 +310,6 @@ export declare function gateAdvisoryFix(calibration: GateCalibration, claimId: s
301
310
  * @param now Optional timestamp override for deterministic tests
302
311
  */
303
312
  export declare function buildGateInquiryRecords(bundle: BundleFile, blockSignal: GateBlockSignal, slug: string, expectedCriterionIds: string[], surface: SurfaceModule, now?: Date): AnyObj[];
313
+ export declare const LIVENESS_TERMINAL: Set<string>;
304
314
  export { buildClaimExplanation } from "./sidecar-claim-explain.js";
305
315
  export type { ClaimEvidenceItem, ClaimExplanation } from "./sidecar-claim-explain.js";