@kontourai/flow-agents 2.4.0 → 3.1.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/CODEOWNERS +8 -0
- package/.github/workflows/ci.yml +20 -0
- package/.github/workflows/trust-reconcile.yml +62 -4
- package/CHANGELOG.md +38 -0
- package/CONTEXT.md +88 -1
- package/README.md +4 -0
- package/build/src/cli/assignment-provider.d.ts +144 -0
- package/build/src/cli/assignment-provider.js +805 -0
- package/build/src/cli/effective-assignment-provider-settings.d.ts +1 -0
- package/build/src/cli/effective-assignment-provider-settings.js +125 -0
- package/build/src/cli/validate-workflow-artifacts.js +5 -1
- package/build/src/cli/workflow-sidecar.d.ts +2 -2
- package/build/src/cli/workflow-sidecar.js +549 -145
- package/build/src/cli.js +6 -0
- 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 -0
- package/context/contracts/artifact-contract.md +2 -0
- package/context/contracts/assignment-provider-contract.md +248 -0
- package/context/contracts/builder-kit-workflow-state-contract.md +2 -0
- package/context/contracts/decision-registry-contract.md +2 -0
- package/context/contracts/delivery-contract.md +2 -0
- package/context/contracts/execution-contract.md +25 -0
- package/context/contracts/governance-adapter-contract.md +2 -0
- package/context/contracts/knowledge-store-contract.md +197 -0
- package/context/contracts/planning-contract.md +2 -0
- package/context/contracts/probe-docs-write-contract.md +187 -0
- package/context/contracts/review-contract.md +2 -0
- package/context/contracts/sandbox-policy.md +2 -0
- package/context/contracts/standing-directives.md +13 -0
- package/context/contracts/verification-contract.md +2 -0
- package/context/contracts/work-item-contract.md +2 -0
- package/context/scripts/hooks/config-protection.js +14 -1
- package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/context/scripts/hooks/stop-goal-fit.js +4 -2
- package/context/scripts/hooks/workflow-steering.js +42 -0
- package/context/settings/assignment-provider-settings.json +33 -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 +182 -0
- package/docs/adr/README.md +49 -0
- package/docs/adr/index.md +34 -0
- package/docs/context-map.md +1 -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 +22 -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/knowledge-store-provider.md +51 -0
- package/docs/decisions/kontour-resource-contract.md +18 -0
- package/docs/decisions/mcp-posture.md +18 -0
- package/docs/decisions/model-routing.md +63 -0
- package/docs/decisions/standing-directives.md +66 -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 -0
- package/docs/workflow-shared-contracts.md +2 -1
- package/docs/workflow-usage-guide.md +7 -1
- package/evals/ci/run-baseline.sh +10 -0
- package/evals/fixtures/assignment-provider/actor-a.json +6 -0
- package/evals/fixtures/assignment-provider/actor-b.json +6 -0
- package/evals/fixtures/assignment-provider/github-issue-claimed.json +27 -0
- package/evals/fixtures/assignment-provider/github-issue-unassigned.json +7 -0
- package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
- package/evals/fixtures/assignment-provider/liveness-fresh.json +9 -0
- package/evals/fixtures/assignment-provider/liveness-stale.json +9 -0
- package/evals/integration/test_assignment_provider_github.sh +318 -0
- package/evals/integration/test_assignment_provider_local_file.sh +222 -0
- package/evals/integration/test_critique_supersession_roundtrip.sh +182 -0
- 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_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_gate_lockdown.sh +10 -0
- package/evals/integration/test_publish_delivery.sh +21 -4
- package/evals/integration/test_pull_work_assignment_join.sh +132 -0
- package/evals/integration/test_pull_work_liveness_preflight.sh +20 -7
- package/evals/integration/test_reconcile_soundness.sh +33 -9
- package/evals/integration/test_trust_reconcile.sh +9 -8
- package/evals/integration/test_trust_reconcile_negatives.sh +608 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +91 -3
- package/evals/run.sh +14 -0
- package/evals/static/test_flowdef_codeowners_coverage.sh +6 -0
- package/evals/static/test_knowledge_providers.sh +32 -0
- package/evals/static/test_workflow_skills.sh +15 -2
- package/kits/builder/skills/deliver/SKILL.md +36 -0
- package/kits/builder/skills/design-probe/SKILL.md +37 -0
- package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
- package/kits/builder/skills/pull-work/SKILL.md +78 -10
- package/kits/knowledge/adapters/default-store/index.js +92 -4
- package/kits/knowledge/adapters/obsidian-store/index.js +69 -4
- package/kits/knowledge/adapters/shared/codec.js +141 -0
- package/kits/knowledge/docs/README.md +121 -2
- package/kits/knowledge/docs/store-contract.md +112 -4
- package/kits/knowledge/evals/contract-suite/suite.test.js +176 -0
- package/kits/knowledge/flows/promote.flow.json +84 -0
- package/kits/knowledge/kit.json +50 -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/fixtures/git-repo/CONTEXT.md +12 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md +13 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md +14 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md +14 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/learnings/fixture-learning.md +7 -0
- package/kits/knowledge/providers/conformance/fixtures/work-item/issues.json +30 -0
- package/kits/knowledge/providers/conformance/suite.test.js +143 -0
- package/kits/knowledge/providers/git-repo/index.js +236 -0
- package/kits/knowledge/providers/health/health-pass.test.js +99 -0
- package/kits/knowledge/providers/health/index.js +153 -0
- package/kits/knowledge/providers/index.js +25 -0
- package/kits/knowledge/providers/lib/model.js +91 -0
- package/kits/knowledge/providers/lib/schema-validate.js +119 -0
- package/kits/knowledge/providers/markdown-vault/index.js +169 -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/kits/knowledge/providers/work-item/index.js +204 -0
- package/package.json +7 -3
- package/schemas/assignment-provider-settings.schema.json +125 -0
- package/schemas/knowledge/edge.schema.json +54 -0
- package/schemas/knowledge/health-report.schema.json +45 -0
- package/schemas/knowledge/node.schema.json +49 -0
- package/schemas/knowledge/proposal.schema.json +53 -0
- package/scripts/README.md +1 -0
- package/scripts/ci/trust-reconcile.js +521 -24
- package/scripts/freeze-adrs.mjs +364 -0
- package/scripts/hooks/config-protection.js +14 -1
- 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 +4 -2
- package/scripts/hooks/workflow-steering.js +42 -0
- package/scripts/statusline/flow-agents-statusline.js +3 -1
- package/src/cli/assignment-provider.ts +927 -0
- package/src/cli/effective-assignment-provider-settings.ts +112 -0
- package/src/cli/validate-workflow-artifacts.ts +5 -1
- package/src/cli/workflow-sidecar.ts +577 -139
- package/src/cli.ts +6 -0
- package/src/lib/flow-resolver.ts +35 -14
- package/src/tools/validate-source-tree.ts +2 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
3
4
|
import * as path from "node:path";
|
|
4
5
|
import { execFileSync } from "node:child_process";
|
|
5
6
|
import { createHash } from "node:crypto";
|
|
@@ -8,6 +9,11 @@ import { fileURLToPath } from "node:url";
|
|
|
8
9
|
// ADR 0016 Abstraction A: shared FlowDefinition resolver (P-a)
|
|
9
10
|
import { resolveActiveFlowStep, resolveFlowFilePath, resolvePhaseMap, resolveRouteBackPolicy, type ActiveFlowStep } from "../lib/flow-resolver.js";
|
|
10
11
|
import { defaultArtifactRootForRead, flowAgentsArtifactRoot } from "../lib/local-artifact-root.js";
|
|
12
|
+
// #291 Wave 1 Task 1.1 exports: ensure-session's ownership guard reuses the EXACT same
|
|
13
|
+
// assignment ⋈ liveness join / claim / supersede logic #290 already ships for the
|
|
14
|
+
// `assignment-provider` CLI, rather than reimplementing a second, parallel join (static ESM
|
|
15
|
+
// import — same idiom already used above for ../lib/flow-resolver.js).
|
|
16
|
+
import { computeEffectiveState, performLocalClaim, performLocalSupersede, readLocalAssignmentStatus, type ActorStruct, type EffectiveState, type FreshHolder } from "./assignment-provider.js";
|
|
11
17
|
|
|
12
18
|
type AnyObj = Record<string, any>;
|
|
13
19
|
|
|
@@ -164,6 +170,78 @@ function validateExplicitBranch(value: string): void {
|
|
|
164
170
|
}
|
|
165
171
|
}
|
|
166
172
|
|
|
173
|
+
/**
|
|
174
|
+
* #291 Wave 2 Task 2.1: the single shared actor resolver for ensure-session — used by BOTH
|
|
175
|
+
* resolveSessionBranch() (below) and the new ownership guard inside ensureSession(), so the
|
|
176
|
+
* branch-naming actor and the assignment-claim actor are ALWAYS derived from the same one
|
|
177
|
+
* resolution pass (never re-derived twice with any risk of divergence). Same explicit-actor
|
|
178
|
+
* validation/die behavior as before this refactor (garbage --actor still dies; ambiguous/
|
|
179
|
+
* unresolved actor never hard-fails session creation — Design Decision 4).
|
|
180
|
+
*
|
|
181
|
+
* Returns TWO distinct identity strings, not one, because this repo already has two genuinely
|
|
182
|
+
* different actor-identity conventions in play and reconciling them into a single string would
|
|
183
|
+
* either break existing branch-naming byte-for-byte (see test_workflow_sidecar_writer.sh AC4) or
|
|
184
|
+
* break self-recognition against a durable assignment-claim record:
|
|
185
|
+
* - `branchActorKey`: the flat, single-token-or-triple string this file has ALWAYS used for
|
|
186
|
+
* branch naming, liveness `--actor`, and (Wave 2 Task 2.1 §6) per-actor current.json
|
|
187
|
+
* partitioning — exactly `resolveActor(env).actor`, or the bare sanitized explicit --actor
|
|
188
|
+
* override, unchanged from before this refactor.
|
|
189
|
+
* - `actorStruct` / `claimActorKey` (`= serializeActor(actorStruct)`): a structured ActorStruct
|
|
190
|
+
* for the assignment-provider claim record's `actor` field (Wave 1's `performLocalClaim`/
|
|
191
|
+
* `performLocalSupersede` require one), and the identity string passed as `computeEffectiveState`'s
|
|
192
|
+
* `selfActor` param. For the common/default case (no explicit --actor override — the ambient
|
|
193
|
+
* runtime-session-id or process-ancestry derivation), this is reconstructed from the SAME
|
|
194
|
+
* exported primitives `resolveActor()` uses internally, so `claimActorKey` reproduces
|
|
195
|
+
* `branchActorKey` bit-for-bit. For an explicit --actor override, no ambient ActorStruct
|
|
196
|
+
* exists (resolveActor()'s override branch is a flat bypass, never a serializeActor() triple),
|
|
197
|
+
* so a synthetic-but-deterministic wrapper is used instead — self-recognition on reentry still
|
|
198
|
+
* holds (same override value always re-derives the same claimActorKey), but this synthetic key
|
|
199
|
+
* will not equal a DIFFERENT process's flat `liveness claim --actor <sameOverrideValue>` actor
|
|
200
|
+
* string (that command's own `--actor` bypass strips the same override value's ':' or wraps it
|
|
201
|
+
* no differently — a pre-existing seam between the liveness-actor and assignment-actor
|
|
202
|
+
* identity domains, not introduced by this task; see Wave 2 Task 2.1 plan Conflict #3).
|
|
203
|
+
*/
|
|
204
|
+
function resolveEnsureSessionActor(p: ReturnType<typeof parseArgs>): { actorStruct: ActorStruct; actorKey: string; branchActorKey: string; unresolved: boolean } {
|
|
205
|
+
const helper = loadActorIdentityHelper();
|
|
206
|
+
const explicitActorRaw = opt(p, "actor", "").trim();
|
|
207
|
+
if (explicitActorRaw && !/[A-Za-z0-9_.-]/.test(explicitActorRaw)) {
|
|
208
|
+
die("ensure-session --actor value strips to empty under the allowed actor charset ([A-Za-z0-9_.-]) — pass a value containing at least one letter, digit, underscore, period, or hyphen.");
|
|
209
|
+
}
|
|
210
|
+
const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
|
|
211
|
+
const resolved = explicitActor ? { actor: explicitActor, source: "explicit-override" } : helper.resolveActor(process.env);
|
|
212
|
+
const unresolved = helper.isUnresolvedActor(resolved.actor);
|
|
213
|
+
const branchActorKey = unresolved ? `unknown-actor-${unknownDisambiguator(resolved.actor)}` : resolved.actor;
|
|
214
|
+
|
|
215
|
+
if (unresolved) {
|
|
216
|
+
// Design Decision 4 (unchanged): never hard-fail session creation on actor ambiguity. No real
|
|
217
|
+
// ActorStruct exists for "unresolved" — the guard (ensureSession) is responsible for skipping
|
|
218
|
+
// the ownership guard entirely when unresolved, rather than claiming under a synthetic identity.
|
|
219
|
+
return { actorStruct: { runtime: "unresolved", session_id: branchActorKey, host: os.hostname() }, actorKey: resolved.actor, branchActorKey, unresolved: true };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const actorStruct: ActorStruct = resolved.source === "explicit-override"
|
|
223
|
+
? { runtime: "explicit-override", session_id: resolved.actor, host: os.hostname() }
|
|
224
|
+
: { runtime: helper.detectRuntime(process.env), session_id: helper.runtimeSessionId(process.env) || (() => { const seed = helper.ancestorActorSeed(); return seed ? `anc-${seed}` : ""; })(), host: os.hostname() };
|
|
225
|
+
const actorKey = helper.serializeActor(actorStruct);
|
|
226
|
+
return { actorStruct, actorKey, branchActorKey, unresolved: false };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Resolve an actor key for a READ-ONLY consumer (current(), currentDir(), recordAgentEvent(),
|
|
231
|
+
* writeTrustBundle()'s default) — deliberately lenient (never
|
|
232
|
+
* dies on a garbage --actor value; that enforcement belongs only to the write/claim path in
|
|
233
|
+
* resolveEnsureSessionActor above). Falls back to resolveActor(process.env) exactly like every
|
|
234
|
+
* write path already does. The returned value is passed straight into
|
|
235
|
+
* scripts/hooks/lib/current-pointer.js's readCurrentPointer(), which already tolerates an empty
|
|
236
|
+
* or unresolved actor by falling straight through to the legacy current.json branch — so an
|
|
237
|
+
* unresolved actor here reproduces exactly today's (pre-#291) legacy-only read behavior.
|
|
238
|
+
*/
|
|
239
|
+
function resolveReadActorKey(p: ReturnType<typeof parseArgs>): string {
|
|
240
|
+
const helper = loadActorIdentityHelper();
|
|
241
|
+
const explicit = opt(p, "actor", "").trim();
|
|
242
|
+
return explicit ? helper.sanitizeSegment(explicit) : helper.resolveActor(process.env).actor;
|
|
243
|
+
}
|
|
244
|
+
|
|
167
245
|
/** Resolve the branch to seed a brand-new session with. Only called from ensureSession's
|
|
168
246
|
* `if (!md)` fresh-creation branch — an existing session's already-recorded branch is never
|
|
169
247
|
* recomputed (ADR 0021 §5 takeover continuity; see Design Decision 3). Precedence: explicit
|
|
@@ -177,13 +255,12 @@ function resolveSessionBranch(p: ReturnType<typeof parseArgs>, slug: string): st
|
|
|
177
255
|
const explicitBranch = opt(p, "branch");
|
|
178
256
|
if (explicitBranch) { validateExplicitBranch(explicitBranch); return explicitBranch; }
|
|
179
257
|
const helper = loadActorIdentityHelper();
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
const safeActor = unresolved ? `unknown-actor-${unknownDisambiguator(actor)}` : sanitizeBranchSegment(actor, helper);
|
|
258
|
+
// #291 Wave 2 Task 2.1: actor resolution is now single-sourced via resolveEnsureSessionActor()
|
|
259
|
+
// (shared with the new ownership guard) — branchActorKey reproduces EXACTLY what this function's
|
|
260
|
+
// own inline `actor` variable used to compute, so branch naming is byte-identical to before this
|
|
261
|
+
// refactor (proven by test_workflow_sidecar_writer.sh's AC2/AC4/AC5 branch-naming assertions).
|
|
262
|
+
const { branchActorKey, unresolved } = resolveEnsureSessionActor(p);
|
|
263
|
+
const safeActor = unresolved ? branchActorKey : sanitizeBranchSegment(branchActorKey, helper);
|
|
187
264
|
if (unresolved) process.stderr.write("[ensure-session] actor unresolved; branch uses \"unknown-actor\" segment (set --actor or FLOW_AGENTS_ACTOR for a stable branch name)\n");
|
|
188
265
|
return `agent/${safeActor}/${sanitizeBranchSegment(slug, helper)}`;
|
|
189
266
|
}
|
|
@@ -444,7 +521,7 @@ function critiqueToEventStatus(verdict: string, findings: AnyObj[]): string | nu
|
|
|
444
521
|
* @param critiques Critique objects (from critique.json .critiques array)
|
|
445
522
|
* @param commandLog Optional parsed command-log.jsonl entries (capture-authoritative fold)
|
|
446
523
|
*/
|
|
447
|
-
export async function buildTrustBundle(slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], commandLog?: AnyObj[], flowAgentsDir?: string): Promise<AnyObj | null> {
|
|
524
|
+
export async function buildTrustBundle(slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], commandLog?: AnyObj[], flowAgentsDir?: string, actorKey?: string): Promise<AnyObj | null> {
|
|
448
525
|
const surface = await tryLoadSurface();
|
|
449
526
|
if (!surface) return null;
|
|
450
527
|
const { deriveClaimStatus, generateClaimId, statusFunctionVersion } = surface;
|
|
@@ -454,7 +531,10 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
454
531
|
// each produced claim gets a DECLARED primary claim (kit-typed) plus a legacy shadow
|
|
455
532
|
// (workflow.* type, claimId suffix "-legacy") for backward compatibility. When null,
|
|
456
533
|
// only the existing workflow.* claims are produced (zero behavior change).
|
|
457
|
-
|
|
534
|
+
// #291 Wave 2 Task 2.1 (§7)/Task 2.2: actorKey (when the caller already resolved one — see
|
|
535
|
+
// writeTrustBundle below) threads through to resolveActiveFlowStep's per-actor-first,
|
|
536
|
+
// legacy-fallback current.json read; omitted, this is IDENTICAL to pre-#291 behavior.
|
|
537
|
+
const activeStep: ActiveFlowStep | null = flowAgentsDir ? resolveActiveFlowStep(flowAgentsDir, actorKey) : null;
|
|
458
538
|
|
|
459
539
|
const claims: AnyObj[] = [];
|
|
460
540
|
const evidenceItems: AnyObj[] = [];
|
|
@@ -618,7 +698,11 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
618
698
|
// (workflow-artifact-cleanup-audit) and validators can detect the promotion claim without a
|
|
619
699
|
// new manifest entry. It rides alongside any waiver in a single merged metadata object.
|
|
620
700
|
const promotionMeta = (check._promotion && typeof check._promotion === "object") ? check._promotion as AnyObj : null;
|
|
621
|
-
|
|
701
|
+
// #268: stamp a stable origin discriminator so checksFromBundle / critiquesFromBundle can
|
|
702
|
+
// distinguish check vs critique vs acceptance claims across round-trips even under --flow-id,
|
|
703
|
+
// where all three collapse onto the same declared claimType (and a command-less critique claim
|
|
704
|
+
// would otherwise be re-absorbed as a test_output check → permanent [not-run] divergence).
|
|
705
|
+
const claimMetadata: AnyObj = { origin: "check", check_kind: String(check.kind ?? "external"), ...(waiver ? { waiver } : {}), ...(promotionMeta ? { promotion: promotionMeta } : {}) };
|
|
622
706
|
|
|
623
707
|
const claimEvents: AnyObj[] = [];
|
|
624
708
|
if (evStatus) {
|
|
@@ -678,12 +762,12 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
678
762
|
if (declared) {
|
|
679
763
|
// Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
|
|
680
764
|
const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
|
|
681
|
-
const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id };
|
|
765
|
+
const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, metadata: { origin: "acceptance" } };
|
|
682
766
|
const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
|
|
683
767
|
claims.push({ ...declaredClaimObj, status: declaredStatus });
|
|
684
768
|
} else {
|
|
685
769
|
// No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
|
|
686
|
-
const claimObj: AnyObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id };
|
|
770
|
+
const claimObj: AnyObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id, metadata: { origin: "acceptance" } };
|
|
687
771
|
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
|
|
688
772
|
claims.push({ ...claimObj, status: derivedStatus });
|
|
689
773
|
}
|
|
@@ -694,10 +778,23 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
694
778
|
if (!c.id) continue;
|
|
695
779
|
const subjectId = `${slug}/${c.id}`;
|
|
696
780
|
const fieldOrBehavior = String(c.summary ?? c.verdict ?? c.id);
|
|
697
|
-
|
|
781
|
+
// #267/#282: a critique carrying `superseded_by` is retained as HISTORY — a prior write for
|
|
782
|
+
// this critique id that a later, same-reviewer critique resolved. It is preserved structurally
|
|
783
|
+
// (status "superseded" + first-class metadata.superseded_by), but is excluded from reconcile
|
|
784
|
+
// evaluation and from the "critique pass cannot include fail members" validator rule.
|
|
785
|
+
const supersededBy = typeof c.superseded_by === "string" && c.superseded_by.length > 0 ? c.superseded_by : null;
|
|
786
|
+
const critiqueReviewer = String(c.reviewer ?? "tool-code-reviewer");
|
|
787
|
+
const critiqueReviewedAt = String(c.reviewed_at ?? ts);
|
|
788
|
+
const critMeta: AnyObj = { origin: "critique", reviewer: critiqueReviewer, reviewed_at: critiqueReviewedAt, ...(supersededBy ? { superseded_by: supersededBy } : {}) };
|
|
789
|
+
// A superseded historical write gets a distinct, stable claimId so it co-exists with the live
|
|
790
|
+
// claim of the same critique id (never overwrites or duplicates it). The salt is reproducible
|
|
791
|
+
// across rebuilds because superseded_by + reviewed_at are preserved in metadata.
|
|
792
|
+
const claimIdSalt = supersededBy ? `${fieldOrBehavior}::superseded::${supersededBy}::${critiqueReviewedAt}` : fieldOrBehavior;
|
|
793
|
+
const claimId = generateClaimId(subjectId, "flow-agents.workflow", claimIdSalt);
|
|
698
794
|
const legacyClaimType = "workflow.critique.review";
|
|
699
795
|
const policy = ensurePolicy(legacyClaimType, "medium", []);
|
|
700
|
-
|
|
796
|
+
// A superseded write emits NO verification event (its status is "superseded" directly).
|
|
797
|
+
const evStatus = supersededBy ? null : critiqueToEventStatus(String(c.verdict ?? ""), c.findings ?? []);
|
|
701
798
|
const claimEvents: AnyObj[] = [];
|
|
702
799
|
if (evStatus) {
|
|
703
800
|
const evt: AnyObj = { id: `evt:${claimId}`, claimId, status: evStatus, actor: "flow-agents/workflow-sidecar", method: "validation", evidenceIds: [], createdAt: ts, verifiedAt: ts };
|
|
@@ -707,16 +804,15 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
707
804
|
|
|
708
805
|
// P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
|
|
709
806
|
const declared = matchExpectsEntry("critique");
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
807
|
+
const claimType = declared ? declared.claimType : legacyClaimType;
|
|
808
|
+
const subjectType = declared ? declared.subjectType : "workflow-critique";
|
|
809
|
+
const claimPolicy = declared ? ensurePolicy(declared.claimType, "medium", []) : policy;
|
|
810
|
+
const claimObj: AnyObj = { id: claimId, subjectType, subjectId, facet: "flow-agents.workflow", claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: claimPolicy.id, metadata: critMeta };
|
|
811
|
+
if (supersededBy) {
|
|
812
|
+
// History: status is "superseded" directly (no verification event); excluded from evaluation.
|
|
813
|
+
claims.push({ ...claimObj, status: "superseded" });
|
|
716
814
|
} else {
|
|
717
|
-
|
|
718
|
-
const claimObj: AnyObj = { id: claimId, subjectType: "workflow-critique", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: policy.id };
|
|
719
|
-
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
|
|
815
|
+
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [claimPolicy] as Record<string, unknown>[] });
|
|
720
816
|
claims.push({ ...claimObj, status: derivedStatus });
|
|
721
817
|
}
|
|
722
818
|
}
|
|
@@ -744,7 +840,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
744
840
|
* @param criteria Acceptance criteria objects (same as buildTrustBundle)
|
|
745
841
|
* @param critiques Critique objects (same as buildTrustBundle)
|
|
746
842
|
*/
|
|
747
|
-
export async function writeTrustBundle(dir: string, slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[]): Promise<{ written: boolean; errors: string[] }> {
|
|
843
|
+
export async function writeTrustBundle(dir: string, slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], actorKey?: string): Promise<{ written: boolean; errors: string[] }> {
|
|
748
844
|
try {
|
|
749
845
|
// Fold the deterministic capture log (PostToolUse evidence-capture) into the
|
|
750
846
|
// bundle so capture is authoritative over claimed status. Best-effort read.
|
|
@@ -756,16 +852,22 @@ export async function writeTrustBundle(dir: string, slug: string, timestamp: str
|
|
|
756
852
|
// ADR 0016 Abstraction A (P-d): pass the runtime artifact root ONLY when current.json
|
|
757
853
|
// points to this session (scoped active-flow guard). If current.json.artifact_dir
|
|
758
854
|
// resolves to a different session, pass null — no active-flow claim mapping for this bundle.
|
|
855
|
+
// #291 Wave 2 Task 2.1 (§7): the SOURCE of "what does current.json say" now prefers the
|
|
856
|
+
// resolved actor's own per-actor projection (falling back to the legacy global file) via the
|
|
857
|
+
// shared readCurrentPointer() helper — the "does artifact_dir match dir?" comparison itself is
|
|
858
|
+
// UNCHANGED. actorKey defaults to resolveActor(process.env) when the caller (below) does not
|
|
859
|
+
// already have one resolved, exactly like every other read-path consumer in this file.
|
|
759
860
|
const _flowAgentsDir = path.dirname(dir);
|
|
861
|
+
const _effectiveActorKey = actorKey ?? loadActorIdentityHelper().resolveActor(process.env).actor;
|
|
760
862
|
let _scopedFlowAgentsDir: string | undefined = undefined;
|
|
761
863
|
try {
|
|
762
|
-
const _currentRaw =
|
|
763
|
-
const _artDir = typeof _currentRaw["artifact_dir"] === "string" ? _currentRaw["artifact_dir"] : null;
|
|
864
|
+
const _currentRaw = loadCurrentPointerHelper().readCurrentPointer(_flowAgentsDir, _effectiveActorKey).payload;
|
|
865
|
+
const _artDir = _currentRaw && typeof _currentRaw["artifact_dir"] === "string" ? (_currentRaw["artifact_dir"] as string) : null;
|
|
764
866
|
if (_artDir && path.resolve(_flowAgentsDir, _artDir) === path.resolve(dir)) {
|
|
765
867
|
_scopedFlowAgentsDir = _flowAgentsDir;
|
|
766
868
|
}
|
|
767
869
|
} catch { /* current.json absent or unreadable — no scoping */ }
|
|
768
|
-
const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir);
|
|
870
|
+
const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir, _effectiveActorKey);
|
|
769
871
|
if (!bundle) return { written: false, errors: [] }; // Surface unavailable — fail-open, skip write
|
|
770
872
|
const result = await validateTrustBundle(bundle);
|
|
771
873
|
if (result.available && !result.valid) {
|
|
@@ -1079,12 +1181,43 @@ function resolveFirstStep(flowId: string, repoRoot: string): string | null {
|
|
|
1079
1181
|
}
|
|
1080
1182
|
}
|
|
1081
1183
|
|
|
1082
|
-
|
|
1184
|
+
/**
|
|
1185
|
+
* Delegate to the shared pure-CJS per-actor current-pointer helper
|
|
1186
|
+
* (scripts/hooks/lib/current-pointer.js), mirroring the createRequire pattern already used above
|
|
1187
|
+
* by loadActorIdentityHelper()/loadLivenessWriteHelper() for cross-boundary CJS helper reuse
|
|
1188
|
+
* (#291 Wave 2 Task 2.1). This is the ONE place every current.json reader/writer in this file
|
|
1189
|
+
* goes through from here on — the per-actor-first/legacy-fallback compat-shim rule (and its
|
|
1190
|
+
* write-side counterpart) can never drift between call sites.
|
|
1191
|
+
*/
|
|
1192
|
+
function loadCurrentPointerHelper(): {
|
|
1193
|
+
perActorCurrentFile: (flowAgentsDir: string, actorKey: string) => string;
|
|
1194
|
+
readCurrentPointer: (flowAgentsDir: string, actorKey?: string) => { payload: AnyObj | null; source: "per-actor" | "legacy" | "none"; file: string | null };
|
|
1195
|
+
writePerActorCurrent: (flowAgentsDir: string, actorKey: string, payload: AnyObj) => void;
|
|
1196
|
+
} {
|
|
1197
|
+
const _req = createRequire(import.meta.url);
|
|
1198
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/current-pointer.js");
|
|
1199
|
+
return _req(helperPath) as {
|
|
1200
|
+
perActorCurrentFile: (flowAgentsDir: string, actorKey: string) => string;
|
|
1201
|
+
readCurrentPointer: (flowAgentsDir: string, actorKey?: string) => { payload: AnyObj | null; source: "per-actor" | "legacy" | "none"; file: string | null };
|
|
1202
|
+
writePerActorCurrent: (flowAgentsDir: string, actorKey: string, payload: AnyObj) => void;
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* #291 Wave 2 Task 2.1 (§5): writes the UNCHANGED legacy global `<root>/current.json` (the
|
|
1208
|
+
* compat-shim's write-side half — every existing consumer without an actorKey keeps reading
|
|
1209
|
+
* exactly this file, exactly as before this change), then ADDITIONALLY projects the SAME payload
|
|
1210
|
+
* into the caller's own `current/<actor>.json` when `actorKey` resolves to a real (not
|
|
1211
|
+
* unresolved) actor. When `actorKey` is omitted or unresolved, only the legacy file is written —
|
|
1212
|
+
* byte-identical to this function's pre-#291 behavior — with a stderr note mirroring the existing
|
|
1213
|
+
* unresolved-actor branch-naming diagnostic.
|
|
1214
|
+
*/
|
|
1215
|
+
function writeCurrent(root: string, dir: string, timestamp: string, owner: string, source: string, flowId?: string, stepId?: string, adHocReason?: string, actorKey?: string): void {
|
|
1083
1216
|
// #289: mirror the active session's already-recorded branch (state.json.branch) into
|
|
1084
1217
|
// current.json so consumers of current.json (which has no schema of its own — not one of the
|
|
1085
1218
|
// 9 schemas under schemas/) see the routing branch without re-reading state.json separately.
|
|
1086
1219
|
const branch = loadJson(path.join(dir, "state.json")).branch;
|
|
1087
|
-
|
|
1220
|
+
const payload: AnyObj = {
|
|
1088
1221
|
schema_version: "1.0",
|
|
1089
1222
|
active_slug: path.basename(dir),
|
|
1090
1223
|
artifact_dir: path.relative(root, dir) || ".",
|
|
@@ -1103,15 +1236,35 @@ function writeCurrent(root: string, dir: string, timestamp: string, owner: strin
|
|
|
1103
1236
|
// gate-review can distinguish an intentional direct entry (e.g. a planning-only
|
|
1104
1237
|
// session that skips pull-work) from a stale/mis-stamped active_step_id.
|
|
1105
1238
|
...(adHocReason ? { ad_hoc_entry: true, ad_hoc_reason: adHocReason } : {}),
|
|
1106
|
-
}
|
|
1239
|
+
};
|
|
1240
|
+
writeJson(path.join(root, "current.json"), payload);
|
|
1241
|
+
if (actorKey && !loadActorIdentityHelper().isUnresolvedActor(actorKey)) {
|
|
1242
|
+
try {
|
|
1243
|
+
loadCurrentPointerHelper().writePerActorCurrent(root, actorKey, payload);
|
|
1244
|
+
} catch (err) {
|
|
1245
|
+
// Best-effort projection only — the legacy file above is already durable and authoritative;
|
|
1246
|
+
// a failure here must never affect ensure-session's own exit code (fail-open, visible).
|
|
1247
|
+
process.stderr.write(`[ensure-session] failed to write per-actor current pointer: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1248
|
+
}
|
|
1249
|
+
} else {
|
|
1250
|
+
process.stderr.write("[ensure-session] actor unresolved or not provided; per-actor current.json projection skipped (legacy current.json remains authoritative)\n");
|
|
1251
|
+
}
|
|
1107
1252
|
}
|
|
1108
1253
|
function loadCurrent(root: string): AnyObj | null {
|
|
1109
1254
|
const file = path.join(root, "current.json");
|
|
1110
1255
|
if (!fs.existsSync(file)) return null;
|
|
1111
1256
|
return JSON.parse(read(file));
|
|
1112
1257
|
}
|
|
1113
|
-
|
|
1114
|
-
|
|
1258
|
+
/**
|
|
1259
|
+
* #291 Wave 2 Task 2.1 (§6): resolves the active session directory via the shared
|
|
1260
|
+
* per-actor-first/legacy-fallback compat shim (readCurrentPointer) instead of the unconditional
|
|
1261
|
+
* legacy-only loadCurrent() this function used before #291. When `actorKey` is omitted, empty, or
|
|
1262
|
+
* unresolved, `readCurrentPointer` falls straight through to the legacy file — IDENTICAL to this
|
|
1263
|
+
* function's pre-#291 behavior.
|
|
1264
|
+
*/
|
|
1265
|
+
function currentDir(root: string, actorKey?: string): string | null {
|
|
1266
|
+
const pointer = loadCurrentPointerHelper().readCurrentPointer(root, actorKey);
|
|
1267
|
+
const c = pointer.payload;
|
|
1115
1268
|
if (!c) return null;
|
|
1116
1269
|
const dir = path.resolve(root, c.artifact_dir ?? c.active_slug ?? "");
|
|
1117
1270
|
if (!fs.existsSync(dir)) return null;
|
|
@@ -1122,14 +1275,34 @@ function currentDir(root: string): string | null {
|
|
|
1122
1275
|
}
|
|
1123
1276
|
return dir;
|
|
1124
1277
|
}
|
|
1125
|
-
|
|
1278
|
+
/**
|
|
1279
|
+
* #291 Wave 2 Task 2.1 (§6): updates BOTH the legacy current.json (when IT points at `dir` — the
|
|
1280
|
+
* exact, unchanged existing check/write) AND the resolved actor's own per-actor current.json
|
|
1281
|
+
* (independently, when IT points at `dir`) — never silently drops the legacy-file update path.
|
|
1282
|
+
* The two checks are independent: either, both, or neither may fire depending on which
|
|
1283
|
+
* pointer(s) currently reference `dir`.
|
|
1284
|
+
*/
|
|
1285
|
+
function updateCurrentAgent(root: string, dir: string, agentId: string, status: string, timestamp: string, actorKey?: string): void {
|
|
1286
|
+
const applyAgentUpdate = (payload: AnyObj): AnyObj => {
|
|
1287
|
+
const active = Array.isArray(payload.active_agents) ? payload.active_agents.filter((a: AnyObj) => a.agent_id !== agentId) : [];
|
|
1288
|
+
if (status === "active" || status === "blocked") active.push({ agent_id: agentId, status, updated_at: timestamp });
|
|
1289
|
+
return { ...payload, active_agents: active, updated_at: timestamp };
|
|
1290
|
+
};
|
|
1291
|
+
|
|
1126
1292
|
const cur = loadCurrent(root);
|
|
1127
|
-
if (
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1293
|
+
if (cur && path.resolve(root, cur.artifact_dir ?? "") === path.resolve(dir)) {
|
|
1294
|
+
writeJson(path.join(root, "current.json"), applyAgentUpdate(cur));
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
if (actorKey && !loadActorIdentityHelper().isUnresolvedActor(actorKey)) {
|
|
1298
|
+
const helper = loadCurrentPointerHelper();
|
|
1299
|
+
const perActorFile = helper.perActorCurrentFile(root, actorKey);
|
|
1300
|
+
let perActor: AnyObj | null = null;
|
|
1301
|
+
try { perActor = fs.existsSync(perActorFile) ? (JSON.parse(fs.readFileSync(perActorFile, "utf8")) as AnyObj) : null; } catch { perActor = null; }
|
|
1302
|
+
if (perActor && path.resolve(root, perActor.artifact_dir ?? "") === path.resolve(dir)) {
|
|
1303
|
+
helper.writePerActorCurrent(root, actorKey, applyAgentUpdate(perActor));
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1133
1306
|
}
|
|
1134
1307
|
|
|
1135
1308
|
function initSidecars(dir: string, slug: string, sourceRequest: string, summary: string, nextAction: string, timestamp: string, markdown?: string): void {
|
|
@@ -1179,10 +1352,201 @@ function initSidecars(dir: string, slug: string, sourceRequest: string, summary:
|
|
|
1179
1352
|
});
|
|
1180
1353
|
}
|
|
1181
1354
|
|
|
1355
|
+
/** Read a `--*-json` flag's value as a file path (or `-` for stdin), mirroring
|
|
1356
|
+
* assignment-provider.ts's own `loadJsonInput` convention — this file's OTHER `--*-json` flags
|
|
1357
|
+
* (e.g. `--check-json`) instead take a literal inline JSON string via parseJson(), a DIFFERENT
|
|
1358
|
+
* convention; `--effective-state-json` deliberately follows assignment-provider's file/stdin
|
|
1359
|
+
* convention instead, since its value is the literal JSON `assignment-provider status
|
|
1360
|
+
* --provider github ...` already prints to a file (or pipe), not something a caller would want to
|
|
1361
|
+
* inline as a shell argument. */
|
|
1362
|
+
function loadJsonInputFile(file: string): unknown {
|
|
1363
|
+
return file === "-" ? JSON.parse(fs.readFileSync(0, "utf8")) : JSON.parse(read(file));
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/**
|
|
1367
|
+
* #291 Wave 2 Task 2.1 (§4): ensure-session's pre-entry ownership guard. MUST be called before
|
|
1368
|
+
* any directory or file is created (a refusal must never leave a stray empty session dir) —
|
|
1369
|
+
* ensureSession() calls this immediately, before `fs.mkdirSync(dir, ...)`. Reuses #290's
|
|
1370
|
+
* assignment ⋈ liveness join (`computeEffectiveState`, Wave 1 export) so a fresh OTHER-actor claim
|
|
1371
|
+
* refuses entry, a human assignment always asks first (never auto-reclaims), a stale
|
|
1372
|
+
* (`reclaimable`) claim requires the explicit `--supersede-stale` takeover flag, and a `free`
|
|
1373
|
+
* subject establishes a durable claim for the entering actor — ensure-session becomes a SECOND
|
|
1374
|
+
* claim point, alongside pull-work (closing the loophole where a session entered without going
|
|
1375
|
+
* through pull-work never gets a durable claim at all).
|
|
1376
|
+
*
|
|
1377
|
+
* Runs INSIDE ensureSession's existing root-level `withLock` (main(), unchanged) — no second/
|
|
1378
|
+
* competing lock is introduced here. `performLocalClaim`/`performLocalSupersede`'s OWN internal
|
|
1379
|
+
* `withSubjectLock` (a DIFFERENT lockdir, under `<root>/assignment/.<subject>.lockdir`) is what
|
|
1380
|
+
* protects against a concurrent bare `assignment-provider claim` CLI invocation (e.g. from
|
|
1381
|
+
* pull-work) racing this guard — two independent, non-conflicting lock resources.
|
|
1382
|
+
*
|
|
1383
|
+
* Every interpolated actor/holder/assignee/reason field in this function's die() messages is run
|
|
1384
|
+
* through stripControlCharsForDisplay + a 64-char cap (AC9 — the #287/#320/#290 prompt-injection
|
|
1385
|
+
* mitigation class: a hostile liveness event or a hand-crafted --effective-state-json fixture must
|
|
1386
|
+
* never be able to smuggle raw control/ANSI bytes into this process's stderr/thrown message).
|
|
1387
|
+
*/
|
|
1388
|
+
function enforceEnsureSessionOwnership(
|
|
1389
|
+
p: ReturnType<typeof parseArgs>,
|
|
1390
|
+
root: string,
|
|
1391
|
+
slug: string,
|
|
1392
|
+
dir: string,
|
|
1393
|
+
resolution: { actorStruct: ActorStruct; actorKey: string; branchActorKey: string; unresolved: boolean },
|
|
1394
|
+
): void {
|
|
1395
|
+
if (p.flags.has("skip-ownership-guard")) {
|
|
1396
|
+
process.stderr.write("[ensure-session] ownership guard skipped via --skip-ownership-guard\n");
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
// Design Decision 4 (unchanged from resolveSessionBranch): actor-resolution ambiguity never
|
|
1400
|
+
// hard-fails session creation. Without a resolvable actor there is no safe identity to claim
|
|
1401
|
+
// under, so the guard is skipped entirely (documented scope boundary, not a silent hole) rather
|
|
1402
|
+
// than claiming under a synthetic/unstable identity.
|
|
1403
|
+
if (resolution.unresolved) {
|
|
1404
|
+
process.stderr.write("[ensure-session] ownership guard not evaluated: actor is unresolved (set --actor or FLOW_AGENTS_ACTOR, or run inside a supported runtime) — proceeding without a durable claim, exactly as ensure-session behaved before #291\n");
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// F5 fix (fix-plan iteration 1, LOW): match assignment-provider.ts's sanitizeDisplayField
|
|
1409
|
+
// two-tier convention (64 for id-like fields, 240 for free text) rather than asserting one
|
|
1410
|
+
// uniform cap applies to every field class regardless of content. `sanitize` below is 64 chars —
|
|
1411
|
+
// deliberately, not by uniform-cap default — because every value this function actually
|
|
1412
|
+
// interpolates into a die()/stderr message is id-like (holder/actor key, subject slug, assignee,
|
|
1413
|
+
// a claimed_at/last_at timestamp, the provider-kind enum); none of them is free text, so the
|
|
1414
|
+
// id-like tier is the correct (not merely convenient) cap here. sanitizeDisplayField's 240
|
|
1415
|
+
// free-text tier is for fields like a claim record's audit-trail `reason` (see
|
|
1416
|
+
// assignment-provider.ts's sanitizeAuditEntryForDisplay) — this guard never echoes `--reason`
|
|
1417
|
+
// into a die() message, so it has no free-text field requiring the 240 tier today.
|
|
1418
|
+
const sanitize = (value: unknown): string => stripControlCharsForDisplay(value).slice(0, 64);
|
|
1419
|
+
const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : Date.now();
|
|
1420
|
+
const assignmentProviderKind = opt(p, "assignment-provider", "local-file");
|
|
1421
|
+
|
|
1422
|
+
type EffectiveResult = { effective_state: EffectiveState; reason: string; holder?: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string } };
|
|
1423
|
+
let effective: EffectiveResult;
|
|
1424
|
+
|
|
1425
|
+
const effectiveStateJsonFlag = opt(p, "effective-state-json");
|
|
1426
|
+
if (effectiveStateJsonFlag) {
|
|
1427
|
+
const parsed = loadJsonInputFile(effectiveStateJsonFlag) as AnyObj;
|
|
1428
|
+
const candidate = parsed && typeof parsed === "object" ? (parsed.effective as AnyObj | undefined) : undefined;
|
|
1429
|
+
const validStates = new Set(["held", "reclaimable", "human-held", "free"]);
|
|
1430
|
+
if (!candidate || typeof candidate.effective_state !== "string" || !validStates.has(candidate.effective_state)) {
|
|
1431
|
+
die(`ensure-session --effective-state-json must contain an .effective object with a recognized effective_state (held|reclaimable|human-held|free); got ${JSON.stringify(candidate ? candidate.effective_state : candidate)}`);
|
|
1432
|
+
}
|
|
1433
|
+
effective = candidate as EffectiveResult;
|
|
1434
|
+
} else if (assignmentProviderKind === "local-file") {
|
|
1435
|
+
// Conflict #3 (plan): subjectId for BOTH the assignment lookup and the liveness freshHolders
|
|
1436
|
+
// slug filter is `slug` — the ALREADY-COMPUTED session slug (workItemSlug()'s output), the
|
|
1437
|
+
// exact same identifier pull-work itself uses for both halves of this same join. Never a
|
|
1438
|
+
// second, independently-derived identifier.
|
|
1439
|
+
// F1 fix (fix-plan iteration 1, HIGH): the guard's own self-check and the liveness join must
|
|
1440
|
+
// key on `resolution.branchActorKey` — the canonical `resolveActor(env).actor` string — NOT
|
|
1441
|
+
// `resolution.actorKey` (`serializeActor(actorStruct)`). For an explicit-override actor
|
|
1442
|
+
// (FLOW_AGENTS_ACTOR) those two diverge: `actorKey` serializes to a triple
|
|
1443
|
+
// (`explicit-override:<value>:<host>`) while `branchActorKey` is the bare value every other
|
|
1444
|
+
// tool (`liveness whoami`, `liveness claim --actor`, per-actor current.json, pull-work's
|
|
1445
|
+
// --self-actor) already uses. freshHolders' `selfActor` param and computeEffectiveState's
|
|
1446
|
+
// `selfActor` param must both be the same canonical string the claim record's `actor_key` is
|
|
1447
|
+
// written as (see the performLocalClaim/performLocalSupersede calls below) — otherwise a
|
|
1448
|
+
// fresh heartbeat for this exact actor would never match the join, and
|
|
1449
|
+
// `assignment-provider status --self-actor <branchActorKey>` run by a DIFFERENT tool
|
|
1450
|
+
// afterward would never recognize this guard's own claim as self.
|
|
1451
|
+
const assignment = readLocalAssignmentStatus(root, slug);
|
|
1452
|
+
const events = readLivenessEvents(root);
|
|
1453
|
+
const freshList = loadLivenessReadHelper().freshHolders(events, slug, resolution.branchActorKey, nowMs);
|
|
1454
|
+
effective = computeEffectiveState(assignment, freshList, resolution.branchActorKey, nowMs);
|
|
1455
|
+
} else {
|
|
1456
|
+
// Conflict #5 (plan): GitHub-provider (and any other non-local-file) subjects get no LIVE
|
|
1457
|
+
// guard check here — assignment-provider.ts is deliberately render-don't-execute (no `gh`
|
|
1458
|
+
// calls from any CLI file). A precomputed --effective-state-json is the escape hatch; when
|
|
1459
|
+
// neither applies, this is a documented scope boundary (today's pre-#291 baseline behavior),
|
|
1460
|
+
// never a silent hole.
|
|
1461
|
+
process.stderr.write(`[ensure-session] ownership guard not evaluated: provider "${sanitize(assignmentProviderKind)}" requires --effective-state-json\n`);
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
const resolveBranchForClaim = (): string => {
|
|
1466
|
+
const existingBranch = fs.existsSync(path.join(dir, "state.json")) ? (loadJson(path.join(dir, "state.json")).branch as string | undefined) : undefined;
|
|
1467
|
+
return existingBranch || resolveSessionBranch(p, slug);
|
|
1468
|
+
};
|
|
1469
|
+
|
|
1470
|
+
switch (effective.effective_state) {
|
|
1471
|
+
case "held": {
|
|
1472
|
+
// F1 fix (fix-plan iteration 1, HIGH): compare against branchActorKey — the same canonical
|
|
1473
|
+
// string just passed as computeEffectiveState's selfActor above — not actorKey (the wrapped
|
|
1474
|
+
// ActorStruct triple), so this redundant belt-and-suspenders check agrees with the
|
|
1475
|
+
// effective_state computation instead of silently using a different identity.
|
|
1476
|
+
const isSelf = effective.reason === "self_is_holder" || (!!effective.holder?.actor && effective.holder.actor === resolution.branchActorKey);
|
|
1477
|
+
if (isSelf) return; // resume own session — no refusal
|
|
1478
|
+
const holderActor = sanitize(effective.holder?.actor ?? "unknown");
|
|
1479
|
+
const lastAtSuffix = effective.holder?.last_at ? ` (last_at ${sanitize(effective.holder.last_at)})` : "";
|
|
1480
|
+
die(`ensure-session refused: subject ${sanitize(slug)} is currently held by a different, still-live actor (${holderActor}${lastAtSuffix}). Pick a different work item, or confirm the holder session is truly gone before considering a takeover.`);
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
case "human-held": {
|
|
1484
|
+
const assignee = effective.holder?.assignee ? sanitize(effective.holder.assignee) : "an assigned human";
|
|
1485
|
+
const idleSuffix = effective.holder?.idle_days != null ? ` (idle ${Number(effective.holder.idle_days)} day(s))` : "";
|
|
1486
|
+
die(`ensure-session refused: subject ${sanitize(slug)} is assigned to ${assignee}${idleSuffix}. This guard never auto-reclaims a human assignment — confirm with the user before proceeding.`);
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
case "reclaimable": {
|
|
1490
|
+
const holderActor = sanitize(effective.holder?.actor ?? "unknown");
|
|
1491
|
+
const claimedAtSuffix = effective.holder?.last_at ? ` (claimed_at ${sanitize(effective.holder.last_at)})` : "";
|
|
1492
|
+
if (!p.flags.has("supersede-stale")) {
|
|
1493
|
+
die(`ensure-session refused: subject ${sanitize(slug)}'s existing claim (held by ${holderActor}${claimedAtSuffix}) is stale. Pass --supersede-stale to take it over explicitly.`);
|
|
1494
|
+
}
|
|
1495
|
+
const assignment = readLocalAssignmentStatus(root, slug);
|
|
1496
|
+
const fromActor = assignment.record?.actor;
|
|
1497
|
+
if (!fromActor) die(`ensure-session --supersede-stale: no existing local-file claim record found for subject ${sanitize(slug)} to supersede`);
|
|
1498
|
+
performLocalSupersede(root, slug, fromActor, resolution.actorStruct, {
|
|
1499
|
+
branch: resolveBranchForClaim(),
|
|
1500
|
+
artifactDir: path.relative(root, dir) || ".",
|
|
1501
|
+
reason: opt(p, "reason", "ensure-session takeover: stale claim"),
|
|
1502
|
+
// F1 fix (fix-plan iteration 1, HIGH): persist the canonical actor_key on the record so
|
|
1503
|
+
// computeEffectiveState's holderActorKey (assignment-provider.ts) matches this same
|
|
1504
|
+
// branchActorKey string on the next status/guard check, cross-tool.
|
|
1505
|
+
actorKey: resolution.branchActorKey,
|
|
1506
|
+
});
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
case "free": {
|
|
1510
|
+
performLocalClaim(root, slug, resolution.actorStruct, {
|
|
1511
|
+
ttlSeconds: opt(p, "claim-ttl-seconds") ? Number(opt(p, "claim-ttl-seconds")) : loadLivenessPolicyHelper().resolveTtlSeconds(process.env),
|
|
1512
|
+
branch: resolveBranchForClaim(),
|
|
1513
|
+
artifactDir: path.relative(root, dir) || ".",
|
|
1514
|
+
reason: opt(p, "reason", "ensure-session entry"),
|
|
1515
|
+
// F1 fix (fix-plan iteration 1, HIGH): see the performLocalSupersede call above.
|
|
1516
|
+
actorKey: resolution.branchActorKey,
|
|
1517
|
+
});
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
default:
|
|
1521
|
+
die(`ensure-session ownership guard: unrecognized effective_state ${JSON.stringify(effective.effective_state)}`);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* ensure-session flags added by #291 Wave 2 Task 2.1 (no printed --help/usage text exists in this
|
|
1527
|
+
* file to update — this doc comment is the discoverable reference instead):
|
|
1528
|
+
* --skip-ownership-guard Skip the pre-entry ownership guard entirely (logged, not silent).
|
|
1529
|
+
* --effective-state-json <path|-> Precomputed `{ effective: {...} }` JSON (the exact shape
|
|
1530
|
+
* `assignment-provider status` prints) — required to evaluate the
|
|
1531
|
+
* guard for any --assignment-provider other than local-file.
|
|
1532
|
+
* --assignment-provider <kind> Defaults to "local-file"; see Conflict #5 in the plan for the
|
|
1533
|
+
* GitHub-provider scope boundary.
|
|
1534
|
+
* --now <iso> Deterministic "now" for freshness/idle-day computation (else
|
|
1535
|
+
* Date.now()).
|
|
1536
|
+
* --supersede-stale Required to take over a `reclaimable` (stale) claim.
|
|
1537
|
+
* --claim-ttl-seconds <n> Overrides the liveness-policy TTL default for a new claim.
|
|
1538
|
+
* --reason <text> Audit-trail reason recorded on the claim/supersede record.
|
|
1539
|
+
*/
|
|
1182
1540
|
function ensureSession(p: ReturnType<typeof parseArgs>): number {
|
|
1183
1541
|
const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
|
|
1184
1542
|
const slug = opt(p, "task-slug") || (opt(p, "work-item") ? workItemSlug(opt(p, "work-item")) : die("--task-slug is required (or pass --work-item to derive it)"));
|
|
1185
1543
|
const dir = sessionDirFor(root, slug);
|
|
1544
|
+
// #291 Wave 2 Task 2.1 (§3, §4): resolve the actor ONCE, then run the ownership guard BEFORE
|
|
1545
|
+
// any directory/file is created — a refusal must never leave a stray empty session dir. Reused
|
|
1546
|
+
// below (writeCurrent's per-actor dual-write) so the branch-naming actor and the
|
|
1547
|
+
// assignment-claim actor are always the same identity.
|
|
1548
|
+
const actorResolution = resolveEnsureSessionActor(p);
|
|
1549
|
+
enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution);
|
|
1186
1550
|
fs.mkdirSync(dir, { recursive: true });
|
|
1187
1551
|
const timestamp = opt(p, "timestamp", now());
|
|
1188
1552
|
let md = fs.existsSync(path.join(dir, `${slug}--deliver.md`)) ? read(path.join(dir, `${slug}--deliver.md`)) : "";
|
|
@@ -1225,14 +1589,19 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
|
|
|
1225
1589
|
adHocReason = opt(p, "ad-hoc-reason") || `direct entry at step "${explicitStep}" via --step-id (flow first step is "${firstStep}")`;
|
|
1226
1590
|
}
|
|
1227
1591
|
}
|
|
1228
|
-
writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined, adHocReason);
|
|
1592
|
+
writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined, adHocReason, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
|
|
1229
1593
|
console.log(dir);
|
|
1230
1594
|
return 0;
|
|
1231
1595
|
}
|
|
1232
1596
|
|
|
1233
1597
|
function current(p: ReturnType<typeof parseArgs>): number {
|
|
1234
1598
|
const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : defaultArtifactRootForRead();
|
|
1235
|
-
|
|
1599
|
+
// #291 Wave 2 Task 2.1 (§6): new optional --actor override (same convention as
|
|
1600
|
+
// resolveSessionBranch's existing --actor), falling back to resolveActor(process.env). Only the
|
|
1601
|
+
// SOURCE of "what does current.json say" changes (per-actor-first, legacy-fallback) — this
|
|
1602
|
+
// command's existing --format slug|path output is unchanged.
|
|
1603
|
+
const actorKey = resolveReadActorKey(p);
|
|
1604
|
+
const dir = currentDir(root, actorKey);
|
|
1236
1605
|
if (!dir) die("no current workflow session is recorded");
|
|
1237
1606
|
const format = opt(p, "format", "path");
|
|
1238
1607
|
console.log(format === "slug" ? path.basename(dir) : dir);
|
|
@@ -1243,7 +1612,8 @@ function recordAgentEvent(p: ReturnType<typeof parseArgs>): number {
|
|
|
1243
1612
|
const hasExplicitRoot = !!opt(p, "artifact-root");
|
|
1244
1613
|
const root = explicitArtifactRoot(p);
|
|
1245
1614
|
const explicit = opt(p, "artifact-dir");
|
|
1246
|
-
const
|
|
1615
|
+
const actorKey = resolveReadActorKey(p);
|
|
1616
|
+
const dir = explicit ? path.resolve(explicit) : currentDir(root, actorKey);
|
|
1247
1617
|
if (!dir || !fs.existsSync(dir)) die("artifact directory does not exist");
|
|
1248
1618
|
if (explicit && fs.lstatSync(dir).isSymbolicLink()) die(`artifact directory must not be a symlink: ${dir}`);
|
|
1249
1619
|
if (hasExplicitRoot || !explicit) requireArtifactDirUnderRoot(dir, root);
|
|
@@ -1251,7 +1621,7 @@ function recordAgentEvent(p: ReturnType<typeof parseArgs>): number {
|
|
|
1251
1621
|
const agent = validateAgentId(opt(p, "agent-id"));
|
|
1252
1622
|
const event = { timestamp, agent_id: agent, kind: opt(p, "kind", "note"), status: opt(p, "status", "info"), summary: opt(p, "summary"), ...(opt(p, "ref") ? { ref: opt(p, "ref") } : {}) };
|
|
1253
1623
|
appendJsonl(path.join(dir, "agents", agent, "events.jsonl"), event);
|
|
1254
|
-
updateCurrentAgent(root, dir, agent, event.status, timestamp);
|
|
1624
|
+
updateCurrentAgent(root, dir, agent, event.status, timestamp, actorKey);
|
|
1255
1625
|
return 0;
|
|
1256
1626
|
}
|
|
1257
1627
|
|
|
@@ -1439,96 +1809,99 @@ export function writeState(dir: string, slug: string, status: string, phase: str
|
|
|
1439
1809
|
// Extract checks and critiques from the existing trust.bundle for callers that
|
|
1440
1810
|
// need to rebuild the bundle (e.g. record-critique, record-learning).
|
|
1441
1811
|
|
|
1442
|
-
//
|
|
1443
|
-
//
|
|
1444
|
-
//
|
|
1445
|
-
//
|
|
1446
|
-
// is active, the set contains the kit-typed claimTypes (e.g. "builder.verify.tests",
|
|
1447
|
-
// "builder.verify.policy-compliance") so round-trip helpers broaden their filters
|
|
1448
|
-
// to include declared claims alongside the legacy workflow.* ones.
|
|
1812
|
+
// #268/#344: buildTrustBundle stamps a stable origin discriminator ("check" | "acceptance" |
|
|
1813
|
+
// "critique") plus check_kind (for origin "check") on EVERY claim it writes. These stamps are
|
|
1814
|
+
// AUTHORITATIVE and the ONLY way checksFromBundle/critiquesFromBundle (and evidenceClean/
|
|
1815
|
+
// critiqueClean below) classify a claim.
|
|
1449
1816
|
//
|
|
1450
|
-
//
|
|
1451
|
-
//
|
|
1452
|
-
//
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1455
|
-
//
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1817
|
+
// Hard cutover (owner directive, no legacy fallbacks): there is deliberately no claimType-
|
|
1818
|
+
// derivation fallback for unstamped claims. A prior version of this file fell back to
|
|
1819
|
+
// classifying an unstamped claim by claimType heuristic — that fallback WAS the #268 defect
|
|
1820
|
+
// kept reachable (a no-evidence declared claim silently re-absorbed as a command-less
|
|
1821
|
+
// test_output check, and a critique claim silently re-absorbed as a check, corrupting the
|
|
1822
|
+
// round-trip catastrophically under --flow-id). An unstamped claim means the bundle predates
|
|
1823
|
+
// #344 and must be regenerated, not silently reclassified — see requireStampedClaim below.
|
|
1824
|
+
function claimOrigin(claim: AnyObj): string | null {
|
|
1825
|
+
const md = claim && (claim as AnyObj).metadata;
|
|
1826
|
+
return md && typeof md === "object" && typeof (md as AnyObj).origin === "string" && (md as AnyObj).origin.length > 0 ? String((md as AnyObj).origin) : null;
|
|
1827
|
+
}
|
|
1828
|
+
// Fails loud — never silent, never a heuristic reclassification — when a claim in `dir`'s
|
|
1829
|
+
// trust.bundle lacks its metadata.origin stamp, or (for an origin==="check" claim) its
|
|
1830
|
+
// metadata.check_kind stamp. Names the session dir and the remedy so the caller can regenerate
|
|
1831
|
+
// a fresh, fully-stamped bundle instead of reading a pre-supersession one.
|
|
1832
|
+
function requireStampedClaim(claim: AnyObj, dir: string): string {
|
|
1833
|
+
if (!claim || typeof claim !== "object") die(`trust.bundle in ${dir} contains a malformed claim entry — cannot read.`);
|
|
1834
|
+
const remedy = `re-record evidence to regenerate: npm run workflow:sidecar -- record-evidence ${dir} --verdict <verdict> --check-json <...>`;
|
|
1835
|
+
const origin = claimOrigin(claim);
|
|
1836
|
+
if (!origin) {
|
|
1837
|
+
die(`pre-supersession trust.bundle in ${dir}: claim '${claim.id ?? "<unknown>"}' has no metadata.origin stamp (this bundle predates #344's origin/check_kind stamping and cannot be read authoritatively) — ${remedy}`);
|
|
1838
|
+
}
|
|
1839
|
+
if (origin === "check") {
|
|
1840
|
+
const md = (claim.metadata && typeof claim.metadata === "object") ? claim.metadata as AnyObj : {};
|
|
1841
|
+
if (typeof md.check_kind !== "string" || md.check_kind.length === 0) {
|
|
1842
|
+
die(`pre-supersession trust.bundle in ${dir}: check claim '${claim.id ?? "<unknown>"}' has metadata.origin but no metadata.check_kind stamp (this bundle predates #344's stamping and cannot be read authoritatively) — ${remedy}`);
|
|
1843
|
+
}
|
|
1469
1844
|
}
|
|
1470
|
-
|
|
1471
|
-
if (!activeStep || activeStep.gateExpects.length === 0) return new Set<string>();
|
|
1472
|
-
return new Set<string>(activeStep.gateExpects.map((e) => e.bundle_claim.claimType));
|
|
1845
|
+
return origin;
|
|
1473
1846
|
}
|
|
1474
|
-
|
|
1475
|
-
function checksFromBundle(dir: string, declaredClaimTypes: Set<string> = new Set()): AnyObj[] {
|
|
1847
|
+
function checksFromBundle(dir: string): AnyObj[] {
|
|
1476
1848
|
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
1477
|
-
if (!Array.isArray(bundle.evidence)) return [];
|
|
1478
1849
|
const allClaims: AnyObj[] = Array.isArray(bundle.claims) ? bundle.claims : [];
|
|
1850
|
+
// Validate stamps on every claim up front — any unstamped claim anywhere in the bundle marks
|
|
1851
|
+
// it pre-supersession, regardless of whether it is check/acceptance/critique-typed.
|
|
1852
|
+
for (const claim of allClaims) requireStampedClaim(claim, dir);
|
|
1853
|
+
if (!Array.isArray(bundle.evidence)) return [];
|
|
1479
1854
|
const claimById = new Map<string, AnyObj>();
|
|
1480
1855
|
for (const c of allClaims) if (c && c.id) claimById.set(c.id, c);
|
|
1481
1856
|
const seen = new Set<string>();
|
|
1482
1857
|
const checks: AnyObj[] = [];
|
|
1858
|
+
const kindOf = (claim: AnyObj): string => String((claim.metadata as AnyObj).check_kind);
|
|
1483
1859
|
for (const ev of bundle.evidence) {
|
|
1484
1860
|
if (!ev || !ev.claimId) continue;
|
|
1485
1861
|
const claim = claimById.get(ev.claimId);
|
|
1486
1862
|
if (!claim) continue;
|
|
1487
|
-
|
|
1488
|
-
// ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
|
|
1489
|
-
if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct)) continue;
|
|
1863
|
+
if (claimOrigin(claim) !== "check") continue;
|
|
1490
1864
|
if (seen.has(ev.claimId)) continue;
|
|
1491
1865
|
seen.add(ev.claimId);
|
|
1492
|
-
const kind =
|
|
1866
|
+
const kind = kindOf(claim);
|
|
1493
1867
|
const status = claim.value ?? "not_verified";
|
|
1494
1868
|
const check: AnyObj = { id: String(claim.subjectId || "").split("/").pop() || ev.claimId, kind, status, summary: claim.fieldOrBehavior || "" };
|
|
1495
1869
|
if (ev.execution && typeof ev.execution.label === "string") check.command = ev.execution.label;
|
|
1496
1870
|
if (ev.evidenceType) check.evidenceType = ev.evidenceType;
|
|
1497
1871
|
checks.push(check);
|
|
1498
1872
|
}
|
|
1499
|
-
// Also include check claims that have no evidence item (surface_trust_refs style)
|
|
1873
|
+
// Also include check claims that have no evidence item (surface_trust_refs style).
|
|
1500
1874
|
for (const claim of allClaims) {
|
|
1501
1875
|
if (!claim) continue;
|
|
1502
|
-
|
|
1503
|
-
// ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
|
|
1504
|
-
if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct)) continue;
|
|
1876
|
+
if (claimOrigin(claim) !== "check") continue;
|
|
1505
1877
|
if (seen.has(claim.id)) continue;
|
|
1506
1878
|
seen.add(claim.id);
|
|
1507
|
-
const kind =
|
|
1879
|
+
const kind = kindOf(claim);
|
|
1508
1880
|
checks.push({ id: String(claim.subjectId || "").split("/").pop() || claim.id, kind, status: claim.value ?? "not_verified", summary: claim.fieldOrBehavior || "" });
|
|
1509
1881
|
}
|
|
1510
1882
|
return checks;
|
|
1511
1883
|
}
|
|
1512
|
-
function critiquesFromBundle(dir: string
|
|
1884
|
+
function critiquesFromBundle(dir: string): AnyObj[] {
|
|
1513
1885
|
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
1514
1886
|
if (!Array.isArray(bundle.claims)) return [];
|
|
1515
|
-
|
|
1516
|
-
//
|
|
1517
|
-
//
|
|
1518
|
-
//
|
|
1519
|
-
const
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1887
|
+
for (const c of bundle.claims) requireStampedClaim(c, dir);
|
|
1888
|
+
// A claim is a CRITIQUE when its origin is "critique" (authoritative — see requireStampedClaim
|
|
1889
|
+
// above). reviewer / reviewed_at / superseded_by are read back from metadata so supersession
|
|
1890
|
+
// (#267/#282) round-trips losslessly.
|
|
1891
|
+
const critiqueClaims = bundle.claims.filter((c: AnyObj) => c && claimOrigin(c) === "critique");
|
|
1892
|
+
return critiqueClaims.map((c: AnyObj) => {
|
|
1893
|
+
const md = (c.metadata && typeof c.metadata === "object") ? c.metadata as AnyObj : {};
|
|
1894
|
+
return {
|
|
1895
|
+
id: String(c.subjectId || "").split("/").pop() || c.id,
|
|
1896
|
+
verdict: c.value ?? "not_verified",
|
|
1897
|
+
summary: c.fieldOrBehavior || "",
|
|
1898
|
+
findings: [],
|
|
1899
|
+
reviewer: typeof md.reviewer === "string" ? md.reviewer : "tool-code-reviewer",
|
|
1900
|
+
reviewed_at: typeof md.reviewed_at === "string" ? md.reviewed_at : (c.updatedAt || c.createdAt || now()),
|
|
1901
|
+
artifact_refs: [],
|
|
1902
|
+
...(typeof md.superseded_by === "string" && md.superseded_by.length > 0 ? { superseded_by: md.superseded_by } : {}),
|
|
1903
|
+
};
|
|
1904
|
+
});
|
|
1532
1905
|
}
|
|
1533
1906
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1534
1907
|
/**
|
|
@@ -1579,7 +1952,11 @@ async function recordEvidence(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
1579
1952
|
const _existingCriteria: AnyObj[] = Array.isArray(_existingAcceptance.criteria) ? _existingAcceptance.criteria : [];
|
|
1580
1953
|
const _criteriaStatus = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
|
|
1581
1954
|
const _criteriaForBundle: AnyObj[] = _existingCriteria.map((c: AnyObj) => ({ ...c, status: _criteriaStatus }));
|
|
1582
|
-
|
|
1955
|
+
// #268: preserve any existing critique claims (including superseded history) instead of dropping
|
|
1956
|
+
// them — record-evidence previously hardcoded critiques:[] here, silently erasing finding history
|
|
1957
|
+
// whenever it ran after a critique existed.
|
|
1958
|
+
const _existingCritiques: AnyObj[] = critiquesFromBundle(dir);
|
|
1959
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, checks, _criteriaForBundle, _existingCritiques));
|
|
1583
1960
|
const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
|
|
1584
1961
|
writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
|
|
1585
1962
|
return 0;
|
|
@@ -1626,9 +2003,14 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
1626
2003
|
const summary = opt(p, "summary") || die("--summary is required");
|
|
1627
2004
|
const expectationId = opt(p, "expectation");
|
|
1628
2005
|
|
|
1629
|
-
// Resolve the active flow step from current.json
|
|
2006
|
+
// Resolve the active flow step from current.json. #291 Wave 2 Task 2.1 (§7)/Task 2.2: resolve
|
|
2007
|
+
// the CALLING actor's own current-pointer (per-actor-first, legacy-fallback) rather than an
|
|
2008
|
+
// unconditional legacy-only read — this is the fix for record-gate-claim's pre-existing (#291
|
|
2009
|
+
// Stop-short risk) lack of a dir-scoping guard: it now resolves ITS OWN actor's current.json
|
|
2010
|
+
// view, not a different actor's more-recently-written legacy pointer.
|
|
1630
2011
|
const flowAgentsDir = path.dirname(dir);
|
|
1631
|
-
const
|
|
2012
|
+
const gateClaimActorKey = resolveReadActorKey(p);
|
|
2013
|
+
const activeStep = resolveActiveFlowStep(flowAgentsDir, gateClaimActorKey);
|
|
1632
2014
|
if (!activeStep) die("record-gate-claim requires an active flow step in current.json (set via ensure-session --flow-id or advance-state --flow-definition)");
|
|
1633
2015
|
|
|
1634
2016
|
const expects = activeStep.gateExpects;
|
|
@@ -1676,7 +2058,7 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
1676
2058
|
if (gateWaiver) checkNormalized._waiver = gateWaiver;
|
|
1677
2059
|
// Log the targeted gate expectation for transparency (goes to stderr only)
|
|
1678
2060
|
process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}${gateWaiver ? " (WAIVED accepted_gap)" : ""}\n`);
|
|
1679
|
-
assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], []));
|
|
2061
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], [], gateClaimActorKey));
|
|
1680
2062
|
return 0;
|
|
1681
2063
|
}
|
|
1682
2064
|
|
|
@@ -1748,11 +2130,10 @@ async function promote(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
1748
2130
|
// Add the promotion claim WITHOUT dropping the session's existing verification
|
|
1749
2131
|
// evidence/criteria/critiques (mirror record-critique's merge pattern). Drop any prior
|
|
1750
2132
|
// "promotion" check so re-running promote is idempotent rather than duplicating.
|
|
1751
|
-
const
|
|
1752
|
-
const existingChecks = checksFromBundle(dir, _dct).filter((c) => c.id !== "promotion");
|
|
2133
|
+
const existingChecks = checksFromBundle(dir).filter((c) => c.id !== "promotion");
|
|
1753
2134
|
const _acc = loadJson(path.join(dir, "acceptance.json"));
|
|
1754
2135
|
const criteria: AnyObj[] = Array.isArray(_acc.criteria) ? _acc.criteria : [];
|
|
1755
|
-
const critiques = critiquesFromBundle(dir
|
|
2136
|
+
const critiques = critiquesFromBundle(dir);
|
|
1756
2137
|
assertBundleWritten(await writeTrustBundle(dir, slug, ts, [...existingChecks, promotionCheck], criteria, critiques));
|
|
1757
2138
|
|
|
1758
2139
|
// Auditable record of what was promoted where (companion to the trust.bundle claim).
|
|
@@ -1813,7 +2194,11 @@ async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
1813
2194
|
const phaseMap = resolvePhaseMap(flow, repoRoot);
|
|
1814
2195
|
const stepId = phaseMap?.[phase] ?? undefined;
|
|
1815
2196
|
if (stepId) {
|
|
1816
|
-
|
|
2197
|
+
// #291 Wave 2 Task 2.1 (§5): thread the calling actor through so this second writeCurrent()
|
|
2198
|
+
// call site ALSO dual-writes the per-actor projection, not only ensure-session's call site —
|
|
2199
|
+
// otherwise a session that only ever calls advance-state (never re-running ensure-session)
|
|
2200
|
+
// would never get a per-actor current.json mirror for its own FlowDefinition routing keys.
|
|
2201
|
+
writeCurrent(root, dir, timestamp, "workflow-sidecar", "advance-state", flow, stepId, undefined, resolveReadActorKey(p));
|
|
1817
2202
|
}
|
|
1818
2203
|
}
|
|
1819
2204
|
livenessLifecycle(dir, slug, LIVENESS_TERMINAL.has(status) ? "release" : "heartbeat", timestamp);
|
|
@@ -1845,13 +2230,29 @@ async function recordCritique(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
1845
2230
|
// Fall back to critique.json for legacy sessions that still have it on disk.
|
|
1846
2231
|
const existingCritiqueJson = loadJson(path.join(dir, "critique.json"), { critiques: [] });
|
|
1847
2232
|
const legacyCritiques: AnyObj[] = Array.isArray(existingCritiqueJson.critiques) ? existingCritiqueJson.critiques : [];
|
|
1848
|
-
const
|
|
1849
|
-
const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir, _dctCritique) : legacyCritiques;
|
|
2233
|
+
const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir) : legacyCritiques;
|
|
1850
2234
|
const critique = { id: opt(p, "id") || "review", reviewer: opt(p, "reviewer", "tool-code-reviewer"), reviewed_at: opt(p, "timestamp", now()), verdict: opt(p, "verdict", "pass"), summary: opt(p, "summary"), artifact_refs: opts(p, "artifact-ref"), findings: opts(p, "finding-json").map((v) => normalizeFinding(parseJson(v, "--finding-json"))) };
|
|
1851
|
-
const critiques = [...bundleCritiques, critique];
|
|
1852
2235
|
if (critique.verdict === "pass" && critique.findings.some((f: AnyObj) => f.status === "open")) die("required critique must pass");
|
|
2236
|
+
// #267/#282: supersede-by-critique-id. The latest write for a critique id wins for
|
|
2237
|
+
// reconcile / status / validator purposes; each prior LIVE write for the same id is RETAINED as
|
|
2238
|
+
// history (status "superseded", first-class metadata.superseded_by) but excluded from evaluation.
|
|
2239
|
+
// Supersession is REVIEWER-SCOPED (anti-gaming): a write may only supersede a prior live critique
|
|
2240
|
+
// of the same id written by the SAME reviewer — a different reviewer's disputed finding is never
|
|
2241
|
+
// buried, so it stays live and continues to block. DOCUMENTED GAP: reviewer identity is the
|
|
2242
|
+
// free-form --reviewer string (with a default); there is no cryptographic worker-vs-reviewer
|
|
2243
|
+
// distinction yet — that lands with the runtime actor-identity slice (#287/#290). Same-reviewer-
|
|
2244
|
+
// string scoping is the strongest honest enforcement available today and matches the granularity
|
|
2245
|
+
// the critique record already has.
|
|
2246
|
+
const _supersedeMarker = `${critique.id}@${critique.reviewed_at}`;
|
|
2247
|
+
const _mergedCritiques = bundleCritiques.map((e: AnyObj) => {
|
|
2248
|
+
const eSuperseded = typeof e.superseded_by === "string" && e.superseded_by.length > 0;
|
|
2249
|
+
const eReviewer = String(e.reviewer ?? "tool-code-reviewer");
|
|
2250
|
+
if (e.id === critique.id && !eSuperseded && eReviewer === critique.reviewer) return { ...e, superseded_by: _supersedeMarker };
|
|
2251
|
+
return e;
|
|
2252
|
+
});
|
|
2253
|
+
const critiques = [..._mergedCritiques, critique];
|
|
1853
2254
|
// Phase 4c: build bundle from raw inputs; read checks from trust.bundle (evidence.json no longer written).
|
|
1854
|
-
const _critiqueEvChecks: AnyObj[] = checksFromBundle(dir
|
|
2255
|
+
const _critiqueEvChecks: AnyObj[] = checksFromBundle(dir);
|
|
1855
2256
|
const _critiqueAccCriteria: AnyObj[] = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
|
|
1856
2257
|
assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueEvChecks, _critiqueAccCriteria, critiques));
|
|
1857
2258
|
return 0;
|
|
@@ -2242,45 +2643,49 @@ async function recordLearning(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
2242
2643
|
writeJson(path.join(dir, "learning.json"), { ...sidecarBase(slug), status, updated_at: timestamp, records });
|
|
2243
2644
|
writeState(dir, slug, "accepted", "learning", timestamp, opt(p, "summary"));
|
|
2244
2645
|
// Phase 4c: build bundle from raw inputs; read checks/critiques from trust.bundle (bespoke sidecars no longer written).
|
|
2245
|
-
//
|
|
2246
|
-
const
|
|
2247
|
-
const _learningChecks: AnyObj[] = checksFromBundle(dir, _dctLearning);
|
|
2646
|
+
// #268/#344: declared builder.* claims survive the round-trip via their authoritative origin stamp.
|
|
2647
|
+
const _learningChecks: AnyObj[] = checksFromBundle(dir);
|
|
2248
2648
|
const _learningCriteria: AnyObj[] = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
|
|
2249
|
-
const _learningCritiques: AnyObj[] = critiquesFromBundle(dir
|
|
2649
|
+
const _learningCritiques: AnyObj[] = critiquesFromBundle(dir);
|
|
2250
2650
|
assertBundleWritten(await writeTrustBundle(dir, slug, timestamp, _learningChecks, _learningCriteria, _learningCritiques));
|
|
2251
2651
|
return 0;
|
|
2252
2652
|
}
|
|
2253
|
-
function evidenceClean(dir: string
|
|
2254
|
-
// Phase 4c: read from trust.bundle (sole verification artifact); fall back to evidence.json for
|
|
2255
|
-
//
|
|
2256
|
-
//
|
|
2653
|
+
function evidenceClean(dir: string): boolean {
|
|
2654
|
+
// Phase 4c: read from trust.bundle (sole verification artifact); fall back to evidence.json for
|
|
2655
|
+
// legacy (pre-bundle-era) sessions that never wrote a trust.bundle at all — unrelated to origin
|
|
2656
|
+
// stamping. When a trust.bundle IS present, every claim must be stamped (requireStampedClaim);
|
|
2657
|
+
// there is no claimType-derivation fallback for an unstamped claim (#268/#344).
|
|
2257
2658
|
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
2258
2659
|
if (Array.isArray(bundle.claims)) {
|
|
2259
|
-
const
|
|
2260
|
-
|
|
2261
|
-
const ct = String(c.claimType || "");
|
|
2262
|
-
return ct.startsWith("workflow.check.") || declaredClaimTypes.has(ct);
|
|
2263
|
-
});
|
|
2660
|
+
for (const c of bundle.claims) requireStampedClaim(c, dir);
|
|
2661
|
+
const checkClaims = (bundle.claims as AnyObj[]).filter((c: AnyObj) => c && claimOrigin(c) === "check");
|
|
2264
2662
|
if (checkClaims.length === 0) return false;
|
|
2265
2663
|
return checkClaims.every((c: AnyObj) => {
|
|
2266
2664
|
const v = String(c.value || "");
|
|
2267
2665
|
return v === "pass" || v === "skip";
|
|
2268
2666
|
});
|
|
2269
2667
|
}
|
|
2270
|
-
// Legacy fallback: evidence.json
|
|
2668
|
+
// Legacy fallback: evidence.json (pre-bundle-era sessions with no trust.bundle at all)
|
|
2271
2669
|
const e = loadJson(path.join(dir, "evidence.json"), {});
|
|
2272
2670
|
return e.verdict === "pass" && Array.isArray(e.checks) && e.checks.length > 0 && e.checks.every((c: AnyObj) => {
|
|
2273
2671
|
if (!(c.status === "pass" || c.status === "skip")) return false;
|
|
2274
2672
|
return !Array.isArray(c.standard_refs) || c.standard_refs.every((r: AnyObj) => ["junit", "sarif", "coverage", "veritas"].includes(r.standard));
|
|
2275
2673
|
});
|
|
2276
2674
|
}
|
|
2277
|
-
function critiqueClean(dir: string
|
|
2278
|
-
// Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for
|
|
2279
|
-
//
|
|
2280
|
-
// (
|
|
2675
|
+
function critiqueClean(dir: string): boolean {
|
|
2676
|
+
// Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for
|
|
2677
|
+
// legacy (pre-bundle-era) sessions — unrelated to origin stamping. When a trust.bundle IS
|
|
2678
|
+
// present, every claim must be stamped (requireStampedClaim); no claimType-derivation fallback
|
|
2679
|
+
// for an unstamped claim (#268/#344).
|
|
2281
2680
|
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
2282
2681
|
if (Array.isArray(bundle.claims)) {
|
|
2283
|
-
const
|
|
2682
|
+
for (const c of bundle.claims) requireStampedClaim(c, dir);
|
|
2683
|
+
const critiqueClaims = (bundle.claims as AnyObj[]).filter((c: AnyObj) => {
|
|
2684
|
+
if (!c) return false;
|
|
2685
|
+
// #267/#282: superseded history is not evaluated for cleanliness.
|
|
2686
|
+
if (c.metadata && typeof c.metadata === "object" && (c.metadata as AnyObj).superseded_by) return false;
|
|
2687
|
+
return claimOrigin(c) === "critique";
|
|
2688
|
+
});
|
|
2284
2689
|
if (critiqueClaims.length === 0) return false; // no critique written yet
|
|
2285
2690
|
return critiqueClaims.every((c: AnyObj) => {
|
|
2286
2691
|
const v = String(c.value || "");
|
|
@@ -2314,10 +2719,9 @@ async function dogfoodPass(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
2314
2719
|
const checks = opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json")));
|
|
2315
2720
|
if (checks.some((c) => c.status !== "pass" && c.status !== "skip")) die("clean evidence requires all non-skipped checks to pass");
|
|
2316
2721
|
// Phase 4c: evidence check reads from trust.bundle (sole verification artifact); legacy evidence.json fallback in evidenceClean.
|
|
2317
|
-
//
|
|
2318
|
-
const
|
|
2319
|
-
const
|
|
2320
|
-
const _hasLegacyEvidence = fs.existsSync(path.join(dir, "evidence.json")) && evidenceClean(dir, _dctDogfood);
|
|
2722
|
+
// #268/#344: builder.* check/critique claims count as clean evidence via their authoritative origin stamp.
|
|
2723
|
+
const _hasBundleEvidence = fs.existsSync(path.join(dir, "trust.bundle")) && evidenceClean(dir);
|
|
2724
|
+
const _hasLegacyEvidence = fs.existsSync(path.join(dir, "evidence.json")) && evidenceClean(dir);
|
|
2321
2725
|
if (!_hasBundleEvidence && !_hasLegacyEvidence && fs.existsSync(path.join(dir, "trust.bundle"))) die("cannot mark clean without passing evidence");
|
|
2322
2726
|
if (!_hasBundleEvidence && !_hasLegacyEvidence && !fs.existsSync(path.join(dir, "trust.bundle")) && fs.existsSync(path.join(dir, "evidence.json"))) die("cannot mark clean without passing evidence");
|
|
2323
2727
|
if (!_hasBundleEvidence && !_hasLegacyEvidence && !fs.existsSync(path.join(dir, "trust.bundle")) && !fs.existsSync(path.join(dir, "evidence.json")) && checks.length === 0) die("cannot mark clean without passing evidence");
|
|
@@ -2325,9 +2729,9 @@ async function dogfoodPass(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
2325
2729
|
const newCritiqueVerdict = opt(p, "critique-verdict", "pass");
|
|
2326
2730
|
for (const value of opts(p, "finding-json")) normalizeFinding(parseJson(value, "--finding-json"));
|
|
2327
2731
|
if (newCritiqueVerdict !== "pass") die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
|
|
2328
|
-
if (!opt(p, "critique-id") && !critiqueClean(dir
|
|
2732
|
+
if (!opt(p, "critique-id") && !critiqueClean(dir)) die("requires passing critique");
|
|
2329
2733
|
// Phase 4c: if existing state has a dirty critique (in bundle or legacy critique.json), block even when adding a new critique-id.
|
|
2330
|
-
if (!critiqueClean(dir
|
|
2734
|
+
if (!critiqueClean(dir) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json")))) die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
|
|
2331
2735
|
}
|
|
2332
2736
|
}
|
|
2333
2737
|
const learningRecords = opts(p, "learning-record-json").map((v) => normalizeLearning(parseJson(v, "--learning-record-json"), opt(p, "timestamp", now())));
|
|
@@ -2902,6 +3306,26 @@ function livenessLabel(status: string): string {
|
|
|
2902
3306
|
return status;
|
|
2903
3307
|
}
|
|
2904
3308
|
|
|
3309
|
+
/**
|
|
3310
|
+
* Delegate to the shared pure-CJS liveness reader's freshHolders() (scripts/hooks/lib/liveness-read.js),
|
|
3311
|
+
* mirroring the exact loader shape assignment-provider.ts already uses for the same helper (#291
|
|
3312
|
+
* Wave 2 Task 2.1) — so ensure-session's ownership guard computes freshness identically to
|
|
3313
|
+
* `assignment-provider status`'s own join, a single implementation. `readLivenessEvents` above
|
|
3314
|
+
* already loads this same module inline for its own narrower need (just the events reader); this
|
|
3315
|
+
* loader additionally exposes `freshHolders` for the guard's `computeEffectiveState` call.
|
|
3316
|
+
*/
|
|
3317
|
+
function loadLivenessReadHelper(): {
|
|
3318
|
+
readLivenessEvents: (streamPath: string) => AnyObj[];
|
|
3319
|
+
freshHolders: (events: AnyObj[], slug: string, selfActor: string, nowMs: number) => FreshHolder[];
|
|
3320
|
+
} {
|
|
3321
|
+
const _req = createRequire(import.meta.url);
|
|
3322
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-read.js");
|
|
3323
|
+
return _req(helperPath) as {
|
|
3324
|
+
readLivenessEvents: (streamPath: string) => AnyObj[];
|
|
3325
|
+
freshHolders: (events: AnyObj[], slug: string, selfActor: string, nowMs: number) => FreshHolder[];
|
|
3326
|
+
};
|
|
3327
|
+
}
|
|
3328
|
+
|
|
2905
3329
|
// ─── ADR 0012 lifecycle-driven liveness (default-on; opt-out via FLOW_AGENTS_LIVENESS) ──
|
|
2906
3330
|
// init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
|
|
2907
3331
|
// so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
|
|
@@ -2917,6 +3341,16 @@ function loadActorIdentityHelper(): {
|
|
|
2917
3341
|
resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
|
|
2918
3342
|
sanitizeSegment: (value: unknown) => string;
|
|
2919
3343
|
isUnresolvedActor: (actor: string) => boolean;
|
|
3344
|
+
// #291 Wave 2 Task 2.1: widened (additive only — every existing caller above keeps using only
|
|
3345
|
+
// the three fields already destructured) so resolveEnsureSessionActor() can reconstruct a real
|
|
3346
|
+
// ActorStruct {runtime, session_id, host} from the SAME exported primitives resolveActor()
|
|
3347
|
+
// already uses internally, and so serializeActor() is available for the ownership guard's
|
|
3348
|
+
// assignment-claim identity (a DIFFERENT identity concept from the flat actorKey used for
|
|
3349
|
+
// branch-naming/liveness — see resolveEnsureSessionActor's doc comment for why both exist).
|
|
3350
|
+
serializeActor: (actor: Partial<ActorStruct> | undefined) => string;
|
|
3351
|
+
detectRuntime: (env: NodeJS.ProcessEnv) => string;
|
|
3352
|
+
runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
|
|
3353
|
+
ancestorActorSeed: () => string;
|
|
2920
3354
|
} {
|
|
2921
3355
|
const _req = createRequire(import.meta.url);
|
|
2922
3356
|
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
|
|
@@ -2924,6 +3358,10 @@ function loadActorIdentityHelper(): {
|
|
|
2924
3358
|
resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
|
|
2925
3359
|
sanitizeSegment: (value: unknown) => string;
|
|
2926
3360
|
isUnresolvedActor: (actor: string) => boolean;
|
|
3361
|
+
serializeActor: (actor: Partial<ActorStruct> | undefined) => string;
|
|
3362
|
+
detectRuntime: (env: NodeJS.ProcessEnv) => string;
|
|
3363
|
+
runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
|
|
3364
|
+
ancestorActorSeed: () => string;
|
|
2927
3365
|
};
|
|
2928
3366
|
}
|
|
2929
3367
|
function resolveLivenessActor(): string {
|