@kontourai/flow-agents 3.1.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 (52) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +17 -0
  3. package/build/src/cli/assignment-provider.d.ts +45 -0
  4. package/build/src/cli/assignment-provider.js +97 -12
  5. package/build/src/cli/workflow-sidecar.d.ts +14 -4
  6. package/build/src/cli/workflow-sidecar.js +100 -10
  7. package/context/contracts/assignment-provider-contract.md +1 -1
  8. package/context/contracts/execution-contract.md +78 -0
  9. package/context/scripts/hooks/config-protection.js +11 -4
  10. package/context/scripts/hooks/stop-goal-fit.js +259 -4
  11. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
  12. package/evals/ci/run-baseline.sh +2 -0
  13. package/evals/integration/test_checkpoint_signing.sh +4 -3
  14. package/evals/integration/test_gate_lockdown.sh +36 -0
  15. package/evals/integration/test_model_routing_escalation.sh +145 -0
  16. package/evals/integration/test_publish_delivery.sh +14 -6
  17. package/evals/integration/test_stop_hook_release.sh +552 -0
  18. package/evals/integration/test_trust_reconcile_negatives.sh +170 -0
  19. package/evals/run.sh +6 -0
  20. package/evals/static/test_model_routing_hints.sh +107 -0
  21. package/kits/builder/skills/builder-shape/SKILL.md +10 -0
  22. package/kits/builder/skills/deliver/SKILL.md +52 -11
  23. package/kits/builder/skills/design-probe/SKILL.md +10 -0
  24. package/kits/builder/skills/execute-plan/SKILL.md +13 -0
  25. package/kits/builder/skills/fix-bug/SKILL.md +17 -0
  26. package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
  27. package/kits/builder/skills/plan-work/SKILL.md +9 -0
  28. package/kits/builder/skills/pull-work/SKILL.md +10 -0
  29. package/kits/builder/skills/review-work/SKILL.md +11 -0
  30. package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
  31. package/kits/builder/skills/verify-work/SKILL.md +11 -0
  32. package/kits/knowledge/adapters/default-store/index.js +56 -15
  33. package/kits/knowledge/adapters/flow-runner/index.js +912 -16
  34. package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
  35. package/kits/knowledge/adapters/shared/codec.js +124 -0
  36. package/kits/knowledge/docs/store-contract.md +405 -3
  37. package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
  38. package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
  39. package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
  40. package/kits/knowledge/evals/contract-suite/suite.test.js +36 -0
  41. package/kits/knowledge/evals/freshness/suite.test.js +339 -0
  42. package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
  43. package/kits/knowledge/evals/retirement/suite.test.js +1 -1
  44. package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
  45. package/package.json +1 -1
  46. package/schemas/workflow-handoff.schema.json +6 -0
  47. package/scripts/ci/mint-attestation.js +33 -6
  48. package/scripts/ci/trust-reconcile.js +144 -26
  49. package/scripts/hooks/config-protection.js +11 -4
  50. package/scripts/hooks/stop-goal-fit.js +259 -4
  51. package/src/cli/assignment-provider.ts +110 -12
  52. package/src/cli/workflow-sidecar.ts +99 -10
@@ -157,6 +157,10 @@ jobs:
157
157
  continue-on-error: true
158
158
  run: bash evals/ci/run-baseline.sh --check assignment-provider-github-integration
159
159
 
160
+ - name: Stop hook release-with-handoff integration
161
+ continue-on-error: true
162
+ run: bash evals/ci/run-baseline.sh --check stop-hook-release-with-handoff-integration
163
+
160
164
  - name: Pull work assignment join integration
161
165
  continue-on-error: true
