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