@kontourai/flow-agents 3.4.0 → 3.4.2

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.
@@ -123,16 +123,16 @@ Flow Agents currently ships five canonical policy classes. Each policy class has
123
123
 
124
124
  **Canonical script**: `scripts/hooks/workflow-steering.js`
125
125
 
126
- **Canonical trigger event**: `userPromptSubmit` and `agentSpawn`/`SessionStart` (active-goal re-grounding), `postToolUse` (after `InvokeSubagents` tool calls)
126
+ **Canonical trigger event**: `userPromptSubmit` and `agentSpawn`/`SessionStart` (active-goal re-grounding), `postToolUse` (after `InvokeSubagents` tool calls), and `preToolUse` when the projected `next_action` explicitly declares `enforcement: "before_tool_use"`
127
127
 
128
128
  **Inputs consumed**:
129
129
  - `.kontourai/flow-agents/<slug>/state.json` — current workflow phase and status
130
130
  - `.kontourai/flow-agents/<slug>/critique.json` — open critique findings
131
131
  - `docs/context-map.md` — structure hint for repo navigation
132
132
 
133
- **Decision contract**: Non-blocking. Always exits 0. Appends steering text to the agent's context via `additionalContext` in the hook response. Does not block any action. It re-grounds the active workflow goal (status, phase, recorded next step) at the start of every user turn — not only for flagged/blocked states — and on `SessionStart`, which fires after context compaction and on resume. This is the mechanism that keeps an in-flight goal alive across context loss instead of relying on the model voluntarily re-reading the sidecar.
133
+ **Decision contract**: Advisory by default. It appends steering text to the agent's context via `additionalContext` and re-grounds the active workflow goal (status, phase, recorded next step) at the start of every user turn — not only for flagged/blocked states — and on `SessionStart`, which fires after context compaction and on resume. A structured `next_action` may explicitly declare a non-empty `command` with `enforcement: "before_tool_use"`; at `PreToolUse`, the canonical script then exits 2 for every unrelated call and includes the exact projected command in the denial reason. The exact projected command is allowed. This narrow bootstrap policy keeps workflow authority in the projected state instead of relying on the model voluntarily following prompt text; ordinary Flow-step projections remain advisory.
134
134
 
135
- **Degradation when host lacks trigger**: If the host has no `userPromptSubmit`-equivalent hook, workflow steering is silent. The agent receives no ambient phase reminders at turn start. This is a capability loss, not a blocking failure. Log the gap in the adapter's conformance declaration as `userPromptSubmit: no native equivalent — steering context injection unavailable`.
135
+ **Degradation when host lacks trigger**: If the host has no `userPromptSubmit`-equivalent hook, workflow steering is silent and the agent receives no ambient phase reminders at turn start. If the host has no block-capable `preToolUse` equivalent, explicit projected-action enforcement degrades to the same advisory command guidance. These are capability losses, not reasons to invent a second lifecycle authority. Record each missing capability in the adapter's conformance declaration.
136
136
 
137
137
  **Codex live hook influence caveat**: Codex hook influence on live sessions is limited — the agent may not honor all injected context. The hook influence behavioral cases in `evals/fixtures/hook-influence/cases.json` document which behaviors are expected (`agent_must_do`) versus which may only be soft guidance. Adapters on similar runtimes should apply the same classification.
138
138
 
@@ -550,7 +550,7 @@ For structured `run()` responses (native import form), the return value is:
550
550
  | config-protection | Fail-closed (exit 2 on protected file) | Yes — hook runtime errors exit 0 | Yes (preToolUse) |
551
551
  | quality-gate | Fail-open (exit 0 always) | Yes | No |
552
552
  | stop-goal-fit | Engine default warn (fail-open); blocks in `FLOW_AGENTS_GOAL_FIT_MODE=block` (shipped L2 default) | Yes — hook runtime errors exit 0 | Yes (stop, block mode) |
553
- | workflow-steering | Fail-open (exit 0 always) | Yes | No |
553
+ | workflow-steering | Advisory; exit 2 only for an explicit `before_tool_use` projected command | Yes — hook runtime and malformed-state errors exit 0 | Yes (preToolUse, explicit projection only) |
554
554
  | evidence-capture | Fail-open (exit 0 always) | Yes — capture errors never block or corrupt the log | No |
555
555
 
556
556
  **Telemetry**: Always fail-open. Hook runtime errors in telemetry scripts must never block agent work.
