@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
|
@@ -24,7 +24,7 @@ import { readJson, writeJson, isoNow } from "../lib/fs.js";
|
|
|
24
24
|
|
|
25
25
|
type AnyObj = Record<string, unknown>;
|
|
26
26
|
|
|
27
|
-
type ActorStruct = {
|
|
27
|
+
export type ActorStruct = {
|
|
28
28
|
runtime: string;
|
|
29
29
|
session_id: string;
|
|
30
30
|
host: string;
|
|
@@ -45,12 +45,21 @@ type AssignmentAuditEntry = {
|
|
|
45
45
|
* The versioned claim-record shape from the contract doc's "Versioned claim-record format"
|
|
46
46
|
* section (Design Decision 2). `schema_version` is bumped only on an incompatible change, per
|
|
47
47
|
* artifact-contract.md's existing sidecar rule.
|
|
48
|
+
*
|
|
49
|
+
* `actor_key` (F1 fix, fix-plan iteration 1, HIGH — additive field, schema_version unchanged):
|
|
50
|
+
* the canonical `resolveActor(env).actor` string for the claiming actor — the SAME flat/bare
|
|
51
|
+
* token every other tool (`liveness whoami`, `liveness claim --actor`, per-actor current.json,
|
|
52
|
+
* pull-work's `--self-actor`) already uses. Optional so every pre-fix record and every #290 eval
|
|
53
|
+
* fixture with no `actor_key` still parses; `computeEffectiveState` falls back to
|
|
54
|
+
* `serializeActor(record.actor)` (today's behavior) whenever it's absent. Present, it is the ONLY
|
|
55
|
+
* correct self-recognition/liveness-join key — see computeEffectiveState's holderActorKey.
|
|
48
56
|
*/
|
|
49
|
-
type AssignmentClaimRecord = {
|
|
57
|
+
export type AssignmentClaimRecord = {
|
|
50
58
|
schema_version: "1.0";
|
|
51
59
|
role: "AssignmentClaimRecord";
|
|
52
60
|
subject_id: string;
|
|
53
61
|
actor: ActorStruct;
|
|
62
|
+
actor_key?: string;
|
|
54
63
|
claimed_at: string;
|
|
55
64
|
ttl_seconds: number;
|
|
56
65
|
branch: string;
|
|
@@ -59,12 +68,12 @@ type AssignmentClaimRecord = {
|
|
|
59
68
|
audit_trail?: AssignmentAuditEntry[];
|
|
60
69
|
};
|
|
61
70
|
|
|
62
|
-
type FreshHolder = { actor: string; lastAt: string; ttlSeconds: number; fresh: boolean };
|
|
71
|
+
export type FreshHolder = { actor: string; lastAt: string; ttlSeconds: number; fresh: boolean };
|
|
63
72
|
|
|
64
|
-
type EffectiveState = "held" | "reclaimable" | "human-held" | "free";
|
|
73
|
+
export type EffectiveState = "held" | "reclaimable" | "human-held" | "free";
|
|
65
74
|
|
|
66
75
|
/** Provider-neutral assignment-layer read, before any liveness join (contract doc's status()). */
|
|
67
|
-
type AssignmentStatus = {
|
|
76
|
+
export type AssignmentStatus = {
|
|
68
77
|
subject_id: string;
|
|
69
78
|
provider: "local-file" | "github";
|
|
70
79
|
assignee: string | null;
|
|
@@ -176,22 +185,29 @@ function loadActorStructFromFile(file: string): ActorStruct {
|
|
|
176
185
|
* fixture-friendly path (used by evals and any caller that already knows its own struct);
|
|
177
186
|
* when omitted, auto-derive from the live environment via the shared resolver, mirroring
|
|
178
187
|
* (never forking) the exact struct fields serializeActor() already defines.
|
|
188
|
+
*
|
|
189
|
+
* F1 fix (fix-plan iteration 1, HIGH): also returns `actorKey` — set to the canonical
|
|
190
|
+
* `resolveActor(env).actor` string ONLY on the auto-derive path (pull-work's real path, and any
|
|
191
|
+
* other caller with no --actor-json), so a claim made via `assignment-provider claim` and one
|
|
192
|
+
* made via ensure-session share the same canonical key. `--actor-json` explicit fixtures leave
|
|
193
|
+
* `actorKey` unset — `performLocalClaim`/`performLocalSupersede` then fall back to
|
|
194
|
+
* `serializeActor(actor)` for the record's `actor_key`, preserving existing fixture behavior.
|
|
179
195
|
*/
|
|
180
|
-
function loadActorStruct(args: ParsedArgs): ActorStruct {
|
|
196
|
+
function loadActorStruct(args: ParsedArgs): { actor: ActorStruct; actorKey?: string } {
|
|
181
197
|
const actorJsonPath = flagString(args.flags, "actor-json");
|
|
182
|
-
if (actorJsonPath) return loadActorStructFromFile(actorJsonPath);
|
|
198
|
+
if (actorJsonPath) return { actor: loadActorStructFromFile(actorJsonPath) };
|
|
183
199
|
const helper = loadActorIdentityHelper();
|
|
184
200
|
const resolved = helper.resolveActor(process.env);
|
|
185
201
|
if (helper.isUnresolvedActor(resolved.actor)) throw new Error("could not resolve an actor identity (no --actor-json and no resolvable environment actor); pass --actor-json explicitly");
|
|
186
|
-
return { runtime: helper.detectRuntime(process.env), session_id: resolved.actor, host: os.hostname(), human: null };
|
|
202
|
+
return { actor: { runtime: helper.detectRuntime(process.env), session_id: resolved.actor, host: os.hostname(), human: null }, actorKey: resolved.actor };
|
|
187
203
|
}
|
|
188
204
|
|
|
189
|
-
function assignmentFilePath(artifactRoot: string, subjectId: string): string {
|
|
205
|
+
export function assignmentFilePath(artifactRoot: string, subjectId: string): string {
|
|
190
206
|
const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
|
|
191
207
|
return path.join(artifactRoot, "assignment", `${sanitized}.json`);
|
|
192
208
|
}
|
|
193
209
|
|
|
194
|
-
function readLocalRecord(artifactRoot: string, subjectId: string): AssignmentClaimRecord | null {
|
|
210
|
+
export function readLocalRecord(artifactRoot: string, subjectId: string): AssignmentClaimRecord | null {
|
|
195
211
|
const file = assignmentFilePath(artifactRoot, subjectId);
|
|
196
212
|
if (!fs.existsSync(file)) return null;
|
|
197
213
|
let data: unknown;
|
|
@@ -208,7 +224,7 @@ function readLocalRecord(artifactRoot: string, subjectId: string): AssignmentCla
|
|
|
208
224
|
return record;
|
|
209
225
|
}
|
|
210
226
|
|
|
211
|
-
function writeLocalRecord(artifactRoot: string, subjectId: string, record: AssignmentClaimRecord): void {
|
|
227
|
+
export function writeLocalRecord(artifactRoot: string, subjectId: string, record: AssignmentClaimRecord): void {
|
|
212
228
|
// writeJson throws on any mkdir/writeFileSync failure; that error is intentionally allowed to
|
|
213
229
|
// propagate to main()'s top-level try/catch and exit non-zero. Durable writes must fail loud,
|
|
214
230
|
// never fail open (artifact-contract.md).
|
|
@@ -254,7 +270,7 @@ function subjectLockDir(artifactRoot: string, subjectId: string): string {
|
|
|
254
270
|
* the write) of all three local-file mutators in this, since all three mutate the same record
|
|
255
271
|
* file for a given subject.
|
|
256
272
|
*/
|
|
257
|
-
function withSubjectLock<T>(artifactRoot: string, subjectId: string, body: () => T): T {
|
|
273
|
+
export function withSubjectLock<T>(artifactRoot: string, subjectId: string, body: () => T): T {
|
|
258
274
|
const lockDir = subjectLockDir(artifactRoot, subjectId);
|
|
259
275
|
const staleMs = Number(process.env.FLOW_AGENTS_ASSIGNMENT_STALE_LOCK_MS ?? 5 * 60 * 1000);
|
|
260
276
|
const deadline = Date.now() + 30000;
|
|
@@ -305,7 +321,7 @@ function withSubjectLock<T>(artifactRoot: string, subjectId: string, body: () =>
|
|
|
305
321
|
* through here too means `--now` deterministically governs idle_days as well as liveness
|
|
306
322
|
* freshness, rather than idle_days silently reading the real wall clock regardless of `--now`.
|
|
307
323
|
*/
|
|
308
|
-
function computeEffectiveState(assignment: AssignmentStatus, freshHoldersList: FreshHolder[], selfActor: string | undefined, nowMs: number): {
|
|
324
|
+
export function computeEffectiveState(assignment: AssignmentStatus, freshHoldersList: FreshHolder[], selfActor: string | undefined, nowMs: number): {
|
|
309
325
|
effective_state: EffectiveState;
|
|
310
326
|
reason: string;
|
|
311
327
|
holder?: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string };
|
|
@@ -339,7 +355,14 @@ function computeEffectiveState(assignment: AssignmentStatus, freshHoldersList: F
|
|
|
339
355
|
return { effective_state: "human-held", reason: "assignee_without_claim_record", holder: { assignee: assignment.assignee } };
|
|
340
356
|
}
|
|
341
357
|
|
|
342
|
-
|
|
358
|
+
// F1 fix (fix-plan iteration 1, HIGH): prefer the canonical actor_key over re-serializing
|
|
359
|
+
// record.actor. record.actor_key IS resolveActor(env).actor for records written by the fixed
|
|
360
|
+
// performLocalClaim/performLocalSupersede paths (below) — the same flat/bare string liveness
|
|
361
|
+
// whoami, `liveness claim --actor`, per-actor current.json, and pull-work's --self-actor all
|
|
362
|
+
// use — so for an explicit-override actor the self-check and the liveness join now agree with
|
|
363
|
+
// every other tool. BACK-COMPAT: records with no actor_key (every pre-fix record, every #290
|
|
364
|
+
// eval fixture) fall back to serializeActor(record.actor) exactly as before this fix.
|
|
365
|
+
const holderActorKey = record.actor_key || loadActorIdentityHelper().serializeActor(record.actor);
|
|
343
366
|
if (selfActor && holderActorKey === selfActor) return { effective_state: "held", reason: "self_is_holder", holder: { actor: holderActorKey } };
|
|
344
367
|
|
|
345
368
|
const fresh = freshHoldersList.find((holder) => holder.actor === holderActorKey);
|
|
@@ -466,26 +489,27 @@ function loadLivenessInputs(args: ParsedArgs): { events: AnyObj[] | null; selfAc
|
|
|
466
489
|
// ─── local-file: claim | release | supersede (the durable-write path; real I/O by design — no
|
|
467
490
|
// external mutation to defer to a skill for this provider kind, per Design Decision 1) ─────────
|
|
468
491
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
492
|
+
/**
|
|
493
|
+
* Wave 1 (#291) extraction: the durable-write body previously inlined inside claimLocalFile's
|
|
494
|
+
* withSubjectLock() closure, now a parameter-driven pure function so ensure-session's ownership
|
|
495
|
+
* guard (workflow-sidecar.ts, Wave 2) can reuse the EXACT same claim logic — same-actor idempotent
|
|
496
|
+
* refresh, different-actor throw, atomic write under withSubjectLock — rather than reimplementing
|
|
497
|
+
* a second, parallel claim path. claimLocalFile (CLI wrapper, below) is now a thin
|
|
498
|
+
* parse-args/print-envelope shell around this.
|
|
499
|
+
*/
|
|
500
|
+
export function performLocalClaim(
|
|
501
|
+
artifactRoot: string,
|
|
502
|
+
subjectId: string,
|
|
503
|
+
actor: ActorStruct,
|
|
504
|
+
opts: { ttlSeconds: number; branch: string; artifactDir: string; reason?: string; actorKey?: string },
|
|
505
|
+
): AssignmentClaimRecord {
|
|
476
506
|
const helper = loadActorIdentityHelper();
|
|
477
|
-
|
|
478
|
-
const ttlSecondsRaw = flagString(args.flags, "ttl-seconds", "1800") ?? "1800";
|
|
479
|
-
const ttlSeconds = Number(ttlSecondsRaw);
|
|
480
|
-
if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) throw new Error(`--ttl-seconds must be a positive number; got ${ttlSecondsRaw}`);
|
|
481
|
-
const branch = requireFlag(args, "branch");
|
|
482
|
-
const artifactDir = requireFlag(args, "artifact-dir");
|
|
483
|
-
const reason = flagString(args.flags, "reason") ?? "claim";
|
|
507
|
+
const reason = opts.reason ?? "claim";
|
|
484
508
|
|
|
485
509
|
// F1 fix (fix-plan iteration 1, CRITICAL): the existing-claim check AND the write must happen
|
|
486
510
|
// atomically with respect to any other `assignment-provider` invocation on the same subject —
|
|
487
511
|
// see withSubjectLock()'s doc comment for the full rationale.
|
|
488
|
-
return withSubjectLock(artifactRoot, subjectId, ():
|
|
512
|
+
return withSubjectLock(artifactRoot, subjectId, (): AssignmentClaimRecord => {
|
|
489
513
|
const existing = readLocalRecord(artifactRoot, subjectId);
|
|
490
514
|
if (existing && existing.status === "claimed") {
|
|
491
515
|
const existingActorKey = helper.serializeActor(existing.actor);
|
|
@@ -502,61 +526,184 @@ function claimLocalFile(argv: string[]): number {
|
|
|
502
526
|
role: "AssignmentClaimRecord",
|
|
503
527
|
subject_id: subjectId,
|
|
504
528
|
actor,
|
|
529
|
+
...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
|
|
505
530
|
claimed_at: isoNow(),
|
|
506
|
-
ttl_seconds: ttlSeconds,
|
|
507
|
-
branch,
|
|
508
|
-
artifact_dir: artifactDir,
|
|
531
|
+
ttl_seconds: opts.ttlSeconds,
|
|
532
|
+
branch: opts.branch,
|
|
533
|
+
artifact_dir: opts.artifactDir,
|
|
509
534
|
status: "claimed",
|
|
510
535
|
audit_trail: [...(existing?.audit_trail ?? []), { at: isoNow(), transition: "claim", from_actor: null, to_actor: actor, reason }],
|
|
511
536
|
};
|
|
512
537
|
writeLocalRecord(artifactRoot, subjectId, record);
|
|
513
|
-
|
|
514
|
-
return 0;
|
|
538
|
+
return record;
|
|
515
539
|
});
|
|
516
540
|
}
|
|
517
541
|
|
|
518
|
-
function
|
|
542
|
+
function claimLocalFile(argv: string[]): number {
|
|
519
543
|
const args = parseArgs(argv);
|
|
520
544
|
const provider = flagString(args.flags, "provider", "local-file");
|
|
521
|
-
if (provider !== "local-file") throw new Error(`
|
|
545
|
+
if (provider !== "local-file") throw new Error(`claim: --provider must be local-file (use render-claim for github); got ${provider}`);
|
|
522
546
|
const artifactRoot = requireFlag(args, "artifact-root");
|
|
523
547
|
const subjectId = requireFlag(args, "subject-id");
|
|
524
|
-
const
|
|
525
|
-
|
|
548
|
+
const { actor, actorKey } = loadActorStruct(args);
|
|
549
|
+
|
|
550
|
+
const ttlSecondsRaw = flagString(args.flags, "ttl-seconds", "1800") ?? "1800";
|
|
551
|
+
const ttlSeconds = Number(ttlSecondsRaw);
|
|
552
|
+
if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) throw new Error(`--ttl-seconds must be a positive number; got ${ttlSecondsRaw}`);
|
|
553
|
+
const branch = requireFlag(args, "branch");
|
|
554
|
+
const artifactDir = requireFlag(args, "artifact-dir");
|
|
555
|
+
const reason = flagString(args.flags, "reason") ?? "claim";
|
|
556
|
+
|
|
557
|
+
const record = performLocalClaim(artifactRoot, subjectId, actor, { ttlSeconds, branch, artifactDir, reason, actorKey });
|
|
558
|
+
console.log(JSON.stringify({ role: "AssignmentClaimResult", subject_id: subjectId, record }, null, 2));
|
|
559
|
+
return 0;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Wave 1 (#292) extraction: the durable-write body previously inlined inside releaseLocalFile's
|
|
564
|
+
* withSubjectLock() closure, now a parameter-driven pure function so the Stop hook's non-terminal
|
|
565
|
+
* release lifecycle (scripts/hooks/stop-goal-fit.js, #292 Wave 2) can reuse the EXACT same release
|
|
566
|
+
* logic — actor-ownership verification, audit-trail append, atomic write under withSubjectLock —
|
|
567
|
+
* rather than reimplementing a second, parallel release path. releaseLocalFile (CLI wrapper,
|
|
568
|
+
* below) is now a thin parse-args/print-envelope shell around this, mirroring the
|
|
569
|
+
* performLocalSupersede/supersedeLocalFile extraction shape exactly.
|
|
570
|
+
*
|
|
571
|
+
* Two behaviors are deliberately DIFFERENT from a naive inline release, both required for the
|
|
572
|
+
* Stop hook's idempotent, actor-scoped lifecycle release (never for the interactive CLI, which
|
|
573
|
+
* keeps `tolerateNoActiveClaim` unset/false and therefore 100% of its prior throw-on-error shape):
|
|
574
|
+
*
|
|
575
|
+
* - `opts.tolerateNoActiveClaim === true` and there is no existing record, or the existing
|
|
576
|
+
* record's status is not `"claimed"`: return `null` (a tolerated no-op) instead of throwing
|
|
577
|
+
* "no active claim to release". This is the one deliberate idempotency change vs today's
|
|
578
|
+
* releaseLocalFile — a second release call (e.g. a double Stop event) must be a safe no-op.
|
|
579
|
+
* - `releasedBy` is provided and does not match the existing record's holder: never force-release
|
|
580
|
+
* a claim held by a different actor — return `null` (if tolerateNoActiveClaim) or throw
|
|
581
|
+
* (otherwise), same as the no-active-claim case. The comparison mirrors computeEffectiveState()'s
|
|
582
|
+
* `record.actor_key || helper.serializeActor(record.actor)` canonical-key preference EXACTLY
|
|
583
|
+
* (actor_key-first, falling back to serializeActor only when actor_key is absent) — the read
|
|
584
|
+
* path (status/effective-state) and this write path (release) must use the identical
|
|
585
|
+
* canonical-key comparison, or a claim written under an explicit-override actor (`actor_key`
|
|
586
|
+
* bare, e.g. `"canonical-x"`, but `serializeActor(record.actor)` a DIFFERENT triple, e.g.
|
|
587
|
+
* `"explicit-override:canonical-x:host"`) can be self-recognized by computeEffectiveState() yet
|
|
588
|
+
* fail to release here because the releaser's canonical key was compared against the wrong
|
|
589
|
+
* (re-derived, triple) form instead of the stored actor_key. Comparing two serializeActor()
|
|
590
|
+
* calls unconditionally — as a prior version of this function did — is NOT correct for override
|
|
591
|
+
* actors and reintroduces the exact #291 seam on the release path.
|
|
592
|
+
*
|
|
593
|
+
* Contract: when `releasedBy` is provided AND the existing record is `actor_key`-stamped,
|
|
594
|
+
* `opts.actorKey` is REQUIRED (the canonical `resolveActor(env).actor` string) — otherwise
|
|
595
|
+
* ownership cannot be verified. A caller that passes `releasedBy` without `opts.actorKey` against
|
|
596
|
+
* an `actor_key`-stamped record would have its ownership compared as
|
|
597
|
+
* `existing.actor_key` (bare canonical) vs `serializeActor(releasedBy)` (re-derived triple), which
|
|
598
|
+
* can NEVER match even for the legitimate holder — a silent-failure trap, not a real ownership
|
|
599
|
+
* check. This is refused loudly (see the guard at the top of the `releasedBy` branch below) rather
|
|
600
|
+
* than allowed to silently no-op or wrongly refuse.
|
|
601
|
+
*/
|
|
602
|
+
export function performLocalRelease(
|
|
603
|
+
artifactRoot: string,
|
|
604
|
+
subjectId: string,
|
|
605
|
+
releasedBy: ActorStruct | null,
|
|
606
|
+
opts: { reason?: string; actorKey?: string; tolerateNoActiveClaim?: boolean } = {},
|
|
607
|
+
): AssignmentClaimRecord | null {
|
|
608
|
+
const helper = loadActorIdentityHelper();
|
|
609
|
+
const reason = opts.reason ?? "released";
|
|
610
|
+
const tolerateNoActiveClaim = opts.tolerateNoActiveClaim ?? false;
|
|
526
611
|
|
|
527
612
|
// F1 fix (fix-plan iteration 1, CRITICAL): release mutates the same record file claim/supersede
|
|
528
613
|
// do, under the same per-subject lock (see withSubjectLock()'s doc comment).
|
|
529
|
-
return withSubjectLock(artifactRoot, subjectId, ():
|
|
614
|
+
return withSubjectLock(artifactRoot, subjectId, (): AssignmentClaimRecord | null => {
|
|
530
615
|
const existing = readLocalRecord(artifactRoot, subjectId);
|
|
531
|
-
if (!existing || existing.status !== "claimed")
|
|
616
|
+
if (!existing || existing.status !== "claimed") {
|
|
617
|
+
if (tolerateNoActiveClaim) return null;
|
|
618
|
+
throw new Error(`no active claim to release for subject: ${subjectId}`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (releasedBy) {
|
|
622
|
+
// Contract guard (hardening fix, #292 review): a caller that supplies `releasedBy` but NOT
|
|
623
|
+
// `opts.actorKey` against a record that already carries `actor_key` cannot reliably prove
|
|
624
|
+
// ownership — see this function's doc comment. This is the ONLY combination that fires: it
|
|
625
|
+
// does NOT fire when `existing.actor_key` is absent (the CLI/fixture path, where both sides
|
|
626
|
+
// fall back to serializeActor() and legitimately compare equal). Fail loudly rather than
|
|
627
|
+
// silently no-op (tolerant callers) or wrongly refuse (throwing callers) — never silent.
|
|
628
|
+
if (!opts.actorKey && existing.actor_key) {
|
|
629
|
+
if (tolerateNoActiveClaim) {
|
|
630
|
+
console.error(
|
|
631
|
+
`[performLocalRelease] cannot verify ownership of an actor_key-stamped record without opts.actorKey; skipping release for ${subjectId}`,
|
|
632
|
+
);
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
throw new Error(
|
|
636
|
+
"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",
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// AC6: never force-release a claim held by a different actor. Mirrors
|
|
641
|
+
// computeEffectiveState()'s canonical self-recognition comparison EXACTLY —
|
|
642
|
+
// `holderActorKey` prefers the stored `actor_key` (the canonical resolveActor(env).actor
|
|
643
|
+
// string, present on records written by the fixed performLocalClaim/performLocalSupersede
|
|
644
|
+
// paths) and only falls back to `serializeActor(existing.actor)` when `actor_key` is
|
|
645
|
+
// absent (every pre-fix record, every #290 eval fixture). The releaser's side must use the
|
|
646
|
+
// SAME canonical form: `opts.actorKey` (the caller's resolveActor(env).actor string, e.g.
|
|
647
|
+
// scripts/hooks/stop-goal-fit.js's Stop hook) when provided, else re-derived via
|
|
648
|
+
// serializeActor(releasedBy) — never serializeActor() unconditionally on both sides, which
|
|
649
|
+
// would compare the bare actor_key form against a re-derived triple form for an
|
|
650
|
+
// explicit-override actor and spuriously reject a legitimate same-actor release (the #291
|
|
651
|
+
// seam, relocated to this write path).
|
|
652
|
+
const holderActorKey = existing.actor_key || helper.serializeActor(existing.actor);
|
|
653
|
+
const releasedByActorKey = opts.actorKey || helper.serializeActor(releasedBy);
|
|
654
|
+
if (holderActorKey !== releasedByActorKey) {
|
|
655
|
+
if (tolerateNoActiveClaim) return null;
|
|
656
|
+
throw new Error(`--actor-json does not match the current holder (${holderActorKey}); refusing to release a claim held by someone else`);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
532
660
|
const record: AssignmentClaimRecord = {
|
|
533
661
|
...existing,
|
|
662
|
+
...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
|
|
534
663
|
status: "released",
|
|
535
664
|
audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "release", from_actor: existing.actor, to_actor: releasedBy, reason }],
|
|
536
665
|
};
|
|
537
666
|
writeLocalRecord(artifactRoot, subjectId, record);
|
|
538
|
-
|
|
539
|
-
return 0;
|
|
667
|
+
return record;
|
|
540
668
|
});
|
|
541
669
|
}
|
|
542
670
|
|
|
543
|
-
function
|
|
671
|
+
function releaseLocalFile(argv: string[]): number {
|
|
544
672
|
const args = parseArgs(argv);
|
|
545
673
|
const provider = flagString(args.flags, "provider", "local-file");
|
|
546
|
-
if (provider !== "local-file") throw new Error(`
|
|
674
|
+
if (provider !== "local-file") throw new Error(`release: --provider must be local-file (use render-release for github); got ${provider}`);
|
|
547
675
|
const artifactRoot = requireFlag(args, "artifact-root");
|
|
548
676
|
const subjectId = requireFlag(args, "subject-id");
|
|
677
|
+
const releasedBy = flagString(args.flags, "actor-json") ? loadActorStructFromFile(requireFlag(args, "actor-json")) : null;
|
|
678
|
+
const reason = flagString(args.flags, "reason") ?? "released";
|
|
679
|
+
|
|
680
|
+
const record = performLocalRelease(artifactRoot, subjectId, releasedBy, { reason, tolerateNoActiveClaim: false });
|
|
681
|
+
console.log(JSON.stringify({ role: "AssignmentReleaseResult", subject_id: subjectId, record }, null, 2));
|
|
682
|
+
return 0;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Wave 1 (#291) extraction: the durable-write body previously inlined inside supersedeLocalFile's
|
|
687
|
+
* withSubjectLock() closure, now a parameter-driven pure function so ensure-session's
|
|
688
|
+
* `--supersede-stale` takeover path (workflow-sidecar.ts, Wave 2) can reuse the EXACT same
|
|
689
|
+
* supersede logic — from-actor holder verification, ttl/branch/artifact_dir carry-forward,
|
|
690
|
+
* audit-trail append, atomic write under withSubjectLock — rather than reimplementing a second,
|
|
691
|
+
* parallel supersede path. supersedeLocalFile (CLI wrapper, below) is now a thin
|
|
692
|
+
* parse-args/print-envelope shell around this.
|
|
693
|
+
*/
|
|
694
|
+
export function performLocalSupersede(
|
|
695
|
+
artifactRoot: string,
|
|
696
|
+
subjectId: string,
|
|
697
|
+
fromActor: ActorStruct,
|
|
698
|
+
toActor: ActorStruct,
|
|
699
|
+
opts: { ttlSeconds?: number; branch?: string; artifactDir?: string; reason?: string; actorKey?: string } = {},
|
|
700
|
+
): AssignmentClaimRecord {
|
|
549
701
|
const helper = loadActorIdentityHelper();
|
|
550
|
-
const
|
|
551
|
-
const toActor = loadActorStructFromFile(requireFlag(args, "to-actor-json"));
|
|
552
|
-
const reason = flagString(args.flags, "reason") ?? "supersede";
|
|
553
|
-
const ttlSecondsOverride = flagString(args.flags, "ttl-seconds");
|
|
554
|
-
const branchOverride = flagString(args.flags, "branch");
|
|
555
|
-
const artifactDirOverride = flagString(args.flags, "artifact-dir");
|
|
702
|
+
const reason = opts.reason ?? "supersede";
|
|
556
703
|
|
|
557
704
|
// F1 fix (fix-plan iteration 1, CRITICAL): supersede mutates the same record file claim/release
|
|
558
705
|
// do, under the same per-subject lock (see withSubjectLock()'s doc comment).
|
|
559
|
-
return withSubjectLock(artifactRoot, subjectId, ():
|
|
706
|
+
return withSubjectLock(artifactRoot, subjectId, (): AssignmentClaimRecord => {
|
|
560
707
|
const existing = readLocalRecord(artifactRoot, subjectId);
|
|
561
708
|
if (!existing || existing.status !== "claimed") throw new Error(`no active claim to supersede for subject: ${subjectId}`);
|
|
562
709
|
|
|
@@ -564,7 +711,7 @@ function supersedeLocalFile(argv: string[]): number {
|
|
|
564
711
|
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`);
|
|
565
712
|
}
|
|
566
713
|
|
|
567
|
-
const ttlSecondsRaw =
|
|
714
|
+
const ttlSecondsRaw = opts.ttlSeconds != null ? String(opts.ttlSeconds) : String(existing.ttl_seconds);
|
|
568
715
|
const ttlSeconds = Number(ttlSecondsRaw);
|
|
569
716
|
if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) throw new Error(`--ttl-seconds must be a positive number; got ${ttlSecondsRaw}`);
|
|
570
717
|
|
|
@@ -573,17 +720,40 @@ function supersedeLocalFile(argv: string[]): number {
|
|
|
573
720
|
role: "AssignmentClaimRecord",
|
|
574
721
|
subject_id: subjectId,
|
|
575
722
|
actor: toActor,
|
|
723
|
+
...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
|
|
576
724
|
claimed_at: isoNow(),
|
|
577
725
|
ttl_seconds: ttlSeconds,
|
|
578
|
-
branch:
|
|
579
|
-
artifact_dir:
|
|
726
|
+
branch: opts.branch ?? existing.branch,
|
|
727
|
+
artifact_dir: opts.artifactDir ?? existing.artifact_dir,
|
|
580
728
|
status: "claimed",
|
|
581
729
|
audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "supersede", from_actor: fromActor, to_actor: toActor, reason }],
|
|
582
730
|
};
|
|
583
731
|
writeLocalRecord(artifactRoot, subjectId, record);
|
|
584
|
-
|
|
585
|
-
|
|
732
|
+
return record;
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function supersedeLocalFile(argv: string[]): number {
|
|
737
|
+
const args = parseArgs(argv);
|
|
738
|
+
const provider = flagString(args.flags, "provider", "local-file");
|
|
739
|
+
if (provider !== "local-file") throw new Error(`supersede: --provider must be local-file (use render-supersede for github); got ${provider}`);
|
|
740
|
+
const artifactRoot = requireFlag(args, "artifact-root");
|
|
741
|
+
const subjectId = requireFlag(args, "subject-id");
|
|
742
|
+
const fromActor = loadActorStructFromFile(requireFlag(args, "from-actor-json"));
|
|
743
|
+
const toActor = loadActorStructFromFile(requireFlag(args, "to-actor-json"));
|
|
744
|
+
const reason = flagString(args.flags, "reason") ?? "supersede";
|
|
745
|
+
const ttlSecondsOverride = flagString(args.flags, "ttl-seconds");
|
|
746
|
+
const branchOverride = flagString(args.flags, "branch");
|
|
747
|
+
const artifactDirOverride = flagString(args.flags, "artifact-dir");
|
|
748
|
+
|
|
749
|
+
const record = performLocalSupersede(artifactRoot, subjectId, fromActor, toActor, {
|
|
750
|
+
ttlSeconds: ttlSecondsOverride != null ? Number(ttlSecondsOverride) : undefined,
|
|
751
|
+
branch: branchOverride ?? undefined,
|
|
752
|
+
artifactDir: artifactDirOverride ?? undefined,
|
|
753
|
+
reason,
|
|
586
754
|
});
|
|
755
|
+
console.log(JSON.stringify({ role: "AssignmentSupersedeResult", subject_id: subjectId, record }, null, 2));
|
|
756
|
+
return 0;
|
|
587
757
|
}
|
|
588
758
|
|
|
589
759
|
// ─── GitHub: render-claim | render-release | render-supersede (render, don't execute — Design
|
|
@@ -745,6 +915,18 @@ function renderSupersede(argv: string[]): number {
|
|
|
745
915
|
|
|
746
916
|
// ─── status | list (both provider kinds) ────────────────────────────────────────────────────
|
|
747
917
|
|
|
918
|
+
/**
|
|
919
|
+
* Wave 1 (#291) extraction: the local-file branch of statusCommand's assignment-layer read,
|
|
920
|
+
* mirrored exactly so ensure-session's ownership guard (workflow-sidecar.ts, Wave 2) derives an
|
|
921
|
+
* AssignmentStatus identically to the `assignment-provider status` CLI command — a single
|
|
922
|
+
* implementation, not a second parallel local-file read.
|
|
923
|
+
*/
|
|
924
|
+
export function readLocalAssignmentStatus(artifactRoot: string, subjectId: string): AssignmentStatus {
|
|
925
|
+
const record = readLocalRecord(artifactRoot, subjectId);
|
|
926
|
+
const active = record && record.status === "claimed" ? record : null;
|
|
927
|
+
return { subject_id: subjectId, provider: "local-file", assignee: active ? loadActorIdentityHelper().serializeActor(active.actor) : null, record: active };
|
|
928
|
+
}
|
|
929
|
+
|
|
748
930
|
function statusCommand(argv: string[]): number {
|
|
749
931
|
const args = parseArgs(argv);
|
|
750
932
|
const provider = requireFlag(args, "provider");
|
|
@@ -754,9 +936,7 @@ function statusCommand(argv: string[]): number {
|
|
|
754
936
|
if (provider === "local-file") {
|
|
755
937
|
const artifactRoot = requireFlag(args, "artifact-root");
|
|
756
938
|
if (!requestedSubjectId) throw new Error("--subject-id is required for status --provider local-file");
|
|
757
|
-
|
|
758
|
-
const active = record && record.status === "claimed" ? record : null;
|
|
759
|
-
assignment = { subject_id: requestedSubjectId, provider: "local-file", assignee: active ? loadActorIdentityHelper().serializeActor(active.actor) : null, record: active };
|
|
939
|
+
assignment = readLocalAssignmentStatus(artifactRoot, requestedSubjectId);
|
|
760
940
|
} else if (provider === "github") {
|
|
761
941
|
const issueJsonPath = requireFlag(args, "issue-json");
|
|
762
942
|
const issue = loadJsonInput(issueJsonPath) as GithubIssueDoc;
|