@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
|
@@ -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[] = [];
|
|
@@ -760,7 +840,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
760
840
|
* @param criteria Acceptance criteria objects (same as buildTrustBundle)
|
|
761
841
|
* @param critiques Critique objects (same as buildTrustBundle)
|
|
762
842
|
*/
|
|
763
|
-
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[] }> {
|
|
764
844
|
try {
|
|
765
845
|
// Fold the deterministic capture log (PostToolUse evidence-capture) into the
|
|
766
846
|
// bundle so capture is authoritative over claimed status. Best-effort read.
|
|
@@ -772,16 +852,22 @@ export async function writeTrustBundle(dir: string, slug: string, timestamp: str
|
|
|
772
852
|
// ADR 0016 Abstraction A (P-d): pass the runtime artifact root ONLY when current.json
|
|
773
853
|
// points to this session (scoped active-flow guard). If current.json.artifact_dir
|
|
774
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.
|
|
775
860
|
const _flowAgentsDir = path.dirname(dir);
|
|
861
|
+
const _effectiveActorKey = actorKey ?? loadActorIdentityHelper().resolveActor(process.env).actor;
|
|
776
862
|
let _scopedFlowAgentsDir: string | undefined = undefined;
|
|
777
863
|
try {
|
|
778
|
-
const _currentRaw =
|
|
779
|
-
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;
|
|
780
866
|
if (_artDir && path.resolve(_flowAgentsDir, _artDir) === path.resolve(dir)) {
|
|
781
867
|
_scopedFlowAgentsDir = _flowAgentsDir;
|
|
782
868
|
}
|
|
783
869
|
} catch { /* current.json absent or unreadable — no scoping */ }
|
|
784
|
-
const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir);
|
|
870
|
+
const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir, _effectiveActorKey);
|
|
785
871
|
if (!bundle) return { written: false, errors: [] }; // Surface unavailable — fail-open, skip write
|
|
786
872
|
const result = await validateTrustBundle(bundle);
|
|
787
873
|
if (result.available && !result.valid) {
|
|
@@ -1095,12 +1181,43 @@ function resolveFirstStep(flowId: string, repoRoot: string): string | null {
|
|
|
1095
1181
|
}
|
|
1096
1182
|
}
|
|
1097
1183
|
|
|
1098
|
-
|
|
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 {
|
|
1099
1216
|
// #289: mirror the active session's already-recorded branch (state.json.branch) into
|
|
1100
1217
|
// current.json so consumers of current.json (which has no schema of its own — not one of the
|
|
1101
1218
|
// 9 schemas under schemas/) see the routing branch without re-reading state.json separately.
|
|
1102
1219
|
const branch = loadJson(path.join(dir, "state.json")).branch;
|
|
1103
|
-
|
|
1220
|
+
const payload: AnyObj = {
|
|
1104
1221
|
schema_version: "1.0",
|
|
1105
1222
|
active_slug: path.basename(dir),
|
|
1106
1223
|
artifact_dir: path.relative(root, dir) || ".",
|
|
@@ -1119,15 +1236,35 @@ function writeCurrent(root: string, dir: string, timestamp: string, owner: strin
|
|
|
1119
1236
|
// gate-review can distinguish an intentional direct entry (e.g. a planning-only
|
|
1120
1237
|
// session that skips pull-work) from a stale/mis-stamped active_step_id.
|
|
1121
1238
|
...(adHocReason ? { ad_hoc_entry: true, ad_hoc_reason: adHocReason } : {}),
|
|
1122
|
-
}
|
|
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
|
+
}
|
|
1123
1252
|
}
|
|
1124
1253
|
function loadCurrent(root: string): AnyObj | null {
|
|
1125
1254
|
const file = path.join(root, "current.json");
|
|
1126
1255
|
if (!fs.existsSync(file)) return null;
|
|
1127
1256
|
return JSON.parse(read(file));
|
|
1128
1257
|
}
|
|
1129
|
-
|
|
1130
|
-
|
|
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;
|
|
1131
1268
|
if (!c) return null;
|
|
1132
1269
|
const dir = path.resolve(root, c.artifact_dir ?? c.active_slug ?? "");
|
|
1133
1270
|
if (!fs.existsSync(dir)) return null;
|
|
@@ -1138,14 +1275,34 @@ function currentDir(root: string): string | null {
|
|
|
1138
1275
|
}
|
|
1139
1276
|
return dir;
|
|
1140
1277
|
}
|
|
1141
|
-
|
|
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
|
+
|
|
1142
1292
|
const cur = loadCurrent(root);
|
|
1143
|
-
if (
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
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
|
+
}
|
|
1149
1306
|
}
|
|
1150
1307
|
|
|
1151
1308
|
function initSidecars(dir: string, slug: string, sourceRequest: string, summary: string, nextAction: string, timestamp: string, markdown?: string): void {
|
|
@@ -1195,10 +1352,201 @@ function initSidecars(dir: string, slug: string, sourceRequest: string, summary:
|
|
|
1195
1352
|
});
|
|
1196
1353
|
}
|
|
1197
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
|
+
*/
|
|
1198
1540
|
function ensureSession(p: ReturnType<typeof parseArgs>): number {
|
|
1199
1541
|
const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
|
|
1200
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)"));
|
|
1201
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);
|
|
1202
1550
|
fs.mkdirSync(dir, { recursive: true });
|
|
1203
1551
|
const timestamp = opt(p, "timestamp", now());
|
|
1204
1552
|
let md = fs.existsSync(path.join(dir, `${slug}--deliver.md`)) ? read(path.join(dir, `${slug}--deliver.md`)) : "";
|
|
@@ -1241,14 +1589,19 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
|
|
|
1241
1589
|
adHocReason = opt(p, "ad-hoc-reason") || `direct entry at step "${explicitStep}" via --step-id (flow first step is "${firstStep}")`;
|
|
1242
1590
|
}
|
|
1243
1591
|
}
|
|
1244
|
-
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);
|
|
1245
1593
|
console.log(dir);
|
|
1246
1594
|
return 0;
|
|
1247
1595
|
}
|
|
1248
1596
|
|
|
1249
1597
|
function current(p: ReturnType<typeof parseArgs>): number {
|
|
1250
1598
|
const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : defaultArtifactRootForRead();
|
|
1251
|
-
|
|
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);
|
|
1252
1605
|
if (!dir) die("no current workflow session is recorded");
|
|
1253
1606
|
const format = opt(p, "format", "path");
|
|
1254
1607
|
console.log(format === "slug" ? path.basename(dir) : dir);
|
|
@@ -1259,15 +1612,39 @@ function recordAgentEvent(p: ReturnType<typeof parseArgs>): number {
|
|
|
1259
1612
|
const hasExplicitRoot = !!opt(p, "artifact-root");
|
|
1260
1613
|
const root = explicitArtifactRoot(p);
|
|
1261
1614
|
const explicit = opt(p, "artifact-dir");
|
|
1262
|
-
const
|
|
1615
|
+
const actorKey = resolveReadActorKey(p);
|
|
1616
|
+
const dir = explicit ? path.resolve(explicit) : currentDir(root, actorKey);
|
|
1263
1617
|
if (!dir || !fs.existsSync(dir)) die("artifact directory does not exist");
|
|
1264
1618
|
if (explicit && fs.lstatSync(dir).isSymbolicLink()) die(`artifact directory must not be a symlink: ${dir}`);
|
|
1265
1619
|
if (hasExplicitRoot || !explicit) requireArtifactDirUnderRoot(dir, root);
|
|
1266
1620
|
const timestamp = opt(p, "timestamp", now());
|
|
1267
1621
|
const agent = validateAgentId(opt(p, "agent-id"));
|
|
1268
|
-
|
|
1622
|
+
// #376 model routing: optionally stamp the delegate role/model resolved from
|
|
1623
|
+
// .datum/config.json onto the event so a downstream economics record (#349)
|
|
1624
|
+
// can price role assignments per delegation, and so an escalate-on-gate-failure
|
|
1625
|
+
// re-dispatch records which tier it climbed FROM. Fully additive/optional: when
|
|
1626
|
+
// no routing flag is passed the event shape is byte-identical to before.
|
|
1627
|
+
// These live as TOP-LEVEL event fields (not nested) on purpose: appendJsonl's
|
|
1628
|
+
// serializer (spacedLine) uses the top-level key list as a JSON.stringify array
|
|
1629
|
+
// replacer, which is an allowlist applied at every nesting level — a nested
|
|
1630
|
+
// routing object would have its inner keys stripped. Flat keeps the shape a
|
|
1631
|
+
// simple per-event routing record a JSONL economics feed can read directly.
|
|
1632
|
+
const role = opt(p, "role");
|
|
1633
|
+
const model = opt(p, "model");
|
|
1634
|
+
const escalatedFrom = opt(p, "escalated-from");
|
|
1635
|
+
const event = {
|
|
1636
|
+
timestamp,
|
|
1637
|
+
agent_id: agent,
|
|
1638
|
+
kind: opt(p, "kind", "note"),
|
|
1639
|
+
status: opt(p, "status", "info"),
|
|
1640
|
+
summary: opt(p, "summary"),
|
|
1641
|
+
...(opt(p, "ref") ? { ref: opt(p, "ref") } : {}),
|
|
1642
|
+
...(role ? { role } : {}),
|
|
1643
|
+
...(model ? { model } : {}),
|
|
1644
|
+
...(escalatedFrom ? { escalated_from: escalatedFrom } : {}),
|
|
1645
|
+
};
|
|
1269
1646
|
appendJsonl(path.join(dir, "agents", agent, "events.jsonl"), event);
|
|
1270
|
-
updateCurrentAgent(root, dir, agent, event.status, timestamp);
|
|
1647
|
+
updateCurrentAgent(root, dir, agent, event.status, timestamp, actorKey);
|
|
1271
1648
|
return 0;
|
|
1272
1649
|
}
|
|
1273
1650
|
|
|
@@ -1649,9 +2026,14 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
1649
2026
|
const summary = opt(p, "summary") || die("--summary is required");
|
|
1650
2027
|
const expectationId = opt(p, "expectation");
|
|
1651
2028
|
|
|
1652
|
-
// Resolve the active flow step from current.json
|
|
2029
|
+
// Resolve the active flow step from current.json. #291 Wave 2 Task 2.1 (§7)/Task 2.2: resolve
|
|
2030
|
+
// the CALLING actor's own current-pointer (per-actor-first, legacy-fallback) rather than an
|
|
2031
|
+
// unconditional legacy-only read — this is the fix for record-gate-claim's pre-existing (#291
|
|
2032
|
+
// Stop-short risk) lack of a dir-scoping guard: it now resolves ITS OWN actor's current.json
|
|
2033
|
+
// view, not a different actor's more-recently-written legacy pointer.
|
|
1653
2034
|
const flowAgentsDir = path.dirname(dir);
|
|
1654
|
-
const
|
|
2035
|
+
const gateClaimActorKey = resolveReadActorKey(p);
|
|
2036
|
+
const activeStep = resolveActiveFlowStep(flowAgentsDir, gateClaimActorKey);
|
|
1655
2037
|
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)");
|
|
1656
2038
|
|
|
1657
2039
|
const expects = activeStep.gateExpects;
|
|
@@ -1699,7 +2081,7 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
1699
2081
|
if (gateWaiver) checkNormalized._waiver = gateWaiver;
|
|
1700
2082
|
// Log the targeted gate expectation for transparency (goes to stderr only)
|
|
1701
2083
|
process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}${gateWaiver ? " (WAIVED accepted_gap)" : ""}\n`);
|
|
1702
|
-
assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], []));
|
|
2084
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], [], gateClaimActorKey));
|
|
1703
2085
|
return 0;
|
|
1704
2086
|
}
|
|
1705
2087
|
|
|
@@ -1835,7 +2217,11 @@ async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
1835
2217
|
const phaseMap = resolvePhaseMap(flow, repoRoot);
|
|
1836
2218
|
const stepId = phaseMap?.[phase] ?? undefined;
|
|
1837
2219
|
if (stepId) {
|
|
1838
|
-
|
|
2220
|
+
// #291 Wave 2 Task 2.1 (§5): thread the calling actor through so this second writeCurrent()
|
|
2221
|
+
// call site ALSO dual-writes the per-actor projection, not only ensure-session's call site —
|
|
2222
|
+
// otherwise a session that only ever calls advance-state (never re-running ensure-session)
|
|
2223
|
+
// would never get a per-actor current.json mirror for its own FlowDefinition routing keys.
|
|
2224
|
+
writeCurrent(root, dir, timestamp, "workflow-sidecar", "advance-state", flow, stepId, undefined, resolveReadActorKey(p));
|
|
1839
2225
|
}
|
|
1840
2226
|
}
|
|
1841
2227
|
livenessLifecycle(dir, slug, LIVENESS_TERMINAL.has(status) ? "release" : "heartbeat", timestamp);
|
|
@@ -2163,52 +2549,118 @@ async function sealCheckpoint(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
2163
2549
|
// Called automatically from recordRelease and advanceState→delivered (best-effort).
|
|
2164
2550
|
// Also exposed as the `publish-delivery <artifact-dir>` subcommand for explicit use.
|
|
2165
2551
|
|
|
2552
|
+
/**
|
|
2553
|
+
* #379 supersede-on-publish cleanup: keep delivery/ bounded by pruning inherited PER-SESSION
|
|
2554
|
+
* seal dirs (the growth vector), scoped to avoid any cross-PR conflict.
|
|
2555
|
+
*
|
|
2556
|
+
* An inherited per-session seal dir (`delivery/<other-slug>/`) is UNIQUELY named, so pruning
|
|
2557
|
+
* it can never conflict with a concurrent PR: two branches deleting the same inherited dir is
|
|
2558
|
+
* a delete/delete (auto-merges), and each new delivery adds its OWN distinct
|
|
2559
|
+
* `delivery/<slug>/`. And it is HARMLESS to leave: trust-reconcile.js's prefer-newest
|
|
2560
|
+
* ownership selection always picks THIS session's fresher bundle over an older inherited one.
|
|
2561
|
+
* Pruning is therefore purely to stop unbounded accumulation of permanently-superseded dirs.
|
|
2562
|
+
*
|
|
2563
|
+
* The SHARED FLAT path (`delivery/trust.bundle` + checkpoints) is deliberately NOT pruned
|
|
2564
|
+
* here. During the migration window other concurrent PRs may still seal to the flat path;
|
|
2565
|
+
* deleting it would produce a modify/delete conflict → a DIRTY PR → the no-CI failure this
|
|
2566
|
+
* whole change fixes. The flat path is a single fixed location (not a growth vector) and the
|
|
2567
|
+
* reconciler treats a stale flat bundle as non-owning / older-owning, so leaving it is safe.
|
|
2568
|
+
* Removing the flat legacy seals is a one-time cleanup for a dedicated PR once no open PR
|
|
2569
|
+
* still seals to it — intentionally NOT bundled into every delivery.
|
|
2570
|
+
*
|
|
2571
|
+
* Best-effort: a prune failure is logged, never fatal to the delivery. Never touches
|
|
2572
|
+
* README.md, DECLARED, the flat seal files, or any subdir that is not itself a seal dir.
|
|
2573
|
+
*/
|
|
2574
|
+
function pruneSupersededSeals(deliveryDir: string, keepSlug: string): void {
|
|
2575
|
+
let entries: fs.Dirent[] = [];
|
|
2576
|
+
try {
|
|
2577
|
+
entries = fs.readdirSync(deliveryDir, { withFileTypes: true });
|
|
2578
|
+
} catch {
|
|
2579
|
+
entries = [];
|
|
2580
|
+
}
|
|
2581
|
+
for (const entry of entries) {
|
|
2582
|
+
if (!entry.isDirectory() || entry.name === keepSlug) continue;
|
|
2583
|
+
const subdir = path.join(deliveryDir, entry.name);
|
|
2584
|
+
// Only prune dirs that actually look like a seal dir (contain a trust.bundle or
|
|
2585
|
+
// trust.checkpoint.json) — never an unrelated directory a human placed under delivery/.
|
|
2586
|
+
const looksLikeSeal =
|
|
2587
|
+
fs.existsSync(path.join(subdir, "trust.bundle")) ||
|
|
2588
|
+
fs.existsSync(path.join(subdir, "trust.checkpoint.json"));
|
|
2589
|
+
if (!looksLikeSeal) continue;
|
|
2590
|
+
try {
|
|
2591
|
+
fs.rmSync(subdir, { recursive: true, force: true });
|
|
2592
|
+
process.stderr.write(`[publish-delivery] #379: pruned superseded per-session seal delivery/${entry.name}/ (older-owning/stale; the reconciler selects the newest bundle regardless)\n`);
|
|
2593
|
+
} catch (err) {
|
|
2594
|
+
process.stderr.write(`[publish-delivery] #379: could not prune per-session seal delivery/${entry.name}/ (non-fatal): ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2166
2599
|
/**
|
|
2167
2600
|
* Publish the session's trust artifacts to the committed delivery/ path.
|
|
2168
2601
|
*
|
|
2602
|
+
* #379: writes to a PER-SESSION path `<repoRoot>/delivery/<slug>/` (slug = the session
|
|
2603
|
+
* artifact dir's basename) rather than the shared flat `delivery/`. A shared path guarantees
|
|
2604
|
+
* a git conflict between ANY two concurrent deliveries, and a conflicting (DIRTY) PR gets NO
|
|
2605
|
+
* pull_request workflows — the required Trust Reconcile check silently never runs (field
|
|
2606
|
+
* incidents #330/#358/#378). Per-session paths make concurrent deliveries write DISTINCT
|
|
2607
|
+
* files that never contend. trust-reconcile.js reads both the flat (back-compat) and
|
|
2608
|
+
* per-session layouts via resolveDeliveryCandidates() and selects the owning candidate by
|
|
2609
|
+
* commit ancestry.
|
|
2610
|
+
*
|
|
2169
2611
|
* Copies trust.bundle, trust.checkpoint.json, and (if present)
|
|
2170
|
-
* trust.checkpoint.intoto.json / trust.checkpoint.sig.json
|
|
2171
|
-
* session artifact dir to <repoRoot>/delivery
|
|
2612
|
+
* trust.checkpoint.intoto.json / trust.checkpoint.sig.json / trust.checkpoint.attestation.json
|
|
2613
|
+
* from the session artifact dir to <repoRoot>/delivery/<slug>/.
|
|
2172
2614
|
*
|
|
2173
2615
|
* Fail-soft on a missing bundle: if trust.bundle is absent, returns without throwing.
|
|
2174
2616
|
* Fail-CLOSED on repo-root resolution: repoRoot must be a real, resolved kits/ ancestor
|
|
2175
2617
|
* (see findRepoRootFromDirStrict) — null (no ancestor found) skips the publish with a
|
|
2176
2618
|
* visible warning instead of writing to whatever process.cwd() happens to be. This
|
|
2177
2619
|
* prevents a scratch/test session dir (no kits/ ancestor) from silently clobbering an
|
|
2178
|
-
* unrelated real repo's delivery/
|
|
2620
|
+
* unrelated real repo's delivery/ seal when invoked with that repo as cwd (see
|
|
2179
2621
|
* evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings at
|
|
2180
2622
|
* .kontourai/flow-agents/ws5-governance-kit-slice1 for the root cause this fixes).
|
|
2181
|
-
* Idempotent: overwrites on re-delivery.
|
|
2623
|
+
* Idempotent: overwrites on re-delivery to the same slug.
|
|
2182
2624
|
*/
|
|
2183
2625
|
export async function publishDelivery(dir: string, repoRoot: string | null): Promise<void> {
|
|
2184
2626
|
const bundleSrc = path.join(dir, "trust.bundle");
|
|
2185
2627
|
if (!fs.existsSync(bundleSrc)) return; // no bundle — skip gracefully
|
|
2186
2628
|
|
|
2187
2629
|
if (!repoRoot) {
|
|
2188
|
-
process.stderr.write(`[publish-delivery] WARNING: no kits/ ancestor found from ${dir}; skipping publish. Refusing to fall back to process.cwd() to avoid clobbering an unrelated repo's delivery/
|
|
2630
|
+
process.stderr.write(`[publish-delivery] WARNING: no kits/ ancestor found from ${dir}; skipping publish. Refusing to fall back to process.cwd() to avoid clobbering an unrelated repo's delivery/ seal. Pass --repo-root explicitly if this session dir is intentionally outside a repo checkout.\n`);
|
|
2189
2631
|
return;
|
|
2190
2632
|
}
|
|
2191
2633
|
|
|
2192
2634
|
const deliveryDir = path.join(repoRoot, "delivery");
|
|
2635
|
+
// #379: slug is the session artifact dir's basename — the same human-meaningful id used
|
|
2636
|
+
// throughout the session (.kontourai/flow-agents/<slug>/). The per-session dir NAME is only
|
|
2637
|
+
// a collision-avoidance handle; ownership is decided by commit ancestry, not by name.
|
|
2638
|
+
const slug = path.basename(path.resolve(dir));
|
|
2639
|
+
const sessionDeliveryDir = path.join(deliveryDir, slug);
|
|
2640
|
+
|
|
2193
2641
|
fs.mkdirSync(deliveryDir, { recursive: true });
|
|
2642
|
+
// Supersede inherited/flat seals BEFORE writing this session's dir (keepSlug = our own).
|
|
2643
|
+
pruneSupersededSeals(deliveryDir, slug);
|
|
2644
|
+
fs.mkdirSync(sessionDeliveryDir, { recursive: true });
|
|
2194
2645
|
|
|
2195
2646
|
// Required: trust.bundle (the CI anchor)
|
|
2196
|
-
fs.copyFileSync(bundleSrc, path.join(
|
|
2647
|
+
fs.copyFileSync(bundleSrc, path.join(sessionDeliveryDir, "trust.bundle"));
|
|
2197
2648
|
|
|
2198
2649
|
// Optional companions: checkpoint + signing artifacts
|
|
2199
2650
|
const companions = [
|
|
2200
2651
|
"trust.checkpoint.json",
|
|
2201
2652
|
"trust.checkpoint.intoto.json",
|
|
2202
2653
|
"trust.checkpoint.sig.json",
|
|
2654
|
+
"trust.checkpoint.attestation.json",
|
|
2203
2655
|
];
|
|
2204
2656
|
for (const filename of companions) {
|
|
2205
2657
|
const src = path.join(dir, filename);
|
|
2206
2658
|
if (fs.existsSync(src)) {
|
|
2207
|
-
fs.copyFileSync(src, path.join(
|
|
2659
|
+
fs.copyFileSync(src, path.join(sessionDeliveryDir, filename));
|
|
2208
2660
|
}
|
|
2209
2661
|
}
|
|
2210
2662
|
|
|
2211
|
-
process.stderr.write(`[publish-delivery] published trust.bundle and companions to ${
|
|
2663
|
+
process.stderr.write(`[publish-delivery] published trust.bundle and companions to ${sessionDeliveryDir} (per-session path, #379)\n`);
|
|
2212
2664
|
}
|
|
2213
2665
|
|
|
2214
2666
|
/**
|
|
@@ -2943,11 +3395,31 @@ function livenessLabel(status: string): string {
|
|
|
2943
3395
|
return status;
|
|
2944
3396
|
}
|
|
2945
3397
|
|
|
3398
|
+
/**
|
|
3399
|
+
* Delegate to the shared pure-CJS liveness reader's freshHolders() (scripts/hooks/lib/liveness-read.js),
|
|
3400
|
+
* mirroring the exact loader shape assignment-provider.ts already uses for the same helper (#291
|
|
3401
|
+
* Wave 2 Task 2.1) — so ensure-session's ownership guard computes freshness identically to
|
|
3402
|
+
* `assignment-provider status`'s own join, a single implementation. `readLivenessEvents` above
|
|
3403
|
+
* already loads this same module inline for its own narrower need (just the events reader); this
|
|
3404
|
+
* loader additionally exposes `freshHolders` for the guard's `computeEffectiveState` call.
|
|
3405
|
+
*/
|
|
3406
|
+
function loadLivenessReadHelper(): {
|
|
3407
|
+
readLivenessEvents: (streamPath: string) => AnyObj[];
|
|
3408
|
+
freshHolders: (events: AnyObj[], slug: string, selfActor: string, nowMs: number) => FreshHolder[];
|
|
3409
|
+
} {
|
|
3410
|
+
const _req = createRequire(import.meta.url);
|
|
3411
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-read.js");
|
|
3412
|
+
return _req(helperPath) as {
|
|
3413
|
+
readLivenessEvents: (streamPath: string) => AnyObj[];
|
|
3414
|
+
freshHolders: (events: AnyObj[], slug: string, selfActor: string, nowMs: number) => FreshHolder[];
|
|
3415
|
+
};
|
|
3416
|
+
}
|
|
3417
|
+
|
|
2946
3418
|
// ─── ADR 0012 lifecycle-driven liveness (default-on; opt-out via FLOW_AGENTS_LIVENESS) ──
|
|
2947
3419
|
// init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
|
|
2948
3420
|
// so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
|
|
2949
3421
|
// Additive + fail-open: a liveness-emit failure never affects the workflow command.
|
|
2950
|
-
const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
|
|
3422
|
+
export const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
|
|
2951
3423
|
/**
|
|
2952
3424
|
* Delegate to the shared pure-CJS resolver (scripts/hooks/lib/actor-identity.js), mirroring the
|
|
2953
3425
|
* createRequire pattern used by readLivenessEvents() above. Deliberately NO inline duplicate
|
|
@@ -2958,6 +3430,16 @@ function loadActorIdentityHelper(): {
|
|
|
2958
3430
|
resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
|
|
2959
3431
|
sanitizeSegment: (value: unknown) => string;
|
|
2960
3432
|
isUnresolvedActor: (actor: string) => boolean;
|
|
3433
|
+
// #291 Wave 2 Task 2.1: widened (additive only — every existing caller above keeps using only
|
|
3434
|
+
// the three fields already destructured) so resolveEnsureSessionActor() can reconstruct a real
|
|
3435
|
+
// ActorStruct {runtime, session_id, host} from the SAME exported primitives resolveActor()
|
|
3436
|
+
// already uses internally, and so serializeActor() is available for the ownership guard's
|
|
3437
|
+
// assignment-claim identity (a DIFFERENT identity concept from the flat actorKey used for
|
|
3438
|
+
// branch-naming/liveness — see resolveEnsureSessionActor's doc comment for why both exist).
|
|
3439
|
+
serializeActor: (actor: Partial<ActorStruct> | undefined) => string;
|
|
3440
|
+
detectRuntime: (env: NodeJS.ProcessEnv) => string;
|
|
3441
|
+
runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
|
|
3442
|
+
ancestorActorSeed: () => string;
|
|
2961
3443
|
} {
|
|
2962
3444
|
const _req = createRequire(import.meta.url);
|
|
2963
3445
|
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
|
|
@@ -2965,6 +3447,10 @@ function loadActorIdentityHelper(): {
|
|
|
2965
3447
|
resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
|
|
2966
3448
|
sanitizeSegment: (value: unknown) => string;
|
|
2967
3449
|
isUnresolvedActor: (actor: string) => boolean;
|
|
3450
|
+
serializeActor: (actor: Partial<ActorStruct> | undefined) => string;
|
|
3451
|
+
detectRuntime: (env: NodeJS.ProcessEnv) => string;
|
|
3452
|
+
runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
|
|
3453
|
+
ancestorActorSeed: () => string;
|
|
2968
3454
|
};
|
|
2969
3455
|
}
|
|
2970
3456
|
function resolveLivenessActor(): string {
|