@@ -591,6 +591,18 @@ Start a newly selected session with `flow-agents builder-run start --session-dir
591
591
  [`docs/spec/builder-flow-runtime.md`](spec/builder-flow-runtime.md) for the ownership,
592
592
  trust-binding, route-back, and artifact-root contract.
593
593
 
594
+ If the same Builder slice is interrupted and its sidecar/current pointers may be
595
+ stale, restore the projection from the existing canonical Flow run with:
596
+
597
+ ```bash
598
+ flow-agents builder-run recover --session-dir .kontourai/flow-agents/<slug>
599
+ ```
600
+
601
+ The session slug is the run identity; recovery accepts no caller-selected run or
602
+ step. It validates the sole Work Item binding and updates only the sidecar/current
603
+ projection, leaving the entire Flow run byte-identical. Recovery never attaches or
604
+ evaluates `trust.bundle`; use `builder-run sync` for that separate operation.
605
+
594
606
  When a session resumes (after context compaction, an agent restart, or a cross-session
595
607
  handoff), the workflow-steering hook emits a `RESUME:` block on `SessionStart` that
596
608
  gives the resuming agent immediate situational awareness without blocking or auto-deciding.
@@ -68,7 +68,10 @@ if (state.status !== 'new' || state.phase !== 'pickup') process.exit(1);
68
68
  if (JSON.stringify(state.work_item_refs) !== JSON.stringify(['local:local-request'])) process.exit(1);
69
69
  if (workItem.id !== 'local-request' || workItem.title !== 'Local request') process.exit(1);
70
70
  if (workItem.source_provider?.kind !== 'local' || workItem.source_provider?.path !== 'work-item.json') process.exit(1);
71
- if (!state.next_action?.summary?.includes('builder-run start') || !state.next_action?.summary?.includes('`pull-work`')) process.exit(1);
71
+ if (state.next_action?.command !== 'flow-agents builder-run start --session-dir .kontourai/flow-agents/local-request') process.exit(1);
72
+ if (state.next_action?.enforcement !== 'before_tool_use') process.exit(1);
73
+ if (JSON.stringify(state.next_action?.skills) !== JSON.stringify(['pull-work'])) process.exit(1);
74
+ if (!state.next_action?.summary?.includes('`pull-work`')) process.exit(1);
72
75
  NODE
73
76
  then
74
77
  pass "providerless request creates a local Work Item and starts at pull-work"
@@ -592,13 +592,14 @@ function hasWorkflowSteering(file, ...eventNames) {
592
592
  }
593
593
  for (const file of process.argv.slice(2)) {
594
594
  if (!hasWorkflowSteering(file, "UserPromptSubmit", "userPromptSubmit")) throw new Error(`missing prompt-submit workflow steering: ${file}`);
595
+ if (!hasWorkflowSteering(file, "PreToolUse", "preToolUse")) throw new Error(`missing pre-tool workflow entry enforcement: ${file}`);
595
596
  }
596
597
  console.log("ok");
597
598
  NODE
598
599
  then
599
- _pass "installed bundles wire prompt-submit workflow steering across Claude Code, Codex, and Kiro"
600
+ _pass "installed bundles wire prompt-submit steering and pre-tool entry enforcement across Claude Code, Codex, and Kiro"
600
601
  else
601
- _fail "installed bundles do not wire prompt-submit workflow steering consistently"
602
+ _fail "installed bundles do not wire workflow steering consistently"
602
603
  fi
603
604
 
604
605
  if [[ -f "$OPENCODE_DEST/.opencode/plugins/flow-agents.js" ]] && node - "$OPENCODE_DEST/.opencode/plugins/flow-agents.js" <<'NODE'
@@ -21,6 +21,78 @@ mkdir -p "$REPO/docs"
21
21
  printf '# Test Repo\n' > "$REPO/AGENTS.md"
22
22
  printf '# Context Map\n' > "$REPO/docs/context-map.md"
23
23
 
24
+ ENTRY_REPO="$TMPDIR_EVAL/entry-repo"
25
+ ENTRY_COMMAND='flow-agents builder-run start --session-dir .kontourai/flow-agents/entry-demo'
26
+ mkdir -p "$ENTRY_REPO/.kontourai/flow-agents/entry-demo"
27
+ printf '# Entry Repo\n' > "$ENTRY_REPO/AGENTS.md"
28
+ cat > "$ENTRY_REPO/.kontourai/flow-agents/entry-demo/state.json" <<JSON
29
+ {
30
+ "schema_version": "1.0",
31
+ "task_slug": "entry-demo",
32
+ "status": "new",
33
+ "phase": "pickup",
34
+ "updated_at": "2026-07-10T00:00:00Z",
35
+ "next_action": {
36
+ "status": "continue",
37
+ "summary": "Start the canonical Flow run.",
38
+ "skills": ["pull-work"],
39
+ "command": "$ENTRY_COMMAND",
40
+ "enforcement": "before_tool_use"
41
+ }
42
+ }
43
+ JSON
44
+
45
+ if node "$ROOT/scripts/hooks/workflow-steering.js" >"$TMPDIR_EVAL/entry-direct.out" 2>"$TMPDIR_EVAL/entry-direct.err" <<JSON
46
+ {"hook_event_name":"PreToolUse","cwd":"$ENTRY_REPO","tool_name":"Bash","tool_input":{"command":"git status --short"}}
47
+ JSON
48
+ then
49
+ _fail "workflow entry enforcement should block unrelated tools"
50
+ else
51
+ status=$?
52
+ if [[ "$status" -eq 2 ]] && rg -F -q "Run exactly: $ENTRY_COMMAND" "$TMPDIR_EVAL/entry-direct.err"; then
53
+ _pass "workflow entry enforcement blocks unrelated tools with the projected action"
54
+ else
55
+ _fail "workflow entry enforcement returned the wrong direct-hook decision: rc=$status $(cat "$TMPDIR_EVAL/entry-direct.err")"
56
+ fi
57
+ fi
58
+
59
+ if node "$ROOT/scripts/hooks/workflow-steering.js" >"$TMPDIR_EVAL/entry-allow.out" 2>"$TMPDIR_EVAL/entry-allow.err" <<JSON
60
+ {"hook_event_name":"PreToolUse","cwd":"$ENTRY_REPO","tool_name":"Bash","tool_input":{"command":"$ENTRY_COMMAND"}}
61
+ JSON
62
+ then
63
+ _pass "workflow entry enforcement allows the exact projected action"
64
+ else
65
+ _fail "workflow entry enforcement blocked the projected action: $(cat "$TMPDIR_EVAL/entry-allow.err")"
66
+ fi
67
+
68
+ for adapter in codex claude; do
69
+ if [[ "$adapter" == "codex" ]]; then
70
+ adapter_command=(node "$ROOT/scripts/hooks/codex-hook-adapter.js" pre:workflow-entry workflow-steering.js standard,strict)
71
+ else
72
+ adapter_command=(node "$ROOT/scripts/hooks/claude-hook-adapter.js" PreToolUse pre:workflow-entry workflow-steering.js standard,strict)
73
+ fi
74
+ if "${adapter_command[@]}" >"$TMPDIR_EVAL/entry-$adapter.out" 2>"$TMPDIR_EVAL/entry-$adapter.err" <<JSON
75
+ {"hook_event_name":"PreToolUse","cwd":"$ENTRY_REPO","tool_name":"Bash","tool_input":{"command":"git status --short"}}
76
+ JSON
77
+ then
78
+ if node - "$TMPDIR_EVAL/entry-$adapter.out" "$ENTRY_COMMAND" <<'NODE'
79
+ const fs = require('node:fs');
80
+ const payload = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
81
+ const expected = process.argv[3];
82
+ if (payload.hookSpecificOutput?.hookEventName !== 'PreToolUse') process.exit(1);
83
+ if (payload.hookSpecificOutput?.permissionDecision !== 'deny') process.exit(1);
84
+ if (!String(payload.hookSpecificOutput?.permissionDecisionReason || '').includes(expected)) process.exit(1);
85
+ NODE
86
+ then
87
+ _pass "$adapter adapter translates projected-action enforcement into a PreToolUse denial"
88
+ else
89
+ _fail "$adapter adapter returned the wrong projected-action denial: $(cat "$TMPDIR_EVAL/entry-$adapter.out")"
90
+ fi
91
+ else
92
+ _fail "$adapter adapter should translate a policy block without failing: $(cat "$TMPDIR_EVAL/entry-$adapter.err")"
93
+ fi
94
+ done
95
+
24
96
  cat > "$REPO/.kontourai/flow-agents/steering-demo/state.json" <<'JSON'
25
97
  {
26
98
  "schema_version": "1.0",
@@ -27,6 +27,13 @@ continue-work ties these together for **one job**: take a multi-slice work item
27
27
  - The request is **brand-new work** with nothing landed yet — that is selection from the backlog. Route to `pull-work`.
28
28
  - The request is to **resume the *same* interrupted slice** after a restart (same in-flight slice, mid-execution, picking up new hooks/logic) — that is the resume surface (#153), which reconstructs `state.json` + `handoff.json` + plan + `trust.bundle` for the *same* increment. continue-work advances to the *next* increment; it does not re-enter an unfinished one.
29
29
 
30
+ For that same-slice case, when a canonical Builder Flow run already exists, restore
31
+ its sidecar/current projection with `flow-agents builder-run recover --session-dir
32
+ .kontourai/flow-agents/<slug>` before following the resume surface. The command
33
+ derives run identity from the session slug and only loads, validates, and projects;
34
+ never invent or supply a run id or step, and use `builder-run sync` separately only
35
+ when recorded evidence should be attached and evaluated.
36
+
30
37
  If the boundary is ambiguous (is this the next slice or the same one?), stop and ask one question before routing. Do not silently assume.
31
38
 
32
39
  ## Boundary (ADR 0014)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/flow-agents",
3
- "version": "3.4.0",
3
+ "version": "3.4.2",
4
4
  "description": "Flow Agents — a Kontour product that applies Flow and Veritas discipline as a portable process layer inside the agent tools you already use: Claude Code, Codex, Kiro, opencode, pi, and GitHub Actions — with framework adapters (AWS Strands preview) on the same policy-engine contract.",
5
5
  "keywords": [
6
6
  "agents",
@@ -122,6 +122,13 @@
122
122
  "command": {
123
123
  "type": "string",
124
124
  "minLength": 1
125
+ },
126
+ "enforcement": {
127
+ "type": "string",
128
+ "enum": [
129
+ "advisory",
130
+ "before_tool_use"
131
+ ]
125
132
  }
126
133
  }
127
134
  }
@@ -9,7 +9,8 @@
9
9
  * survive context loss instead of relying on the model voluntarily re-reading
10
10
  * the sidecar.
11
11
  *
12
- * Non-blocking always exits 0.
12
+ * Advisory by default. A structured next action may explicitly require its
13
+ * projected command before unrelated tool use; only that PreToolUse case blocks.
13
14
  */
14
15
 
15
16
  'use strict';
@@ -282,6 +283,32 @@ function stateSteering(root) {
282
283
  return parts.join(' ');
283
284
  }
284
285
 
286
+ function normalizedCommand(value) {
287
+ return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
288
+ }
289
+
290
+ function toolCommands(input) {
291
+ const toolInput = input && input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {};
292
+ return [
293
+ toolInput.command,
294
+ toolInput.content && toolInput.content.command,
295
+ toolInput.args && toolInput.args.command,
296
+ ].map(normalizedCommand).filter(Boolean);
297
+ }
298
+
299
+ function beforeToolUseEnforcement(input, current) {
300
+ if (!input || input.hook_event_name !== 'PreToolUse' || !current) return null;
301
+ const next = current.payload && current.payload.next_action;
302
+ if (!next || next.status !== 'continue' || next.enforcement !== 'before_tool_use') return null;
303
+ const expected = normalizedCommand(next.command);
304
+ if (!expected) return null;
305
+ if (toolCommands(input).some(command => command === expected)) return null;
306
+ return {
307
+ exitCode: 2,
308
+ stderr: `[workflow-entry] Run the required projected action before using other tools. Run exactly: ${safeStateText(expected, 500)}`,
309
+ };
310
+ }
311
+
285
312
  function contextMapSteering(root) {
286
313
  const mapPath = path.join(root, 'docs', 'context-map.md');
287
314
  if (!fs.existsSync(mapPath)) return '';
@@ -572,6 +599,8 @@ function run(rawInput) {
572
599
  const toolInput = input.tool_input || {};
573
600
  const root = findRepoRoot(input.cwd || process.cwd());
574
601
  const current = latestWorkflowState(root);
602
+ const enforcement = beforeToolUseEnforcement(input, current);
603
+ if (enforcement) return enforcement;
575
604
  const hints = [];
576
605
  let shouldAppendWorkflowContext = false;
577
606
 
@@ -648,7 +677,14 @@ if (require.main === module) {
648
677
  data += chunk;
649
678
  });
650
679
  process.stdin.on('end', () => {
651
- process.stdout.write(String(run(data)));
680
+ const result = run(data);
681
+ if (result && typeof result === 'object') {
682
+ if (result.stderr) process.stderr.write(String(result.stderr) + (String(result.stderr).endsWith('\n') ? '' : '\n'));
683
+ if (Object.prototype.hasOwnProperty.call(result, 'stdout')) process.stdout.write(String(result.stdout || ''));
684
+ process.exitCode = Number.isInteger(result.exitCode) ? result.exitCode : 0;
685
+ } else {
686
+ process.stdout.write(String(result));
687
+ }
652
688
  });
653
689
  }
654
690
 
@@ -667,4 +703,6 @@ module.exports = {
667
703
  promptText,
668
704
  looksLikeImplementationWork,
669
705
  kitWorkflowSteering,
706
+ beforeToolUseEnforcement,
707
+ toolCommands,
670
708
  };
@@ -41,13 +41,14 @@ const path = require("node:path");
41
41
  const os = require("node:os");
42
42
 
43
43
  // ─── Marker strings ───────────────────────────────────────────────────────────
44
- // These three statusMessage strings identify every flow-agents managed hook.
44
+ // These statusMessage strings identify every flow-agents managed hook.
45
45
  // They are the same strings used by COLLISION_MARKER in src/cli/init.ts and
46
46
  // by exportClaudeSettings() / exportCodexHooks() in build-universal-bundles.ts.
47
47
  const FA_MARKERS = [
48
48
  "Recording Flow Agents telemetry",
49
49
  "Running Flow Agents hook policy",
50
50
  "Capturing Flow Agents command evidence",
51
+ "Enforcing Flow Agents projected action",
51
52
  ];
52
53
 
53
54
  const FA_OWNERSHIP_MARKERS = [
@@ -44,10 +44,28 @@ type SessionContext = {
44
44
  bundleFile: string;
45
45
  };
46
46
 
47
+ type SidecarSnapshot = {
48
+ state: AnyRecord;
49
+ raw: string;
50
+ };
51
+
52
+ type ProjectionTargetSnapshot = {
53
+ file: string;
54
+ raw: string | null;
55
+ root: string;
56
+ field: string;
57
+ };
58
+
59
+ type PreparedProjectionWrites = {
60
+ targets: ProjectionTargetSnapshot[];
61
+ actorEntries: string[] | null;
62
+ writes: Array<{ file: string; content: string }>;
63
+ };
64
+
47
65
  export async function startBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult> {
48
66
  const context = resolveSessionContext(input.sessionDir);
49
- const sidecarState = readSidecarState(context);
50
- const subject = workflowSubject(sidecarState);
67
+ const sidecarSnapshot = readSidecarSnapshot(context);
68
+ const subject = workflowSubject(sidecarSnapshot.state);
51
69
  let run: BuilderBuildRunResult;
52
70
  try {
53
71
  run = await loadBuilderBuildRun({
@@ -77,6 +95,33 @@ export async function syncBuilderFlowSession(input: BuilderFlowSessionInput): Pr
77
95
  return syncAndProject(context, run);
78
96
  }
79
97
 
98
+ export async function recoverBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult> {
99
+ const context = resolveSessionContext(input.sessionDir);
100
+ const sidecarSnapshot = readSidecarSnapshot(context);
101
+ const subject = workflowSubject(sidecarSnapshot.state);
102
+ const run = await loadBuilderBuildRun({
103
+ cwd: context.projectRoot,
104
+ runId: context.slug,
105
+ });
106
+ if (run.state.subject !== subject) {
107
+ throw new BuilderBuildRunInputError("flow_run.state.subject", "must match the selected Work Item");
108
+ }
109
+ if (isRecord(run.state.params)
110
+ && Object.prototype.hasOwnProperty.call(run.state.params, "subject")
111
+ && run.state.params.subject !== subject) {
112
+ throw new BuilderBuildRunInputError("flow_run.state.params.subject", "must match the selected Work Item");
113
+ }
114
+ const projection = projectFlowRun(context, run, sidecarSnapshot.state);
115
+ writeProjection(context, projection, sidecarSnapshot.raw, "recovery");
116
+ return {
117
+ sessionDir: context.sessionDir,
118
+ projectRoot: context.projectRoot,
119
+ run,
120
+ projection,
121
+ attached: false,
122
+ };
123
+ }
124
+
80
125
  export async function syncBuilderFlowSessionIfPresent(sessionDir: string): Promise<BuilderFlowSessionResult | null> {
81
126
  let context: SessionContext;
82
127
  try {
@@ -119,8 +164,9 @@ async function syncAndProject(context: SessionContext, initial: BuilderBuildRunR
119
164
  }
120
165
  }
121
166
  }
122
- const projection = projectFlowRun(context, run);
123
- writeProjection(context, projection);
167
+ const sidecarSnapshot = readSidecarSnapshot(context);
168
+ const projection = projectFlowRun(context, run, sidecarSnapshot.state);
169
+ writeProjection(context, projection, sidecarSnapshot.raw, "projection");
124
170
  return {
125
171
  sessionDir: context.sessionDir,
126
172
  projectRoot: context.projectRoot,
@@ -137,6 +183,7 @@ function resolveSessionContext(sessionDirInput: string): SessionContext {
137
183
  if (path.basename(artifactRoot) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") {
138
184
  throw new BuilderBuildRunInputError("sessionDir", "must be .kontourai/flow-agents/<slug>");
139
185
  }
186
+ assertSafeDirectory(sessionDir, artifactRoot, "sessionDir");
140
187
  const slug = path.basename(sessionDir);
141
188
  if (!slug || slug === "." || slug === "..") {
142
189
  throw new BuilderBuildRunInputError("sessionDir", "must name a session");
@@ -145,6 +192,7 @@ function resolveSessionContext(sessionDirInput: string): SessionContext {
145
192
  if (!fs.existsSync(stateFile)) {
146
193
  throw new BuilderBuildRunInputError("sessionDir", "must contain state.json");
147
194
  }
195
+ assertSafeFile(stateFile, sessionDir, "state.json");
148
196
  return {
149
197
  sessionDir,
150
198
  artifactRoot,
@@ -155,19 +203,22 @@ function resolveSessionContext(sessionDirInput: string): SessionContext {
155
203
  };
156
204
  }
157
205
 
158
- function readSidecarState(context: SessionContext): AnyRecord {
159
- const value = JSON.parse(fs.readFileSync(context.stateFile, "utf8"));
206
+ function readSidecarSnapshot(context: SessionContext): SidecarSnapshot {
207
+ assertSafeFile(context.stateFile, context.sessionDir, "state.json");
208
+ const raw = fs.readFileSync(context.stateFile, "utf8");
209
+ const value = JSON.parse(raw);
160
210
  if (!isRecord(value) || value.task_slug !== context.slug) {
161
211
  throw new BuilderBuildRunInputError("sessionDir", "state.json task_slug must match the session directory");
162
212
  }
163
- return value;
213
+ return { state: value, raw };
164
214
  }
165
215
 
166
216
  function workflowSubject(state: AnyRecord): string {
167
- const refs = Array.isArray(state.work_item_refs)
168
- ? state.work_item_refs.filter((value: unknown): value is string => typeof value === "string" && value.length > 0)
169
- : [];
170
- if (refs.length !== 1) {
217
+ const refs = state.work_item_refs;
218
+ if (!Array.isArray(refs)
219
+ || refs.length !== 1
220
+ || typeof refs[0] !== "string"
221
+ || refs[0].trim().length === 0) {
171
222
  throw new BuilderBuildRunInputError("state.work_item_refs", "must contain exactly one selected Work Item for builder.build");
172
223
  }
173
224
  return refs[0]!;
@@ -225,8 +276,7 @@ function manifestEvidence(manifest: JsonObject): AnyRecord[] {
225
276
  return Array.isArray(manifest.evidence) ? manifest.evidence.filter(isRecord) : [];
226
277
  }
227
278
 
228
- function projectFlowRun(context: SessionContext, run: BuilderBuildRunResult): AnyRecord {
229
- const sidecar = readSidecarState(context);
279
+ function projectFlowRun(context: SessionContext, run: BuilderBuildRunResult, sidecar: AnyRecord): AnyRecord {
230
280
  const definition = JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8"));
231
281
  const gates = openGates(definition, run.state) as Array<FlowGate & { id: string }>;
232
282
  const complete = run.state.status === "completed";
@@ -279,23 +329,164 @@ function projectFlowRun(context: SessionContext, run: BuilderBuildRunResult): An
279
329
  };
280
330
  }
281
331
 
282
- function writeProjection(context: SessionContext, projection: AnyRecord): void {
283
- fs.writeFileSync(context.stateFile, `${JSON.stringify(projection, null, 2)}\n`);
284
- const pointerFiles = [path.join(context.artifactRoot, "current.json")];
332
+ function writeProjection(context: SessionContext, projection: AnyRecord, expectedStateRaw: string, operation: string): void {
333
+ const prepared = prepareProjectionWrites(context, projection, expectedStateRaw, operation);
334
+ assertProjectionTargetsUnchanged(context, prepared, operation);
335
+ for (const write of prepared.writes) writeExistingFileNoFollow(write.file, write.content);
336
+ }
337
+
338
+ function prepareProjectionWrites(
339
+ context: SessionContext,
340
+ projection: AnyRecord,
341
+ expectedStateRaw: string,
342
+ operation: string,
343
+ ): PreparedProjectionWrites {
344
+ const targets: ProjectionTargetSnapshot[] = [];
345
+ const writes: Array<{ file: string; content: string }> = [];
346
+ const stateTarget = readProjectionTarget(context.stateFile, context.sessionDir, "state.json");
347
+ targets.push(stateTarget);
348
+ if (stateTarget.raw !== expectedStateRaw) {
349
+ throw new BuilderBuildRunInputError("state.json", `changed during ${operation}`);
350
+ }
351
+ writes.push({ file: context.stateFile, content: `${JSON.stringify(projection, null, 2)}\n` });
352
+
353
+ const pointerFiles: string[] = [];
354
+ const globalPointer = path.join(context.artifactRoot, "current.json");
355
+ const globalTarget = readOptionalProjectionTarget(globalPointer, context.artifactRoot, "current.json");
356
+ targets.push(globalTarget);
357
+ if (globalTarget.raw !== null) pointerFiles.push(globalPointer);
358
+
285
359
  const actorRoot = path.join(context.artifactRoot, "current");
286
- if (fs.existsSync(actorRoot)) {
287
- pointerFiles.push(...fs.readdirSync(actorRoot)
288
- .filter((name) => name.endsWith(".json"))
289
- .map((name) => path.join(actorRoot, name)));
360
+ let actorEntries: string[] | null = null;
361
+ if (pathExistsNoFollow(actorRoot)) {
362
+ assertSafeDirectory(actorRoot, context.artifactRoot, "current directory");
363
+ actorEntries = fs.readdirSync(actorRoot).sort();
364
+ for (const name of actorEntries) {
365
+ const file = path.join(actorRoot, name);
366
+ const stat = fs.lstatSync(file);
367
+ if (stat.isSymbolicLink()) {
368
+ throw new BuilderBuildRunInputError("projection target", `current/${name} must not be a symbolic link`);
369
+ }
370
+ if (!name.endsWith(".json")) continue;
371
+ if (!stat.isFile()) {
372
+ throw new BuilderBuildRunInputError("projection target", `current/${name} must be a regular file`);
373
+ }
374
+ const target = readProjectionTarget(file, actorRoot, `current/${name}`);
375
+ targets.push(target);
376
+ pointerFiles.push(file);
377
+ }
290
378
  }
379
+
291
380
  for (const file of pointerFiles) {
292
- if (!fs.existsSync(file)) continue;
293
- const pointer = JSON.parse(fs.readFileSync(file, "utf8"));
381
+ const target = targets.find((candidate) => candidate.file === file)!;
382
+ const pointer = parseProjectionTarget(target);
294
383
  if (!isRecord(pointer) || pointer.active_slug !== context.slug) continue;
295
- pointer.active_flow_id = BUILDER_BUILD_FLOW_ID;
296
- pointer.active_step_id = projection.flow_run.current_step;
297
- pointer.updated_at = projection.updated_at;
298
- fs.writeFileSync(file, `${JSON.stringify(pointer, null, 2)}\n`);
384
+ const output = {
385
+ ...pointer,
386
+ active_flow_id: BUILDER_BUILD_FLOW_ID,
387
+ active_step_id: projection.flow_run.current_step,
388
+ updated_at: projection.updated_at,
389
+ };
390
+ writes.push({ file, content: `${JSON.stringify(output, null, 2)}\n` });
391
+ }
392
+ return { targets, actorEntries, writes };
393
+ }
394
+
395
+ function assertProjectionTargetsUnchanged(
396
+ context: SessionContext,
397
+ prepared: PreparedProjectionWrites,
398
+ operation: string,
399
+ ): void {
400
+ const actorRoot = path.join(context.artifactRoot, "current");
401
+ const currentActorEntries = pathExistsNoFollow(actorRoot)
402
+ ? (assertSafeDirectory(actorRoot, context.artifactRoot, "current directory"), fs.readdirSync(actorRoot).sort())
403
+ : null;
404
+ if (JSON.stringify(currentActorEntries) !== JSON.stringify(prepared.actorEntries)) {
405
+ throw new BuilderBuildRunInputError("current", `directory changed during ${operation}`);
406
+ }
407
+ for (const target of prepared.targets) {
408
+ const current = readOptionalProjectionTarget(target.file, target.root, target.field);
409
+ if (current.raw !== target.raw) {
410
+ throw new BuilderBuildRunInputError(target.field, `changed during ${operation}`);
411
+ }
412
+ }
413
+ }
414
+
415
+ function readOptionalProjectionTarget(file: string, root: string, field: string): ProjectionTargetSnapshot {
416
+ if (!pathExistsNoFollow(file)) return { file, raw: null, root, field };
417
+ return readProjectionTarget(file, root, field);
418
+ }
419
+
420
+ function readProjectionTarget(file: string, root: string, field: string): ProjectionTargetSnapshot {
421
+ assertSafeFile(file, root, field);
422
+ return { file, raw: fs.readFileSync(file, "utf8"), root, field };
423
+ }
424
+
425
+ function parseProjectionTarget(target: ProjectionTargetSnapshot): unknown {
426
+ try {
427
+ return JSON.parse(target.raw!);
428
+ } catch (error) {
429
+ throw new BuilderBuildRunInputError("projection target", `${target.field} must contain valid JSON: ${error instanceof Error ? error.message : String(error)}`);
430
+ }
431
+ }
432
+
433
+ function assertSafeDirectory(directory: string, root: string, field: string): void {
434
+ if (!pathExistsNoFollow(directory)) {
435
+ throw new BuilderBuildRunInputError(field, "must exist");
436
+ }
437
+ const stat = fs.lstatSync(directory);
438
+ if (stat.isSymbolicLink()) {
439
+ throw new BuilderBuildRunInputError(field, "must not be a symbolic link");
440
+ }
441
+ if (!stat.isDirectory()) {
442
+ throw new BuilderBuildRunInputError(field, "must be a directory");
443
+ }
444
+ assertContainedPath(directory, root, field);
445
+ }
446
+
447
+ function pathExistsNoFollow(candidate: string): boolean {
448
+ try {
449
+ fs.lstatSync(candidate);
450
+ return true;
451
+ } catch (error) {
452
+ if (isRecord(error) && error.code === "ENOENT") return false;
453
+ throw error;
454
+ }
455
+ }
456
+
457
+ function assertSafeFile(file: string, root: string, field: string): void {
458
+ const stat = fs.lstatSync(file);
459
+ if (stat.isSymbolicLink()) {
460
+ throw new BuilderBuildRunInputError(field, "must not be a symbolic link");
461
+ }
462
+ if (!stat.isFile()) {
463
+ throw new BuilderBuildRunInputError(field, "must be a regular file");
464
+ }
465
+ assertContainedPath(file, root, field);
466
+ }
467
+
468
+ function assertContainedPath(candidate: string, root: string, field: string): void {
469
+ if (!pathIsWithin(candidate, root)) {
470
+ throw new BuilderBuildRunInputError(field, "must remain within its expected artifact root");
471
+ }
472
+ const realCandidate = fs.realpathSync(candidate);
473
+ const realRoot = fs.realpathSync(root);
474
+ if (!pathIsWithin(realCandidate, realRoot)) {
475
+ throw new BuilderBuildRunInputError(field, "must not escape its expected artifact root");
476
+ }
477
+ }
478
+
479
+ function pathIsWithin(candidate: string, root: string): boolean {
480
+ const relative = path.relative(root, candidate);
481
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
482
+ }
483
+
484
+ function writeExistingFileNoFollow(file: string, content: string): void {
485
+ const descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW);
486
+ try {
487
+ fs.writeFileSync(descriptor, content);
488
+ } finally {
489
+ fs.closeSync(descriptor);
299
490
  }
300
491
  }
301
492