162
166
  run: bash evals/ci/run-baseline.sh --check pull-work-assignment-join-integration
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.2.0](https://github.com/kontourai/flow-agents/compare/v3.1.0...v3.2.0) (2026-07-04)
4
+
5
+
6
+ ### Features
7
+
8
+ * **builder:** per-step model-routing hints + escalate-on-gate-failure ladder ([#376](https://github.com/kontourai/flow-agents/issues/376)) ([8182dc6](https://github.com/kontourai/flow-agents/commit/8182dc66da10f3bfd731aef93d7cbd38b63ddd06))
9
+ * **knowledge:** inbound-reference integrity check, fail closed ([#340](https://github.com/kontourai/flow-agents/issues/340)) ([02932ab](https://github.com/kontourai/flow-agents/commit/02932abec98fcc43ae3755d2281697e21c7451a6))
10
+ * **knowledge:** incremental append-mode consolidate (Closes [#343](https://github.com/kontourai/flow-agents/issues/343)) ([9b972f5](https://github.com/kontourai/flow-agents/commit/9b972f5bf9d7a41d1de4af23d5e27619db3b1635))
11
+ * **knowledge:** incremental append-mode consolidate, regenerate snapshot from records ([#343](https://github.com/kontourai/flow-agents/issues/343)) ([32cc263](https://github.com/kontourai/flow-agents/commit/32cc263aedc4c3727b144ced24872bc102a13383))
12
+ * **knowledge:** record-carried freshness + Hachure-aligned status semantics ([#341](https://github.com/kontourai/flow-agents/issues/341)) ([32ff45e](https://github.com/kontourai/flow-agents/commit/32ff45eb9244fa1879a0b32784e95e5e5c80de71))
13
+
14
+
15
+ ### Fixes
16
+
17
+ * **delivery:** per-session delivery paths so concurrent deliveries stop contending ([#379](https://github.com/kontourai/flow-agents/issues/379)) ([fedf4db](https://github.com/kontourai/flow-agents/commit/fedf4db5c41c7e45dcf9db949176be076131a09e))
18
+ * **delivery:** per-session delivery paths so concurrent deliveries stop contending ([#379](https://github.com/kontourai/flow-agents/issues/379)) ([a03026f](https://github.com/kontourai/flow-agents/commit/a03026fccf940da2129a3b82e979443c37b87456))
19
+
3
20
  ## [3.1.0](https://github.com/kontourai/flow-agents/compare/v3.0.0...v3.1.0) (2026-07-04)
4
21
 
5
22
 
@@ -117,6 +117,51 @@ export declare function performLocalClaim(artifactRoot: string, subjectId: strin
117
117
  reason?: string;
118
118
  actorKey?: string;
119
119
  }): AssignmentClaimRecord;
120
+ /**
121
+ * Wave 1 (#292) extraction: the durable-write body previously inlined inside releaseLocalFile's
122
+ * withSubjectLock() closure, now a parameter-driven pure function so the Stop hook's non-terminal
123
+ * release lifecycle (scripts/hooks/stop-goal-fit.js, #292 Wave 2) can reuse the EXACT same release
124
+ * logic — actor-ownership verification, audit-trail append, atomic write under withSubjectLock —
125
+ * rather than reimplementing a second, parallel release path. releaseLocalFile (CLI wrapper,
126
+ * below) is now a thin parse-args/print-envelope shell around this, mirroring the
127
+ * performLocalSupersede/supersedeLocalFile extraction shape exactly.
128
+ *
129
+ * Two behaviors are deliberately DIFFERENT from a naive inline release, both required for the
130
+ * Stop hook's idempotent, actor-scoped lifecycle release (never for the interactive CLI, which
131
+ * keeps `tolerateNoActiveClaim` unset/false and therefore 100% of its prior throw-on-error shape):
132
+ *
133
+ * - `opts.tolerateNoActiveClaim === true` and there is no existing record, or the existing
134
+ * record's status is not `"claimed"`: return `null` (a tolerated no-op) instead of throwing
135
+ * "no active claim to release". This is the one deliberate idempotency change vs today's
136
+ * releaseLocalFile — a second release call (e.g. a double Stop event) must be a safe no-op.
137
+ * - `releasedBy` is provided and does not match the existing record's holder: never force-release
138
+ * a claim held by a different actor — return `null` (if tolerateNoActiveClaim) or throw
139
+ * (otherwise), same as the no-active-claim case. The comparison mirrors computeEffectiveState()'s
140
+ * `record.actor_key || helper.serializeActor(record.actor)` canonical-key preference EXACTLY
141
+ * (actor_key-first, falling back to serializeActor only when actor_key is absent) — the read
142
+ * path (status/effective-state) and this write path (release) must use the identical
143
+ * canonical-key comparison, or a claim written under an explicit-override actor (`actor_key`
144
+ * bare, e.g. `"canonical-x"`, but `serializeActor(record.actor)` a DIFFERENT triple, e.g.
145
+ * `"explicit-override:canonical-x:host"`) can be self-recognized by computeEffectiveState() yet
146
+ * fail to release here because the releaser's canonical key was compared against the wrong
147
+ * (re-derived, triple) form instead of the stored actor_key. Comparing two serializeActor()
148
+ * calls unconditionally — as a prior version of this function did — is NOT correct for override
149
+ * actors and reintroduces the exact #291 seam on the release path.
150
+ *
151
+ * Contract: when `releasedBy` is provided AND the existing record is `actor_key`-stamped,
152
+ * `opts.actorKey` is REQUIRED (the canonical `resolveActor(env).actor` string) — otherwise
153
+ * ownership cannot be verified. A caller that passes `releasedBy` without `opts.actorKey` against
154
+ * an `actor_key`-stamped record would have its ownership compared as
155
+ * `existing.actor_key` (bare canonical) vs `serializeActor(releasedBy)` (re-derived triple), which
156
+ * can NEVER match even for the legitimate holder — a silent-failure trap, not a real ownership
157
+ * check. This is refused loudly (see the guard at the top of the `releasedBy` branch below) rather
158
+ * than allowed to silently no-op or wrongly refuse.
159
+ */
160
+ export declare function performLocalRelease(artifactRoot: string, subjectId: string, releasedBy: ActorStruct | null, opts?: {
161
+ reason?: string;
162
+ actorKey?: string;
163
+ tolerateNoActiveClaim?: boolean;
164
+ }): AssignmentClaimRecord | null;
120
165
  /**
121
166
  * Wave 1 (#291) extraction: the durable-write body previously inlined inside supersedeLocalFile's
122
167
  * withSubjectLock() closure, now a parameter-driven pure function so ensure-session's
@@ -424,31 +424,116 @@ function claimLocalFile(argv) {
424
424
  console.log(JSON.stringify({ role: "AssignmentClaimResult", subject_id: subjectId, record }, null, 2));
425
425
  return 0;
426
426
  }
427
- function releaseLocalFile(argv) {
428
- const args = parseArgs(argv);
429
- const provider = flagString(args.flags, "provider", "local-file");
430
- if (provider !== "local-file")
431
- throw new Error(`release: --provider must be local-file (use render-release for github); got ${provider}`);
432
- const artifactRoot = requireFlag(args, "artifact-root");
433
- const subjectId = requireFlag(args, "subject-id");
434
- const releasedBy = flagString(args.flags, "actor-json") ? loadActorStructFromFile(requireFlag(args, "actor-json")) : null;
435
- const reason = flagString(args.flags, "reason") ?? "released";
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;
436
471
  // F1 fix (fix-plan iteration 1, CRITICAL): release mutates the same record file claim/supersede
437
472
  // do, under the same per-subject lock (see withSubjectLock()'s doc comment).
438
473
  return withSubjectLock(artifactRoot, subjectId, () => {
439
474
  const existing = readLocalRecord(artifactRoot, subjectId);
440
- if (!existing || existing.status !== "claimed")
475
+ if (!existing || existing.status !== "claimed") {
476
+ if (tolerateNoActiveClaim)
477
+ return null;
441
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
+ }
442
514
  const record = {
443
515
  ...existing,
516
+ ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
444
517
  status: "released",
445
518
  audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "release", from_actor: existing.actor, to_actor: releasedBy, reason }],
446
519
  };
447
520
  writeLocalRecord(artifactRoot, subjectId, record);
448
- console.log(JSON.stringify({ role: "AssignmentReleaseResult", subject_id: subjectId, record }, null, 2));
449
- return 0;
521
+ return record;
450
522
  });
451
523
  }
524
+ function releaseLocalFile(argv) {
525
+ const args = parseArgs(argv);
526
+ const provider = flagString(args.flags, "provider", "local-file");
527
+ if (provider !== "local-file")
528
+ throw new Error(`release: --provider must be local-file (use render-release for github); got ${provider}`);
529
+ const artifactRoot = requireFlag(args, "artifact-root");
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
+ }
452
537
  /**
453
538
  * Wave 1 (#291) extraction: the durable-write body previously inlined inside supersedeLocalFile's
454
539
  * withSubjectLock() closure, now a parameter-driven pure function so ensure-session's
@@ -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";
@@ -1623,7 +1623,30 @@ function recordAgentEvent(p) {
1623
1623
  requireArtifactDirUnderRoot(dir, root);
1624
1624
  const timestamp = opt(p, "timestamp", now());
1625
1625
  const agent = validateAgentId(opt(p, "agent-id"));
1626
- const event = { timestamp, agent_id: agent, kind: opt(p, "kind", "note"), status: opt(p, "status", "info"), summary: opt(p, "summary"), ...(opt(p, "ref") ? { ref: opt(p, "ref") } : {}) };
1626
+ // #376 model routing: optionally stamp the delegate role/model resolved from
1627
+ // .datum/config.json onto the event so a downstream economics record (#349)
1628
+ // can price role assignments per delegation, and so an escalate-on-gate-failure
1629
+ // re-dispatch records which tier it climbed FROM. Fully additive/optional: when
1630
+ // no routing flag is passed the event shape is byte-identical to before.
1631
+ // These live as TOP-LEVEL event fields (not nested) on purpose: appendJsonl's
1632
+ // serializer (spacedLine) uses the top-level key list as a JSON.stringify array
1633
+ // replacer, which is an allowlist applied at every nesting level — a nested
1634
+ // routing object would have its inner keys stripped. Flat keeps the shape a
1635
+ // simple per-event routing record a JSONL economics feed can read directly.
1636
+ const role = opt(p, "role");
1637
+ const model = opt(p, "model");
1638
+ const escalatedFrom = opt(p, "escalated-from");
1639
+ const event = {
1640
+ timestamp,
1641
+ agent_id: agent,
1642
+ kind: opt(p, "kind", "note"),
1643
+ status: opt(p, "status", "info"),
1644
+ summary: opt(p, "summary"),
1645
+ ...(opt(p, "ref") ? { ref: opt(p, "ref") } : {}),
1646
+ ...(role ? { role } : {}),
1647
+ ...(model ? { model } : {}),
1648
+ ...(escalatedFrom ? { escalated_from: escalatedFrom } : {}),
1649
+ };
1627
1650
  appendJsonl(path.join(dir, "agents", agent, "events.jsonl"), event);
1628
1651
  updateCurrentAgent(root, dir, agent, event.status, timestamp, actorKey);
1629
1652
  return 0;
@@ -2582,48 +2605,115 @@ async function sealCheckpoint(p) {
2582
2605
  // Idempotent: overwrites on re-delivery.
2583
2606
  // Called automatically from recordRelease and advanceState→delivered (best-effort).
2584
2607
  // Also exposed as the `publish-delivery <artifact-dir>` subcommand for explicit use.
2608
+ /**
2609
+ * #379 supersede-on-publish cleanup: keep delivery/ bounded by pruning inherited PER-SESSION
2610
+ * seal dirs (the growth vector), scoped to avoid any cross-PR conflict.
2611
+ *
2612
+ * An inherited per-session seal dir (`delivery/<other-slug>/`) is UNIQUELY named, so pruning
2613
+ * it can never conflict with a concurrent PR: two branches deleting the same inherited dir is
2614
+ * a delete/delete (auto-merges), and each new delivery adds its OWN distinct
2615
+ * `delivery/<slug>/`. And it is HARMLESS to leave: trust-reconcile.js's prefer-newest
2616
+ * ownership selection always picks THIS session's fresher bundle over an older inherited one.
2617
+ * Pruning is therefore purely to stop unbounded accumulation of permanently-superseded dirs.
2618
+ *
2619
+ * The SHARED FLAT path (`delivery/trust.bundle` + checkpoints) is deliberately NOT pruned
2620
+ * here. During the migration window other concurrent PRs may still seal to the flat path;
2621
+ * deleting it would produce a modify/delete conflict → a DIRTY PR → the no-CI failure this
2622
+ * whole change fixes. The flat path is a single fixed location (not a growth vector) and the
2623
+ * reconciler treats a stale flat bundle as non-owning / older-owning, so leaving it is safe.
2624
+ * Removing the flat legacy seals is a one-time cleanup for a dedicated PR once no open PR
2625
+ * still seals to it — intentionally NOT bundled into every delivery.
2626
+ *
2627
+ * Best-effort: a prune failure is logged, never fatal to the delivery. Never touches
2628
+ * README.md, DECLARED, the flat seal files, or any subdir that is not itself a seal dir.
2629
+ */
2630
+ function pruneSupersededSeals(deliveryDir, keepSlug) {
2631
+ let entries = [];
2632
+ try {
2633
+ entries = fs.readdirSync(deliveryDir, { withFileTypes: true });
2634
+ }
2635
+ catch {
2636
+ entries = [];
2637
+ }
2638
+ for (const entry of entries) {
2639
+ if (!entry.isDirectory() || entry.name === keepSlug)
2640
+ continue;
2641
+ const subdir = path.join(deliveryDir, entry.name);
2642
+ // Only prune dirs that actually look like a seal dir (contain a trust.bundle or
2643
+ // trust.checkpoint.json) — never an unrelated directory a human placed under delivery/.
2644
+ const looksLikeSeal = fs.existsSync(path.join(subdir, "trust.bundle")) ||
2645
+ fs.existsSync(path.join(subdir, "trust.checkpoint.json"));
2646
+ if (!looksLikeSeal)
2647
+ continue;
2648
+ try {
2649
+ fs.rmSync(subdir, { recursive: true, force: true });
2650
+ process.stderr.write(`[publish-delivery] #379: pruned superseded per-session seal delivery/${entry.name}/ (older-owning/stale; the reconciler selects the newest bundle regardless)\n`);
2651
+ }
2652
+ catch (err) {
2653
+ process.stderr.write(`[publish-delivery] #379: could not prune per-session seal delivery/${entry.name}/ (non-fatal): ${err instanceof Error ? err.message : String(err)}\n`);
2654
+ }
2655
+ }
2656
+ }
2585
2657
  /**
2586
2658
  * Publish the session's trust artifacts to the committed delivery/ path.
2587
2659
  *
2660
+ * #379: writes to a PER-SESSION path `<repoRoot>/delivery/<slug>/` (slug = the session
2661
+ * artifact dir's basename) rather than the shared flat `delivery/`. A shared path guarantees
2662
+ * a git conflict between ANY two concurrent deliveries, and a conflicting (DIRTY) PR gets NO
2663
+ * pull_request workflows — the required Trust Reconcile check silently never runs (field
2664
+ * incidents #330/#358/#378). Per-session paths make concurrent deliveries write DISTINCT
2665
+ * files that never contend. trust-reconcile.js reads both the flat (back-compat) and
2666
+ * per-session layouts via resolveDeliveryCandidates() and selects the owning candidate by
2667
+ * commit ancestry.
2668
+ *
2588
2669
  * Copies trust.bundle, trust.checkpoint.json, and (if present)
2589
- * trust.checkpoint.intoto.json / trust.checkpoint.sig.json from the
2590
- * session artifact dir to <repoRoot>/delivery/.
2670
+ * trust.checkpoint.intoto.json / trust.checkpoint.sig.json / trust.checkpoint.attestation.json
2671
+ * from the session artifact dir to <repoRoot>/delivery/<slug>/.
2591
2672
  *
2592
2673
  * Fail-soft on a missing bundle: if trust.bundle is absent, returns without throwing.
2593
2674
  * Fail-CLOSED on repo-root resolution: repoRoot must be a real, resolved kits/ ancestor
2594
2675
  * (see findRepoRootFromDirStrict) — null (no ancestor found) skips the publish with a
2595
2676
  * visible warning instead of writing to whatever process.cwd() happens to be. This
2596
2677
  * prevents a scratch/test session dir (no kits/ ancestor) from silently clobbering an
2597
- * unrelated real repo's delivery/trust.bundle when invoked with that repo as cwd (see
2678
+ * unrelated real repo's delivery/ seal when invoked with that repo as cwd (see
2598
2679
  * evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings at
2599
2680
  * .kontourai/flow-agents/ws5-governance-kit-slice1 for the root cause this fixes).
2600
- * Idempotent: overwrites on re-delivery.
2681
+ * Idempotent: overwrites on re-delivery to the same slug.
2601
2682
  */
2602
2683
  export async function publishDelivery(dir, repoRoot) {
2603
2684
  const bundleSrc = path.join(dir, "trust.bundle");
2604
2685
  if (!fs.existsSync(bundleSrc))
2605
2686
  return; // no bundle — skip gracefully
2606
2687
  if (!repoRoot) {
2607
- process.stderr.write(`[publish-delivery] WARNING: no kits/ ancestor found from ${dir}; skipping publish. Refusing to fall back to process.cwd() to avoid clobbering an unrelated repo's delivery/trust.bundle. Pass --repo-root explicitly if this session dir is intentionally outside a repo checkout.\n`);
2688
+ process.stderr.write(`[publish-delivery] WARNING: no kits/ ancestor found from ${dir}; skipping publish. Refusing to fall back to process.cwd() to avoid clobbering an unrelated repo's delivery/ seal. Pass --repo-root explicitly if this session dir is intentionally outside a repo checkout.\n`);
2608
2689
  return;
2609
2690
  }
2610
2691
  const deliveryDir = path.join(repoRoot, "delivery");
2692
+ // #379: slug is the session artifact dir's basename — the same human-meaningful id used
2693
+ // throughout the session (.kontourai/flow-agents/<slug>/). The per-session dir NAME is only
2694
+ // a collision-avoidance handle; ownership is decided by commit ancestry, not by name.
2695
+ const slug = path.basename(path.resolve(dir));
2696
+ const sessionDeliveryDir = path.join(deliveryDir, slug);
2611
2697
  fs.mkdirSync(deliveryDir, { recursive: true });
2698
+ // Supersede inherited/flat seals BEFORE writing this session's dir (keepSlug = our own).
2699
+ pruneSupersededSeals(deliveryDir, slug);
2700
+ fs.mkdirSync(sessionDeliveryDir, { recursive: true });
2612
2701
  // Required: trust.bundle (the CI anchor)
2613
- fs.copyFileSync(bundleSrc, path.join(deliveryDir, "trust.bundle"));
2702
+ fs.copyFileSync(bundleSrc, path.join(sessionDeliveryDir, "trust.bundle"));
2614
2703
  // Optional companions: checkpoint + signing artifacts
2615
2704
  const companions = [
2616
2705
  "trust.checkpoint.json",
2617
2706
  "trust.checkpoint.intoto.json",
2618
2707
  "trust.checkpoint.sig.json",
2708
+ "trust.checkpoint.attestation.json",
2619
2709
  ];
2620
2710
  for (const filename of companions) {
2621
2711
  const src = path.join(dir, filename);
2622
2712
  if (fs.existsSync(src)) {
2623
- fs.copyFileSync(src, path.join(deliveryDir, filename));
2713
+ fs.copyFileSync(src, path.join(sessionDeliveryDir, filename));
2624
2714
  }
2625
2715
  }
2626
- process.stderr.write(`[publish-delivery] published trust.bundle and companions to ${deliveryDir}\n`);
2716
+ process.stderr.write(`[publish-delivery] published trust.bundle and companions to ${sessionDeliveryDir} (per-session path, #379)\n`);
2627
2717
  }
2628
2718
  /**
2629
2719
  * publish-delivery <artifact-dir> [--repo-root <path>]
@@ -3369,7 +3459,7 @@ function loadLivenessReadHelper() {
3369
3459
  // init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
3370
3460
  // so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
3371
3461
  // Additive + fail-open: a liveness-emit failure never affects the workflow command.
3372
- const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
3462
+ export const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
3373
3463
  /**
3374
3464
  * Delegate to the shared pure-CJS resolver (scripts/hooks/lib/actor-identity.js), mirroring the
3375
3465
  * createRequire pattern used by readLivenessEvents() above. Deliberately NO inline duplicate
@@ -94,7 +94,7 @@ delivers all four:
94
94
  | --- | --- | --- | --- |
95
95
  | `claim` | the claiming session | at selection | **#290 (this issue)** |
96
96
  | `claim` / `supersede` | `ensure-session`'s pre-entry ownership guard (a SECOND mutator, alongside `assignment-provider claim`) | on session entry, before any session directory is created — `free` establishes a claim, `reclaimable` requires explicit `--supersede-stale` | **#291** |
97
- | `clean_release` | the incumbent (Stop hook / terminal `advance-state`) | session end — unassign + handoff comment | #292 |
97
+ | `clean_release` | the incumbent (Stop hook / terminal `advance-state`) | session end — unassign + handoff comment | **#292** — the Stop hook (`scripts/hooks/stop-goal-fit.js`) on clean non-terminal session end: liveness release + local-file assignment release (`performLocalRelease`) run inline; for the github provider the hook cannot run `gh`, so it honestly defers via a `provider_release_pending` handoff intent rather than pretending to complete the release |
98
98
  | `supersede` | the successor, inside the takeover protocol | after the grace beat | #294 |
99
99
  | `crash_no_successor` | nobody, initially; corrected by the next actor to want the subject, or the janitor | lazily, or on a janitor sweep | out of scope — the Console relay's first cross-machine duty (ADR 0021 §4/§7) |
100
100
 
@@ -67,6 +67,84 @@ model backs which delegate role. This contract only says where to read it.
67
67
  fallback in the session/task artifact — do not block delegation on datum
68
68
  being present.
69
69
 
70
+ ### Tier ladder
71
+
72
+ The delegate roles form a cost/capability ladder that both escalation and the
73
+ Goodhart guard below reference. Lowest to highest:
74
+
75
+ ```
76
+ delegate-mechanical < delegate-implementation < delegate-design
77
+ ```
78
+
79
+ `orchestrator` is off-ladder — it is the session's own model (planning, gates,
80
+ adversarial verification), typically inherited rather than overridden, and sits
81
+ above `delegate-design` for escalation purposes. `extraction-default` is a
82
+ non-delegate role (bulk extraction) and is not part of this ladder.
83
+
84
+ ### Escalation on gate failure
85
+
86
+ Delegate at the step's hinted tier, then let the gates enforce cost safety:
87
+
88
+ - Dispatch the delegate at the tier its skill hint names (a cheap tier for
89
+ mechanical/fully-specified work).
90
+ - On a review or verify **gate FAILURE of that delegate's output**, re-dispatch
91
+ the **fix** one tier higher on the ladder — `delegate-mechanical` failures
92
+ escalate to `delegate-implementation`, `delegate-implementation` failures to
93
+ `delegate-design`, and a `delegate-design` failure escalates to the
94
+ `orchestrator` / a human decision. Do not re-dispatch the fix at the same
95
+ tier that already failed its gate.
96
+ - Record every escalation in the session artifact (see *Routing decisions in
97
+ the run artifact* below) with the tier it climbed **from**, so the run shows
98
+ why a more expensive model was used.
99
+ - This is the fail-closed cost story: cheap tiers are safe **because** the gates
100
+ catch their misses and escalate. Routing cheap-by-default never weakens
101
+ verification — an ungated cheap delegate is what this ladder exists to prevent.
102
+
103
+ ### Goodhart guard (review/verify never cheaper than the work)
104
+
105
+ Review and verify roles resolve at a tier **greater than or equal to** the tier
106
+ of the work they check; they never auto-downgrade below it.
107
+
108
+ - If the checked work was produced at `delegate-design`, its reviewer/verifier
109
+ resolves `delegate-design` (or `orchestrator`) — never `delegate-implementation`
110
+ or `delegate-mechanical`.
111
+ - If the checked work was produced at `delegate-implementation`, its
112
+ reviewer/verifier resolves at `delegate-implementation` or higher.
113
+ - Rationale: a cheaper checker rubber-stamping a more capable worker's output
114
+ defeats the gate and turns the routing table into a Goodhart target (optimize
115
+ the cost metric, lose the thing the metric was a proxy for). The gate must be
116
+ at least as capable as the work, or the fail-closed escalation story above is
117
+ hollow.
118
+
119
+ ### Routing decisions in the run artifact
120
+
121
+ Record each delegation's resolved role and model on the session artifact so a
122
+ downstream economics record (`flow-agents#349`) can price role assignments and a
123
+ baseline harness (`flow-agents#350`) can A/B tiers. Use the sidecar writer's
124
+ `record-agent-event` (additive `--role` / `--model` / `--escalated-from` flags):
125
+
126
+ ```bash
127
+ # per-delegation routing decision
128
+ npm run workflow:sidecar -- record-agent-event \
129
+ --agent-id <delegate-id> --kind delegation --status active \
130
+ --role <resolved-role> --model "<resolved-model@provider>" \
131
+ --summary "<what was delegated>"
132
+
133
+ # an escalate-on-gate-failure re-dispatch
134
+ npm run workflow:sidecar -- record-agent-event \
135
+ --agent-id <delegate-id> --kind escalation --status active \
136
+ --role <higher-tier-role> --model "<resolved-model@provider>" \
137
+ --escalated-from <lower-tier-role> \
138
+ --summary "<gate that failed> failed at <lower-tier-role>; fix re-dispatched one tier higher"
139
+ ```
140
+
141
+ These land as top-level `role`, `model`, and (for escalations) `escalated_from`
142
+ fields on the JSONL event under `agents/<agent-id>/events.jsonl`. The shape is
143
+ additive: events without a routing decision are byte-identical to before, so no
144
+ existing consumer breaks. An economics consumer reads events where `role` is
145
+ present as one priced delegation each; `escalated_from` marks the entries that
146
+ cost more because a gate caught a cheaper tier.
147
+
70
148
  ## Completion Rules
71
149
 
72
150
  Execution is complete only when:
@@ -180,7 +180,9 @@ function checkProtectedPathPattern(filePath) {
180
180
  // copy a forged bundle here to corrupt the CI trust check.
181
181
  // SAFE: publishDelivery writes via fs.copyFileSync (not Write/Edit tool).
182
182
  // RESIDUAL: runtime-constructed paths and fs writes are unaffected.
183
- if (/(?:^|\/)delivery\/trust\.bundle$/.test(norm)) {
183
+ // #379: the optional (?:[^/]+\/)? segment also covers the per-session path
184
+ // delivery/<slug>/trust.bundle — the forgery surface moved with the write path.
185
+ if (/(?:^|\/)delivery\/(?:[^/]+\/)?trust\.bundle$/.test(norm)) {
184
186
  return {
185
187
  name: "delivery/trust.bundle",
186
188
  reason: "an agent could write a forged bundle to corrupt the CI trust-reconcile anchor",
@@ -189,7 +191,8 @@ function checkProtectedPathPattern(filePath) {
189
191
 
190
192
  // delivery/trust.checkpoint.json -- the signed checkpoint companion.
191
193
  // SAFE: publishDelivery writes via fs.copyFileSync, NOT via Write/Edit tool.
192
- if (/(?:^|\/)delivery\/trust\.checkpoint\.json$/.test(norm)) {
194
+ // #379: optional (?:[^/]+\/)? segment also covers delivery/<slug>/trust.checkpoint.json.
195
+ if (/(?:^|\/)delivery\/(?:[^/]+\/)?trust\.checkpoint\.json$/.test(norm)) {
193
196
  return {
194
197
  name: "delivery/trust.checkpoint.json",
195
198
  reason: "an agent could forge a signed delivery by writing a tampered checkpoint",
@@ -421,7 +424,9 @@ function checkCommandForBypass(command) {
421
424
  * .kontourai/flow-agents/<slug>/trust.bundle, and deprecated runtime-shaped
422
425
  * .flow-agents equivalents.
423
426
  */
424
- const REDIRECT_PROTECTED_RE = /(?:^|\/|~\/)(\.bash_profile|\.bashrc|\.profile|\.zprofile|\.zshrc)$|(?:^|\/)\.claude\/settings(?:\.local)?\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\/[^/]+\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/\.goal-fit-block-streak\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/state\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/trust\.bundle$|(?:^|\/)delivery\/trust\.bundle$|(?:^|\/)delivery\/trust\.checkpoint\.json$/;
427
+ // #379: the delivery/ arms carry an optional (?:[^/]+\/)? segment so redirects/tee to the
428
+ // per-session path delivery/<slug>/trust.bundle (+ checkpoint) are caught, not just the flat path.
429
+ const REDIRECT_PROTECTED_RE = /(?:^|\/|~\/)(\.bash_profile|\.bashrc|\.profile|\.zprofile|\.zshrc)$|(?:^|\/)\.claude\/settings(?:\.local)?\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\/[^/]+\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/\.goal-fit-block-streak\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/state\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/trust\.bundle$|(?:^|\/)delivery\/(?:[^/]+\/)?trust\.bundle$|(?:^|\/)delivery\/(?:[^/]+\/)?trust\.checkpoint\.json$/;
425
430
 
426
431
  /**
427
432
  * Return true when a token (an unquoted redirect target or tee argument) matches
@@ -569,8 +574,10 @@ function checkInterpreterWriteToProtected(command) {
569
574
  * Delivery-protected path regex: delivery/trust.bundle and delivery/trust.checkpoint.json.
570
575
  * These are the CI anchor files whose contents must not be agent-forged.
571
576
  * Used by checkCopyMoveToProtected to catch `cp x delivery/trust.bundle`.
577
+ * #379: the optional (?:[^/]+\/)? segment also catches the per-session path
578
+ * `cp forged.json delivery/<slug>/trust.bundle`.
572
579
  */
573
- const DELIVERY_COPY_PROTECTED_RE = /(?:^|\/)delivery\/trust\.bundle$|(?:^|\/)delivery\/trust\.checkpoint\.json$/;
580
+ const DELIVERY_COPY_PROTECTED_RE = /(?:^|\/)delivery\/(?:[^/]+\/)?trust\.bundle$|(?:^|\/)delivery\/(?:[^/]+\/)?trust\.checkpoint\.json$/;
574
581
 
575
582
  /**
576
583
  * Return true when a normalized token matches a delivery-protected path.