@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.
- package/.github/workflows/ci.yml +4 -0
- package/CHANGELOG.md +17 -0
- package/build/src/cli/assignment-provider.d.ts +45 -0
- package/build/src/cli/assignment-provider.js +97 -12
- package/build/src/cli/workflow-sidecar.d.ts +14 -4
- package/build/src/cli/workflow-sidecar.js +100 -10
- package/context/contracts/assignment-provider-contract.md +1 -1
- package/context/contracts/execution-contract.md +78 -0
- package/context/scripts/hooks/config-protection.js +11 -4
- package/context/scripts/hooks/stop-goal-fit.js +259 -4
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
- package/evals/ci/run-baseline.sh +2 -0
- package/evals/integration/test_checkpoint_signing.sh +4 -3
- package/evals/integration/test_gate_lockdown.sh +36 -0
- package/evals/integration/test_model_routing_escalation.sh +145 -0
- package/evals/integration/test_publish_delivery.sh +14 -6
- package/evals/integration/test_stop_hook_release.sh +552 -0
- package/evals/integration/test_trust_reconcile_negatives.sh +170 -0
- package/evals/run.sh +6 -0
- package/evals/static/test_model_routing_hints.sh +107 -0
- package/kits/builder/skills/builder-shape/SKILL.md +10 -0
- package/kits/builder/skills/deliver/SKILL.md +52 -11
- package/kits/builder/skills/design-probe/SKILL.md +10 -0
- package/kits/builder/skills/execute-plan/SKILL.md +13 -0
- package/kits/builder/skills/fix-bug/SKILL.md +17 -0
- package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
- package/kits/builder/skills/plan-work/SKILL.md +9 -0
- package/kits/builder/skills/pull-work/SKILL.md +10 -0
- package/kits/builder/skills/review-work/SKILL.md +11 -0
- package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
- package/kits/builder/skills/verify-work/SKILL.md +11 -0
- package/kits/knowledge/adapters/default-store/index.js +56 -15
- package/kits/knowledge/adapters/flow-runner/index.js +912 -16
- package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
- package/kits/knowledge/adapters/shared/codec.js +124 -0
- package/kits/knowledge/docs/store-contract.md +405 -3
- package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
- package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
- package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
- package/kits/knowledge/evals/contract-suite/suite.test.js +36 -0
- package/kits/knowledge/evals/freshness/suite.test.js +339 -0
- package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
- package/kits/knowledge/evals/retirement/suite.test.js +1 -1
- package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
- package/package.json +1 -1
- package/schemas/workflow-handoff.schema.json +6 -0
- package/scripts/ci/mint-attestation.js +33 -6
- package/scripts/ci/trust-reconcile.js +144 -26
- package/scripts/hooks/config-protection.js +11 -4
- package/scripts/hooks/stop-goal-fit.js +259 -4
- package/src/cli/assignment-provider.ts +110 -12
- package/src/cli/workflow-sidecar.ts +99 -10
package/.github/workflows/ci.yml
CHANGED
|
@@ -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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
|
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/
|
|
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
|
-
|
|
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
|
|
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/
|
|
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/
|
|
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(
|
|
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(
|
|
2713
|
+
fs.copyFileSync(src, path.join(sessionDeliveryDir, filename));
|
|
2624
2714
|
}
|
|
2625
2715
|
}
|
|
2626
|
-
process.stderr.write(`[publish-delivery] published trust.bundle and companions to ${
|
|
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 |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|