@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
|
@@ -522,32 +522,139 @@ const DELIVERY_DIR = 'delivery';
|
|
|
522
522
|
|
|
523
523
|
/**
|
|
524
524
|
* Single seam: every delivery/ path resolution in this file routes through here.
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
* delivery/<
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
525
|
+
*
|
|
526
|
+
* #335/#379 (per-session delivery paths) is IMPLEMENTED here now, not deferred: a shared
|
|
527
|
+
* `delivery/<filename>` guarantees a git merge conflict between ANY two concurrent
|
|
528
|
+
* deliveries — and a conflicting (DIRTY) PR gets NO `pull_request` workflows scheduled, so
|
|
529
|
+
* the required Trust Reconcile check silently never runs (field incidents #330/#358/#378).
|
|
530
|
+
* The fix is per-session paths so concurrent deliveries write to DISTINCT files and never
|
|
531
|
+
* contend.
|
|
532
|
+
*
|
|
533
|
+
* Returned candidate order (precedence, first is highest):
|
|
534
|
+
* 1. the flat `delivery/<filename>` — kept FIRST for full back-compat. A repo that never
|
|
535
|
+
* adopts per-session layout sees byte-identical behavior; an already-committed flat
|
|
536
|
+
* bundle from before this change still resolves.
|
|
537
|
+
* 2. `delivery/<slug>/<filename>` for every immediate subdirectory of `delivery/`, sorted
|
|
538
|
+
* by name for determinism.
|
|
539
|
+
*
|
|
540
|
+
* Ordering here is ONLY the tie-break / fallback. Which candidate actually reconciles is
|
|
541
|
+
* decided by COMMIT-OWNERSHIP (discoverBundle() + bundleAttestsThisChange()), not directory
|
|
542
|
+
* order: among the candidates, the one whose checkpoint attests an ancestor-or-equal commit
|
|
543
|
+
* of THIS change wins, and stale siblings from other concurrent sessions are ignored.
|
|
544
|
+
* discoverBundle() and discoverDeclaredMarker() both iterate whatever this returns, so the
|
|
545
|
+
* per-session layout composes with zero call-site changes.
|
|
532
546
|
*/
|
|
533
547
|
function resolveDeliveryCandidates(repoRoot, filename) {
|
|
534
|
-
|
|
548
|
+
const deliveryRoot = path.join(repoRoot, DELIVERY_DIR);
|
|
549
|
+
const candidates = [path.join(deliveryRoot, filename)];
|
|
550
|
+
// #379: append delivery/<slug>/<filename> for each immediate subdirectory of delivery/.
|
|
551
|
+
// readdirSync is best-effort — a missing delivery/ (or an unreadable one) yields just the
|
|
552
|
+
// flat candidate, never throws.
|
|
553
|
+
let entries = [];
|
|
554
|
+
try {
|
|
555
|
+
entries = fs.readdirSync(deliveryRoot, { withFileTypes: true });
|
|
556
|
+
} catch {
|
|
557
|
+
entries = [];
|
|
558
|
+
}
|
|
559
|
+
const subdirs = entries
|
|
560
|
+
.filter((e) => e.isDirectory())
|
|
561
|
+
.map((e) => e.name)
|
|
562
|
+
.sort();
|
|
563
|
+
for (const name of subdirs) {
|
|
564
|
+
candidates.push(path.join(deliveryRoot, name, filename));
|
|
565
|
+
}
|
|
566
|
+
return candidates;
|
|
535
567
|
}
|
|
536
568
|
|
|
537
569
|
/**
|
|
538
570
|
* Auto-discover the delivery bundle when no explicit path is provided.
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
571
|
+
*
|
|
572
|
+
* Gathers every existing candidate — flat `delivery/trust.bundle`, flat
|
|
573
|
+
* `delivery/trust.checkpoint.json`, and their `delivery/<slug>/…` per-session siblings (via
|
|
574
|
+
* resolveDeliveryCandidates()) — with trust.bundle preferred over trust.checkpoint.json.
|
|
575
|
+
*
|
|
576
|
+
* #379 (per-session, ownership-aware selection): when `changeSha` is known, the candidate
|
|
577
|
+
* whose checkpoint attests an ancestor-or-equal commit of THIS change wins (same
|
|
578
|
+
* bundleAttestsThisChange() semantics the flat staleness gate already uses). Stale/older
|
|
579
|
+
* siblings from OTHER concurrent sessions are ignored.
|
|
580
|
+
*
|
|
581
|
+
* PREFER-NEWEST among owning candidates. More than one candidate can legitimately "own" the
|
|
582
|
+
* change at once: an inherited FLAT bundle's commit_sha can be a real ancestor of HEAD in a
|
|
583
|
+
* merge-commit repo (it was committed on the trunk's linear history before this branch
|
|
584
|
+
* point), AND this session's fresh per-session bundle attests a NEWER ancestor (the delivery
|
|
585
|
+
* commit right before HEAD). "First-fresh-wins" would then select the STALE inherited flat
|
|
586
|
+
* bundle purely because it sorts first — reconciling last delivery's claims against this
|
|
587
|
+
* change's CI. So among all owning candidates we pick the one attesting the NEWEST commit
|
|
588
|
+
* (the descendant-most: the owning candidate whose commit_sha every other owning candidate's
|
|
589
|
+
* commit_sha is an ancestor of), via the same `git merge-base --is-ancestor` primitive. This
|
|
590
|
+
* makes an inherited/older owning bundle HARMLESS whether or not it was ever pruned — the
|
|
591
|
+
* fresh per-session bundle wins on recency, not on being deleted first.
|
|
592
|
+
*
|
|
593
|
+
* When no candidate owns this change (or `changeSha` is unknown, e.g. a local run with no
|
|
594
|
+
* TRUST_RECONCILE_SHA), the first existing candidate is returned so the caller's staleness
|
|
595
|
+
* gate below handles it exactly as before: a stale bundle is treated as absent, a malformed
|
|
596
|
+
* one still raises the Step 2 read error.
|
|
597
|
+
*
|
|
598
|
+
* Returns null if no candidate exists at all — the caller then falls through to
|
|
599
|
+
* delivery/DECLARED marker resolution (ADR 0022 §1); bundle absence is not fail-open.
|
|
543
600
|
*/
|
|
544
|
-
function discoverBundle(repoRoot) {
|
|
601
|
+
function discoverBundle(repoRoot, changeSha) {
|
|
602
|
+
const candidates = [];
|
|
545
603
|
for (const filename of ['trust.bundle', 'trust.checkpoint.json']) {
|
|
546
604
|
for (const candidate of resolveDeliveryCandidates(repoRoot, filename)) {
|
|
547
|
-
if (fs.existsSync(candidate))
|
|
605
|
+
if (fs.existsSync(candidate)) candidates.push(candidate);
|
|
548
606
|
}
|
|
549
607
|
}
|
|
550
|
-
return null;
|
|
608
|
+
if (candidates.length === 0) return null;
|
|
609
|
+
|
|
610
|
+
const deliveryRoot = path.join(repoRoot, DELIVERY_DIR);
|
|
611
|
+
const hasPerSession = candidates.some((c) => path.dirname(c) !== deliveryRoot);
|
|
612
|
+
|
|
613
|
+
// Ownership-aware selection (#379). Only meaningful when we know this change's sha; a
|
|
614
|
+
// candidate that fails to parse is skipped here (never treated as owning) — if it turns
|
|
615
|
+
// out to be the only candidate, it is still returned below so Step 2's "failed to read
|
|
616
|
+
// bundle" diagnostic fires unchanged.
|
|
617
|
+
if (changeSha) {
|
|
618
|
+
const owning = []; // { candidate, bundleSha } — every candidate that attests this change
|
|
619
|
+
for (const candidate of candidates) {
|
|
620
|
+
let json = null;
|
|
621
|
+
try {
|
|
622
|
+
json = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
623
|
+
} catch {
|
|
624
|
+
json = null;
|
|
625
|
+
}
|
|
626
|
+
if (!json) continue;
|
|
627
|
+
const { fresh, bundleSha } = bundleAttestsThisChange(repoRoot, candidate, json, changeSha);
|
|
628
|
+
if (fresh) owning.push({ candidate, bundleSha });
|
|
629
|
+
}
|
|
630
|
+
if (owning.length > 0) {
|
|
631
|
+
// Prefer the owning candidate attesting the NEWEST (descendant-most) commit. `best` is
|
|
632
|
+
// upgraded to a candidate whose commit is a strict descendant of the current best's —
|
|
633
|
+
// i.e. best.sha is an ancestor of cand.sha. Every owning entry has a non-null bundleSha
|
|
634
|
+
// (bundleAttestsThisChange returns fresh=false otherwise). Ties / incomparable siblings
|
|
635
|
+
// keep the earlier (candidate-order) entry, so the flat path still wins a genuine tie.
|
|
636
|
+
let best = owning[0];
|
|
637
|
+
for (let i = 1; i < owning.length; i++) {
|
|
638
|
+
const cand = owning[i];
|
|
639
|
+
if (cand.bundleSha !== best.bundleSha && isAncestorCommit(repoRoot, best.bundleSha, cand.bundleSha)) {
|
|
640
|
+
best = cand;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (hasPerSession) {
|
|
644
|
+
process.stdout.write(`[trust-reconcile] #379: selected delivery candidate ${path.relative(repoRoot, best.candidate)} — attests this change ${changeSha} (${candidates.length} candidate(s) present across flat + per-session delivery/<slug>/; ${owning.length} owning, newest wins; older/stale ignored)\n`);
|
|
645
|
+
}
|
|
646
|
+
return best.candidate;
|
|
647
|
+
}
|
|
648
|
+
// No candidate attests this change. When per-session candidates were in play, emit a
|
|
649
|
+
// loud, grep-stable concurrency hint (#379) so the next agent can tell a concurrent
|
|
650
|
+
// per-session collision apart from a plain stale/absent bundle — the per-candidate
|
|
651
|
+
// stale line still prints in runTrustReconcile's staleness gate for the returned one.
|
|
652
|
+
if (hasPerSession) {
|
|
653
|
+
const rels = candidates.map((c) => path.relative(repoRoot, c)).join(', ');
|
|
654
|
+
process.stdout.write(`[trust-reconcile] #379: examined ${candidates.length} delivery candidate(s) (flat + per-session delivery/<slug>/); none attests this change ${changeSha}. If this is a concurrent-delivery collision, the OWNING session's bundle is not on this ref — re-publish this session's delivery, or check whether main moved under the PR. Candidates: ${rels}\n`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return candidates[0];
|
|
551
658
|
}
|
|
552
659
|
|
|
553
660
|
/**
|
|
@@ -813,22 +920,30 @@ function resolveDeclaredExemption(repoRoot, ctx) {
|
|
|
813
920
|
* src/cli/workflow-sidecar.ts). trust.bundle itself carries NO commit/branch metadata
|
|
814
921
|
* (schemaVersion 5: {schemaVersion, source, claims, evidence, policies, events} — confirmed
|
|
815
922
|
* by inspection), so when the discovered bundle IS trust.bundle, this falls through to its
|
|
816
|
-
*
|
|
817
|
-
*
|
|
923
|
+
* SIBLING trust.checkpoint.json for the binding — publishDelivery() always writes both
|
|
924
|
+
* together into the SAME directory.
|
|
925
|
+
*
|
|
926
|
+
* #379: the sibling is resolved from the bundle's OWN directory (path.dirname(bundlePath)),
|
|
927
|
+
* not from a global resolveDeliveryCandidates() scan. In the per-session layout each session
|
|
928
|
+
* gets its own delivery/<slug>/ dir, and a global scan would cross-contaminate — pairing a
|
|
929
|
+
* per-session trust.bundle with the FLAT (or a different session's) trust.checkpoint.json and
|
|
930
|
+
* reading the wrong commit binding. Same-directory resolution is identical to the prior
|
|
931
|
+
* behavior for the flat layout (delivery/trust.bundle ↔ delivery/trust.checkpoint.json) and
|
|
932
|
+
* correct for per-session.
|
|
818
933
|
*/
|
|
819
934
|
function extractBundleCommitSha(repoRoot, bundlePath, bundleJson) {
|
|
820
935
|
if (bundleJson && typeof bundleJson.commit_sha === 'string' && bundleJson.commit_sha.trim()) {
|
|
821
936
|
return bundleJson.commit_sha.trim();
|
|
822
937
|
}
|
|
823
|
-
|
|
824
|
-
|
|
938
|
+
const siblingCheckpoint = path.join(path.dirname(bundlePath), 'trust.checkpoint.json');
|
|
939
|
+
if (siblingCheckpoint !== bundlePath && fs.existsSync(siblingCheckpoint)) {
|
|
825
940
|
try {
|
|
826
|
-
const checkpoint = JSON.parse(fs.readFileSync(
|
|
941
|
+
const checkpoint = JSON.parse(fs.readFileSync(siblingCheckpoint, 'utf8'));
|
|
827
942
|
if (checkpoint && typeof checkpoint.commit_sha === 'string' && checkpoint.commit_sha.trim()) {
|
|
828
943
|
return checkpoint.commit_sha.trim();
|
|
829
944
|
}
|
|
830
945
|
} catch {
|
|
831
|
-
// Malformed sibling checkpoint — no usable binding from it;
|
|
946
|
+
// Malformed sibling checkpoint — no usable binding from it; fall through to null.
|
|
832
947
|
}
|
|
833
948
|
}
|
|
834
949
|
return null;
|
|
@@ -907,14 +1022,17 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null, mani
|
|
|
907
1022
|
// (.github/workflows/trust-reconcile.yml) never passes --bundle; it always relies on
|
|
908
1023
|
// auto-discovery, which is exactly the path PR #278's stale-bundle incident went through.
|
|
909
1024
|
const explicitBundlePath = bundle || process.env.TRUST_RECONCILE_BUNDLE || null;
|
|
910
|
-
let bundlePath = explicitBundlePath || discoverBundle(resolvedRepoRoot) || null;
|
|
911
|
-
const bundleWasAutoDiscovered = !explicitBundlePath && !!bundlePath;
|
|
912
1025
|
|
|
913
|
-
// Scope-matching context (ref/actor/sha) — resolved once, reused by
|
|
914
|
-
//
|
|
915
|
-
//
|
|
1026
|
+
// Scope-matching context (ref/actor/sha) — resolved once, reused by ownership-aware
|
|
1027
|
+
// auto-discovery (#379, immediately below), the bundle-ownership staleness check further
|
|
1028
|
+
// down (ADR 0022 addendum §2), and the delivery/DECLARED exemption path (bundle-absent
|
|
1029
|
+
// branch). Resolved BEFORE auto-discovery because discoverBundle() now uses scopeCtx.sha
|
|
1030
|
+
// to pick, among per-session candidates, the one that attests THIS change.
|
|
916
1031
|
const scopeCtx = resolveScopeContext(resolvedRepoRoot);
|
|
917
1032
|
|
|
1033
|
+
let bundlePath = explicitBundlePath || discoverBundle(resolvedRepoRoot, scopeCtx.sha) || null;
|
|
1034
|
+
const bundleWasAutoDiscovered = !explicitBundlePath && !!bundlePath;
|
|
1035
|
+
|
|
918
1036
|
// Event-scoped enforcement (ADR 0022 addendum, part 4): bundle-required enforcement
|
|
919
1037
|
// (and the staleness-gate consequence of it, immediately below) applies ONLY when this
|
|
920
1038
|
// run is gating a proposed change. A push run on main happens AFTER the gating PR run
|
|
@@ -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.
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
'use strict';
|
|
26
26
|
|
|
27
27
|
const fs = require('fs');
|
|
28
|
+
const os = require('os');
|
|
28
29
|
const path = require('path');
|
|
29
30
|
const { spawnSync } = require('child_process');
|
|
30
31
|
const crypto = require('crypto');
|
|
@@ -42,7 +43,7 @@ const {
|
|
|
42
43
|
flowAgentsArtifactRoot,
|
|
43
44
|
flowAgentsArtifactRootsForRead,
|
|
44
45
|
} = require('./lib/local-artifact-paths');
|
|
45
|
-
const { resolveActor } = require('./lib/actor-identity.js');
|
|
46
|
+
const { resolveActor, isUnresolvedActor, detectRuntime } = require('./lib/actor-identity.js');
|
|
46
47
|
const { readCurrentPointer } = require('./lib/current-pointer.js');
|
|
47
48
|
|
|
48
49
|
const MAX_STDIN = 1024 * 1024;
|
|
@@ -1671,7 +1672,7 @@ async function analyze(root, now = Date.now()) {
|
|
|
1671
1672
|
artifacts = artifacts.filter(a => a && hasSidecarPresence(path.dirname(a.file)));
|
|
1672
1673
|
}
|
|
1673
1674
|
|
|
1674
|
-
if (artifacts.length === 0) return { warnings: [], blocking: false };
|
|
1675
|
+
if (artifacts.length === 0) return { warnings: [], blocking: false, latestArtifactDir: null };
|
|
1675
1676
|
|
|
1676
1677
|
const latest = artifacts[0];
|
|
1677
1678
|
const latestArtifactDir = path.dirname(latest.file);
|
|
@@ -1745,7 +1746,7 @@ async function analyze(root, now = Date.now()) {
|
|
|
1745
1746
|
if (/\[backstop in warn mode — not blocking\]/.test(w)) return false;
|
|
1746
1747
|
return blockRe.test(w);
|
|
1747
1748
|
});
|
|
1748
|
-
return { warnings, blocking, preExecution, gatePrefix: gateLabel(activeFlowStep) };
|
|
1749
|
+
return { warnings, blocking, preExecution, gatePrefix: gateLabel(activeFlowStep), latestArtifactDir };
|
|
1749
1750
|
}
|
|
1750
1751
|
|
|
1751
1752
|
/**
|
|
@@ -1844,12 +1845,266 @@ function remediationFor(warning) {
|
|
|
1844
1845
|
return null;
|
|
1845
1846
|
}
|
|
1846
1847
|
|
|
1848
|
+
// ─── #292 Wave 2: Stop-hook non-terminal release-with-handoff ────────────────
|
|
1849
|
+
//
|
|
1850
|
+
// When a session's Stop event fires and the session's state.json status is NOT
|
|
1851
|
+
// one of workflow-sidecar.ts's LIVENESS_TERMINAL statuses (delivered/accepted/
|
|
1852
|
+
// archived — the SAME set advance-state's terminal path already tests against,
|
|
1853
|
+
// imported here rather than re-declared, per AC8), the liveness claim and (for
|
|
1854
|
+
// the local-file assignment provider) the durable assignment claim would
|
|
1855
|
+
// otherwise sit held until TTL, even though the session has genuinely ended.
|
|
1856
|
+
// This closes that gap: always emit a provider-agnostic liveness release (AC1),
|
|
1857
|
+
// and for local-file additionally perform the real release inline (AC2), while
|
|
1858
|
+
// honestly disclosing (never executing) the equivalent GitHub-provider release
|
|
1859
|
+
// as a pending handoff.json intent (AC3). See the plan artifact
|
|
1860
|
+
// (.kontourai/flow-agents/kontourai-flow-agents-292/kontourai-flow-agents-292--plan-work.md,
|
|
1861
|
+
// "Wave 2") for the full design rationale and the render-don't-execute
|
|
1862
|
+
// constraint this deliberately does not cross.
|
|
1863
|
+
//
|
|
1864
|
+
// Fail-open (AC7): the ENTIRE body runs under one try/catch. Any failure —
|
|
1865
|
+
// missing build/, corrupt assignment record, unresolved actor, missing/
|
|
1866
|
+
// malformed provider settings — degrades to a single stderr diagnostic and
|
|
1867
|
+
// returns, never throwing, never affecting the Stop hook's own exit code or
|
|
1868
|
+
// goal-fit's warnings/blocking output (this function's return value is not
|
|
1869
|
+
// consumed by that contract at all — it is a pure side effect).
|
|
1870
|
+
|
|
1871
|
+
/**
|
|
1872
|
+
* require() the compiled workflow-sidecar.js the same hasBuild/fs.existsSync-guarded
|
|
1873
|
+
* way loadActiveFlowStep() already requires flow-resolver.js. Returns null (never
|
|
1874
|
+
* throws) when build/ is absent or the require fails — the caller treats null as
|
|
1875
|
+
* "the LIVENESS_TERMINAL boundary is unknown; do not guess it" and skips the whole
|
|
1876
|
+
* release (fail-open, never a re-derived duplicate Set — AC8).
|
|
1877
|
+
*/
|
|
1878
|
+
function loadWorkflowSidecarBuilt() {
|
|
1879
|
+
const packageRoot = path.resolve(__dirname, '..', '..');
|
|
1880
|
+
const builtSidecar = path.join(packageRoot, 'build', 'src', 'cli', 'workflow-sidecar.js');
|
|
1881
|
+
if (!fs.existsSync(builtSidecar)) return null; // hasBuild guard
|
|
1882
|
+
try {
|
|
1883
|
+
const mod = require(builtSidecar);
|
|
1884
|
+
if (!(mod.LIVENESS_TERMINAL instanceof Set)) return null;
|
|
1885
|
+
return mod;
|
|
1886
|
+
} catch {
|
|
1887
|
+
return null; // require failed — fail-open
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
/** Same hasBuild-guarded require idiom, for build/src/cli/assignment-provider.js. */
|
|
1892
|
+
function loadAssignmentProviderBuilt() {
|
|
1893
|
+
const packageRoot = path.resolve(__dirname, '..', '..');
|
|
1894
|
+
const builtProvider = path.join(packageRoot, 'build', 'src', 'cli', 'assignment-provider.js');
|
|
1895
|
+
if (!fs.existsSync(builtProvider)) return null;
|
|
1896
|
+
try {
|
|
1897
|
+
const mod = require(builtProvider);
|
|
1898
|
+
if (typeof mod.performLocalRelease !== 'function') return null;
|
|
1899
|
+
return mod;
|
|
1900
|
+
} catch {
|
|
1901
|
+
return null;
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
/**
|
|
1906
|
+
* Resolve the effective assignment-provider kind for this repo ('local-file' | 'github'),
|
|
1907
|
+
* or null when it cannot be determined. Prefers reading context/settings/
|
|
1908
|
+
* assignment-provider-settings.json directly (project settings first, matching
|
|
1909
|
+
* effective-assignment-provider-settings.ts's own lookup precedence) rather than shelling
|
|
1910
|
+
* out to or refactoring that CLI-shaped file (see plan Unresolved Question 1) — this task's
|
|
1911
|
+
* scope is the Stop hook, not that file. Returns null (never guesses) when no settings file
|
|
1912
|
+
* is present/parseable or names no provider kind for a project entry.
|
|
1913
|
+
*
|
|
1914
|
+
* Deliberately reads ONLY the project settings file (context/settings/
|
|
1915
|
+
* assignment-provider-settings.json relative to repo root) — the common case for this repo
|
|
1916
|
+
* and the one confirmed present. The effective() merge/repo-matching logic in
|
|
1917
|
+
* effective-assignment-provider-settings.ts is intentionally NOT reimplemented here (that
|
|
1918
|
+
* would fork a second merge algorithm); a repo whose provider kind can only be resolved via
|
|
1919
|
+
* global settings or multi-project merge degrades to "unknown, skip assignment-layer
|
|
1920
|
+
* release" rather than guessing.
|
|
1921
|
+
*/
|
|
1922
|
+
function resolveAssignmentProviderKind(root) {
|
|
1923
|
+
const settingsFile = path.join(root, 'context', 'settings', 'assignment-provider-settings.json');
|
|
1924
|
+
const settings = readJsonFile(settingsFile);
|
|
1925
|
+
if (!settings) return null;
|
|
1926
|
+
const candidates = [];
|
|
1927
|
+
if (settings.defaults && settings.defaults.provider) candidates.push(settings.defaults.provider);
|
|
1928
|
+
if (Array.isArray(settings.projects)) {
|
|
1929
|
+
for (const project of settings.projects) {
|
|
1930
|
+
if (project && project.provider) candidates.push(project.provider);
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
const kind = candidates.map(p => p && p.kind).find(k => k === 'local-file' || k === 'github');
|
|
1934
|
+
return kind || null;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* Refresh handoff.json by merge (read+spread the existing file's JSON, overwrite only the
|
|
1939
|
+
* fields this task owns), mirroring advanceState's own handoff-write shape
|
|
1940
|
+
* (`{ ...loadJson(...), ...sidecarBase(slug), summary, current_state_ref: "state.json",
|
|
1941
|
+
* next_steps, blockers: [], warnings: [] }`) and current-pointer.js's writePerActorCurrent
|
|
1942
|
+
* write idiom (`fs.writeFileSync(file, JSON.stringify(payload, null, 2) + "\n")`). next_steps
|
|
1943
|
+
* is appended to (not clobbered) when a provider-release next-step is due.
|
|
1944
|
+
*/
|
|
1945
|
+
function refreshHandoffAfterRelease(artifactDir, slug, state, providerReleasePending, providerReleaseNextCommand) {
|
|
1946
|
+
const file = path.join(artifactDir, 'handoff.json');
|
|
1947
|
+
const existing = readJsonFile(file) || {};
|
|
1948
|
+
const status = state ? normalizedStatus(state.status || 'unknown') : 'unknown';
|
|
1949
|
+
const phase = state ? normalizedStatus(state.phase || 'unknown') : 'unknown';
|
|
1950
|
+
const summary = (state && state.next_action && state.next_action.summary)
|
|
1951
|
+
? String(state.next_action.summary)
|
|
1952
|
+
: `session ended at status:${status} phase:${phase}`;
|
|
1953
|
+
const existingNextSteps = Array.isArray(existing.next_steps) ? existing.next_steps.slice() : [];
|
|
1954
|
+
const nextSteps = existingNextSteps.slice();
|
|
1955
|
+
if (providerReleasePending && providerReleaseNextCommand && !nextSteps.includes(providerReleaseNextCommand)) {
|
|
1956
|
+
nextSteps.push(providerReleaseNextCommand);
|
|
1957
|
+
}
|
|
1958
|
+
const payload = {
|
|
1959
|
+
...existing,
|
|
1960
|
+
schema_version: existing.schema_version || '1.0',
|
|
1961
|
+
task_slug: existing.task_slug || slug,
|
|
1962
|
+
summary,
|
|
1963
|
+
current_state_ref: 'state.json',
|
|
1964
|
+
next_steps: nextSteps,
|
|
1965
|
+
...(providerReleasePending ? { provider_release_pending: true } : {}),
|
|
1966
|
+
...(providerReleaseNextCommand ? { provider_release_next_command: providerReleaseNextCommand } : {}),
|
|
1967
|
+
};
|
|
1968
|
+
fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`);
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/**
|
|
1972
|
+
* #292 Wave 2: on a Stop event whose resolved session is at a NON-terminal status, release
|
|
1973
|
+
* the session's liveness claim (always, provider-agnostic — AC1) and, for the local-file
|
|
1974
|
+
* assignment provider, its durable assignment claim too (AC2), then refresh handoff.json
|
|
1975
|
+
* (AC4). A terminal-status session (already released by advance-state) is a deliberate no-op
|
|
1976
|
+
* (AC5). For the github provider, never calls render-release or gh — only records an
|
|
1977
|
+
* honestly-labeled pending intent in handoff.json (AC3). Actor-scoped: only ever releases
|
|
1978
|
+
* the CURRENT actor's own held claim (AC6, enforced inside performLocalRelease).
|
|
1979
|
+
*
|
|
1980
|
+
* Called once from run(), reusing analyze()'s already-resolved latestArtifactDir — this
|
|
1981
|
+
* function does not re-derive "what is the active session" a second time.
|
|
1982
|
+
*
|
|
1983
|
+
* Fail-open (AC7): the entire body is wrapped in one try/catch. Any failure degrades to a
|
|
1984
|
+
* single stderr diagnostic and returns, never throwing, never affecting the Stop hook's exit
|
|
1985
|
+
* code or goal-fit's warnings/blocking contract (this function's return value is unused by
|
|
1986
|
+
* that contract).
|
|
1987
|
+
*
|
|
1988
|
+
* @param {string} root - repo root (as resolved by findRepoRoot)
|
|
1989
|
+
* @param {string|null} artifactDir - analyze()'s already-resolved latestArtifactDir
|
|
1990
|
+
*/
|
|
1991
|
+
function releaseOnNonTerminalStop(root, artifactDir) {
|
|
1992
|
+
try {
|
|
1993
|
+
if (!artifactDir) return; // no active session resolved — nothing to release.
|
|
1994
|
+
|
|
1995
|
+
const state = readJsonFile(path.join(artifactDir, 'state.json'));
|
|
1996
|
+
if (!state) return; // AC5: no state.json — nothing to gate a release decision on.
|
|
1997
|
+
|
|
1998
|
+
const sidecar = loadWorkflowSidecarBuilt();
|
|
1999
|
+
if (!sidecar) {
|
|
2000
|
+
process.stderr.write('[Hook] Goal Fit: stop-hook release skipped — build/src/cli/workflow-sidecar.js not available (LIVENESS_TERMINAL boundary unknown).\n');
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
const status = normalizedStatus(state.status || 'unknown');
|
|
2005
|
+
if (sidecar.LIVENESS_TERMINAL.has(status)) return; // AC5: terminal — advance-state already released.
|
|
2006
|
+
|
|
2007
|
+
// Slug: artifact dir's basename, or state.json's own task_slug field — reuse whichever
|
|
2008
|
+
// this file already has, never a new slug-derivation rule.
|
|
2009
|
+
const slug = (state.task_slug && String(state.task_slug).trim()) || path.basename(artifactDir);
|
|
2010
|
+
|
|
2011
|
+
const { actor } = resolveActor(process.env);
|
|
2012
|
+
if (isUnresolvedActor(actor)) {
|
|
2013
|
+
process.stderr.write(`[Hook] Goal Fit: stop-hook release skipped for "${safeOneLine(slug, 80)}" — actor identity is unresolved; never releasing under a synthetic identity.\n`);
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
const flowAgentsDir = path.dirname(artifactDir);
|
|
2018
|
+
const nowIso = new Date().toISOString();
|
|
2019
|
+
|
|
2020
|
+
// AC1: ALWAYS emit the liveness release, regardless of provider kind — liveness is
|
|
2021
|
+
// provider-agnostic and this is the ONE shared writer every other liveness emitter uses.
|
|
2022
|
+
try {
|
|
2023
|
+
require('./lib/liveness-write.js').appendLivenessEvent(flowAgentsDir, {
|
|
2024
|
+
type: 'release',
|
|
2025
|
+
subjectId: slug,
|
|
2026
|
+
actor,
|
|
2027
|
+
at: nowIso,
|
|
2028
|
+
source: 'stop-hook',
|
|
2029
|
+
});
|
|
2030
|
+
} catch (err) {
|
|
2031
|
+
process.stderr.write(`[Hook] Goal Fit: stop-hook liveness release failed for "${safeOneLine(slug, 80)}": ${safeOneLine(err && err.message || err, 160)}\n`);
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// Resolve the effective assignment-provider kind — unknown means "do not guess",
|
|
2035
|
+
// skip the assignment-layer release entirely (fail-open).
|
|
2036
|
+
const providerKind = resolveAssignmentProviderKind(root);
|
|
2037
|
+
if (!providerKind) {
|
|
2038
|
+
process.stderr.write(`[Hook] Goal Fit: stop-hook release — provider kind unknown for "${safeOneLine(slug, 80)}", skipping assignment-layer release (liveness release already emitted).\n`);
|
|
2039
|
+
refreshHandoffAfterRelease(artifactDir, slug, state, false, null);
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
let providerReleasePending = false;
|
|
2044
|
+
let providerReleaseNextCommand = null;
|
|
2045
|
+
|
|
2046
|
+
if (providerKind === 'local-file') {
|
|
2047
|
+
const provider = loadAssignmentProviderBuilt();
|
|
2048
|
+
if (!provider) {
|
|
2049
|
+
process.stderr.write('[Hook] Goal Fit: stop-hook local-file release skipped — build/src/cli/assignment-provider.js not available.\n');
|
|
2050
|
+
} else {
|
|
2051
|
+
try {
|
|
2052
|
+
// Mirror assignment-provider.ts's loadActorStruct() exactly: runtime is derived via
|
|
2053
|
+
// detectRuntime (not a literal 'unknown'), since serializeActor()'s comparison key
|
|
2054
|
+
// includes runtime — a mismatched runtime segment here would make performLocalRelease's
|
|
2055
|
+
// AC6 actor-match check wrongly treat this session's own claim as foreign.
|
|
2056
|
+
//
|
|
2057
|
+
// actorKey: opts.actorKey MUST be the same canonical `resolveActor(env).actor` string
|
|
2058
|
+
// already resolved above (`actor`) — the identical bare-or-triple form
|
|
2059
|
+
// computeEffectiveState()'s holderActorKey preference (`record.actor_key ||
|
|
2060
|
+
// serializeActor(record.actor)`) and performLocalRelease's mirrored ownership check
|
|
2061
|
+
// both key on. Without this, an explicit-override actor's release-side comparison
|
|
2062
|
+
// would fall back to serializeActor(releasedBy) — a re-derived triple that never
|
|
2063
|
+
// equals the stored bare actor_key — and this hook would wrongly treat its own claim
|
|
2064
|
+
// as foreign (the #291 seam, relocated to the release path; see performLocalRelease's
|
|
2065
|
+
// doc comment in src/cli/assignment-provider.ts).
|
|
2066
|
+
const releasedBy = { runtime: detectRuntime(process.env), session_id: actor, host: os.hostname(), human: null };
|
|
2067
|
+
const artifactRoot = flowAgentsArtifactRoot(root);
|
|
2068
|
+
provider.performLocalRelease(artifactRoot, slug, releasedBy, {
|
|
2069
|
+
reason: 'stop-hook non-terminal release',
|
|
2070
|
+
tolerateNoActiveClaim: true,
|
|
2071
|
+
actorKey: actor,
|
|
2072
|
+
});
|
|
2073
|
+
} catch (err) {
|
|
2074
|
+
process.stderr.write(`[Hook] Goal Fit: stop-hook local-file release failed for "${safeOneLine(slug, 80)}": ${safeOneLine(err && err.message || err, 160)}\n`);
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
} else {
|
|
2078
|
+
// AC3: github (or any non-local-file kind) — render-don't-execute. Never call
|
|
2079
|
+
// render-release, never invoke gh. Disclose the pending intent honestly.
|
|
2080
|
+
providerReleasePending = true;
|
|
2081
|
+
providerReleaseNextCommand = `npm run workflow -- assignment-provider status --provider github --subject-id "${slug}" ... # then: assignment-provider render-release --provider github --subject-id "${slug}" ... (emits the gh argv to run)`;
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
// AC4: refresh handoff.json — merge, never fully overwrite.
|
|
2085
|
+
refreshHandoffAfterRelease(artifactDir, slug, state, providerReleasePending, providerReleaseNextCommand);
|
|
2086
|
+
} catch (err) {
|
|
2087
|
+
// Fail-open (AC7): any unexpected failure anywhere above (JSON parse error, fs error,
|
|
2088
|
+
// etc.) must never crash the Stop hook or affect its exit code / goal-fit's output.
|
|
2089
|
+
try {
|
|
2090
|
+
process.stderr.write(`[Hook] Goal Fit: stop-hook release logic error (fail-open): ${safeOneLine(err && err.message || err, 160)}\n`);
|
|
2091
|
+
} catch { /* best-effort diagnostic only */ }
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
|
|
1847
2095
|
async function run(rawInput) {
|
|
1848
2096
|
const input = parseJson(rawInput);
|
|
1849
2097
|
const root = findRepoRoot(input.cwd || process.cwd());
|
|
1850
2098
|
const mode = resolveGoalFitMode();
|
|
1851
2099
|
if (mode === 'off') return rawInput;
|
|
1852
2100
|
const result = await analyze(root);
|
|
2101
|
+
// #292 Wave 2: additive side effect only — never changes analyze()'s warnings/blocking
|
|
2102
|
+
// contract or this function's return value. Reuses analyze()'s already-resolved
|
|
2103
|
+
// latestArtifactDir rather than re-deriving a second "what is the active session" path.
|
|
2104
|
+
// Runs regardless of whether goal-fit itself has any warnings this turn (AC1: the
|
|
2105
|
+
// liveness release must always be attempted on a non-terminal Stop, independent of
|
|
2106
|
+
// whether goal-fit found anything to warn about).
|
|
2107
|
+
releaseOnNonTerminalStop(root, result.latestArtifactDir || null);
|
|
1853
2108
|
if (result.warnings.length === 0) {
|
|
1854
2109
|
clearBlockStreak(root);
|
|
1855
2110
|
return rawInput;
|
|
@@ -1939,4 +2194,4 @@ if (require.main === module) {
|
|
|
1939
2194
|
});
|
|
1940
2195
|
}
|
|
1941
2196
|
|
|
1942
|
-
module.exports = { analyze, run, resolveGoalFitMode, uncheckedInSection, findRepoRoot, sidecarGuidance, safeOneLine, captureCrossReference, bundleEnforcement, loadActiveFlowStep, readCommandLog, resolveTrustedCommand, declaredManifestTarget, verifyCommandLogChain, CHAIN_GENESIS_VERIFY, hasLaunderingOperator };
|
|
2197
|
+
module.exports = { analyze, run, resolveGoalFitMode, uncheckedInSection, findRepoRoot, sidecarGuidance, safeOneLine, captureCrossReference, bundleEnforcement, loadActiveFlowStep, readCommandLog, resolveTrustedCommand, declaredManifestTarget, verifyCommandLogChain, CHAIN_GENESIS_VERIFY, hasLaunderingOperator, releaseOnNonTerminalStop };
|