@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.4.2](https://github.com/kontourai/flow-agents/compare/v3.4.1...v3.4.2) (2026-07-10)
4
+
5
+
6
+ ### Fixes
7
+
8
+ * enforce canonical Builder entry action ([#536](https://github.com/kontourai/flow-agents/issues/536)) ([bab5dfe](https://github.com/kontourai/flow-agents/commit/bab5dfe419a42b137dcb79bd276edf3268c5fb23))
9
+
10
+ ## [3.4.1](https://github.com/kontourai/flow-agents/compare/v3.4.0...v3.4.1) (2026-07-10)
11
+
12
+
13
+ ### Fixes
14
+
15
+ * resolve packaged Flow definitions in consumer repos ([#532](https://github.com/kontourai/flow-agents/issues/532)) ([3eb4a2e](https://github.com/kontourai/flow-agents/commit/3eb4a2e42d49a9c31b16ef62484cf5b0a001c7a1))
16
+
3
17
  ## [3.4.0](https://github.com/kontourai/flow-agents/compare/v3.3.0...v3.4.0) (2026-07-10)
4
18
 
5
19
 
package/agents/dev.json CHANGED
@@ -53,6 +53,11 @@
53
53
  "matcher": "*",
54
54
  "timeout_ms": 3000
55
55
  },
56
+ {
57
+ "command": "node ~/.flow-agents/scripts/hooks/run-hook.js pre:workflow-entry workflow-steering.js standard,strict",
58
+ "matcher": "*",
59
+ "timeout_ms": 5000
60
+ },
56
61
  {
57
62
  "command": "node ~/.flow-agents/scripts/hooks/run-hook.js pre:config-protection config-protection.js standard,strict",
58
63
  "matcher": "fs_write",
@@ -12,5 +12,6 @@ export interface BuilderFlowSessionResult {
12
12
  }
13
13
  export declare function startBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult>;
14
14
  export declare function syncBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult>;
15
+ export declare function recoverBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult>;
15
16
  export declare function syncBuilderFlowSessionIfPresent(sessionDir: string): Promise<BuilderFlowSessionResult | null>;
16
17
  export {};
@@ -5,8 +5,8 @@ import { expectationsForGate, openGates, readJson, runDir, sha256File, } from "@
5
5
  import { BUILDER_BUILD_FLOW_ID, BuilderBuildRunInputError, evaluateBuilderBuildRun, loadBuilderBuildRun, startBuilderBuildRun, } from "./builder-flow-run-adapter.js";
6
6
  export async function startBuilderFlowSession(input) {
7
7
  const context = resolveSessionContext(input.sessionDir);
8
- const sidecarState = readSidecarState(context);
9
- const subject = workflowSubject(sidecarState);
8
+ const sidecarSnapshot = readSidecarSnapshot(context);
9
+ const subject = workflowSubject(sidecarSnapshot.state);
10
10
  let run;
11
11
  try {
12
12
  run = await loadBuilderBuildRun({
@@ -36,6 +36,32 @@ export async function syncBuilderFlowSession(input) {
36
36
  });
37
37
  return syncAndProject(context, run);
38
38
  }
39
+ export async function recoverBuilderFlowSession(input) {
40
+ const context = resolveSessionContext(input.sessionDir);
41
+ const sidecarSnapshot = readSidecarSnapshot(context);
42
+ const subject = workflowSubject(sidecarSnapshot.state);
43
+ const run = await loadBuilderBuildRun({
44
+ cwd: context.projectRoot,
45
+ runId: context.slug,
46
+ });
47
+ if (run.state.subject !== subject) {
48
+ throw new BuilderBuildRunInputError("flow_run.state.subject", "must match the selected Work Item");
49
+ }
50
+ if (isRecord(run.state.params)
51
+ && Object.prototype.hasOwnProperty.call(run.state.params, "subject")
52
+ && run.state.params.subject !== subject) {
53
+ throw new BuilderBuildRunInputError("flow_run.state.params.subject", "must match the selected Work Item");
54
+ }
55
+ const projection = projectFlowRun(context, run, sidecarSnapshot.state);
56
+ writeProjection(context, projection, sidecarSnapshot.raw, "recovery");
57
+ return {
58
+ sessionDir: context.sessionDir,
59
+ projectRoot: context.projectRoot,
60
+ run,
61
+ projection,
62
+ attached: false,
63
+ };
64
+ }
39
65
  export async function syncBuilderFlowSessionIfPresent(sessionDir) {
40
66
  let context;
41
67
  try {
@@ -78,8 +104,9 @@ async function syncAndProject(context, initial) {
78
104
  }
79
105
  }
80
106
  }
81
- const projection = projectFlowRun(context, run);
82
- writeProjection(context, projection);
107
+ const sidecarSnapshot = readSidecarSnapshot(context);
108
+ const projection = projectFlowRun(context, run, sidecarSnapshot.state);
109
+ writeProjection(context, projection, sidecarSnapshot.raw, "projection");
83
110
  return {
84
111
  sessionDir: context.sessionDir,
85
112
  projectRoot: context.projectRoot,
@@ -95,6 +122,7 @@ function resolveSessionContext(sessionDirInput) {
95
122
  if (path.basename(artifactRoot) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") {
96
123
  throw new BuilderBuildRunInputError("sessionDir", "must be .kontourai/flow-agents/<slug>");
97
124
  }
125
+ assertSafeDirectory(sessionDir, artifactRoot, "sessionDir");
98
126
  const slug = path.basename(sessionDir);
99
127
  if (!slug || slug === "." || slug === "..") {
100
128
  throw new BuilderBuildRunInputError("sessionDir", "must name a session");
@@ -103,6 +131,7 @@ function resolveSessionContext(sessionDirInput) {
103
131
  if (!fs.existsSync(stateFile)) {
104
132
  throw new BuilderBuildRunInputError("sessionDir", "must contain state.json");
105
133
  }
134
+ assertSafeFile(stateFile, sessionDir, "state.json");
106
135
  return {
107
136
  sessionDir,
108
137
  artifactRoot,
@@ -112,18 +141,21 @@ function resolveSessionContext(sessionDirInput) {
112
141
  bundleFile: path.join(sessionDir, "trust.bundle"),
113
142
  };
114
143
  }
115
- function readSidecarState(context) {
116
- const value = JSON.parse(fs.readFileSync(context.stateFile, "utf8"));
144
+ function readSidecarSnapshot(context) {
145
+ assertSafeFile(context.stateFile, context.sessionDir, "state.json");
146
+ const raw = fs.readFileSync(context.stateFile, "utf8");
147
+ const value = JSON.parse(raw);
117
148
  if (!isRecord(value) || value.task_slug !== context.slug) {
118
149
  throw new BuilderBuildRunInputError("sessionDir", "state.json task_slug must match the session directory");
119
150
  }
120
- return value;
151
+ return { state: value, raw };
121
152
  }
122
153
  function workflowSubject(state) {
123
- const refs = Array.isArray(state.work_item_refs)
124
- ? state.work_item_refs.filter((value) => typeof value === "string" && value.length > 0)
125
- : [];
126
- if (refs.length !== 1) {
154
+ const refs = state.work_item_refs;
155
+ if (!Array.isArray(refs)
156
+ || refs.length !== 1
157
+ || typeof refs[0] !== "string"
158
+ || refs[0].trim().length === 0) {
127
159
  throw new BuilderBuildRunInputError("state.work_item_refs", "must contain exactly one selected Work Item for builder.build");
128
160
  }
129
161
  return refs[0];
@@ -174,8 +206,7 @@ function workflowSubjectRef(claim) {
174
206
  function manifestEvidence(manifest) {
175
207
  return Array.isArray(manifest.evidence) ? manifest.evidence.filter(isRecord) : [];
176
208
  }
177
- function projectFlowRun(context, run) {
178
- const sidecar = readSidecarState(context);
209
+ function projectFlowRun(context, run, sidecar) {
179
210
  const definition = JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8"));
180
211
  const gates = openGates(definition, run.state);
181
212
  const complete = run.state.status === "completed";
@@ -227,25 +258,150 @@ function projectFlowRun(context, run) {
227
258
  next_action: nextAction,
228
259
  };
229
260
  }
230
- function writeProjection(context, projection) {
231
- fs.writeFileSync(context.stateFile, `${JSON.stringify(projection, null, 2)}\n`);
232
- const pointerFiles = [path.join(context.artifactRoot, "current.json")];
261
+ function writeProjection(context, projection, expectedStateRaw, operation) {
262
+ const prepared = prepareProjectionWrites(context, projection, expectedStateRaw, operation);
263
+ assertProjectionTargetsUnchanged(context, prepared, operation);
264
+ for (const write of prepared.writes)
265
+ writeExistingFileNoFollow(write.file, write.content);
266
+ }
267
+ function prepareProjectionWrites(context, projection, expectedStateRaw, operation) {
268
+ const targets = [];
269
+ const writes = [];
270
+ const stateTarget = readProjectionTarget(context.stateFile, context.sessionDir, "state.json");
271
+ targets.push(stateTarget);
272
+ if (stateTarget.raw !== expectedStateRaw) {
273
+ throw new BuilderBuildRunInputError("state.json", `changed during ${operation}`);
274
+ }
275
+ writes.push({ file: context.stateFile, content: `${JSON.stringify(projection, null, 2)}\n` });
276
+ const pointerFiles = [];
277
+ const globalPointer = path.join(context.artifactRoot, "current.json");
278
+ const globalTarget = readOptionalProjectionTarget(globalPointer, context.artifactRoot, "current.json");
279
+ targets.push(globalTarget);
280
+ if (globalTarget.raw !== null)
281
+ pointerFiles.push(globalPointer);
233
282
  const actorRoot = path.join(context.artifactRoot, "current");
234
- if (fs.existsSync(actorRoot)) {
235
- pointerFiles.push(...fs.readdirSync(actorRoot)
236
- .filter((name) => name.endsWith(".json"))
237
- .map((name) => path.join(actorRoot, name)));
283
+ let actorEntries = null;
284
+ if (pathExistsNoFollow(actorRoot)) {
285
+ assertSafeDirectory(actorRoot, context.artifactRoot, "current directory");
286
+ actorEntries = fs.readdirSync(actorRoot).sort();
287
+ for (const name of actorEntries) {
288
+ const file = path.join(actorRoot, name);
289
+ const stat = fs.lstatSync(file);
290
+ if (stat.isSymbolicLink()) {
291
+ throw new BuilderBuildRunInputError("projection target", `current/${name} must not be a symbolic link`);
292
+ }
293
+ if (!name.endsWith(".json"))
294
+ continue;
295
+ if (!stat.isFile()) {
296
+ throw new BuilderBuildRunInputError("projection target", `current/${name} must be a regular file`);
297
+ }
298
+ const target = readProjectionTarget(file, actorRoot, `current/${name}`);
299
+ targets.push(target);
300
+ pointerFiles.push(file);
301
+ }
238
302
  }
239
303
  for (const file of pointerFiles) {
240
- if (!fs.existsSync(file))
241
- continue;
242
- const pointer = JSON.parse(fs.readFileSync(file, "utf8"));
304
+ const target = targets.find((candidate) => candidate.file === file);
305
+ const pointer = parseProjectionTarget(target);
243
306
  if (!isRecord(pointer) || pointer.active_slug !== context.slug)
244
307
  continue;
245
- pointer.active_flow_id = BUILDER_BUILD_FLOW_ID;
246
- pointer.active_step_id = projection.flow_run.current_step;
247
- pointer.updated_at = projection.updated_at;
248
- fs.writeFileSync(file, `${JSON.stringify(pointer, null, 2)}\n`);
308
+ const output = {
309
+ ...pointer,
310
+ active_flow_id: BUILDER_BUILD_FLOW_ID,
311
+ active_step_id: projection.flow_run.current_step,
312
+ updated_at: projection.updated_at,
313
+ };
314
+ writes.push({ file, content: `${JSON.stringify(output, null, 2)}\n` });
315
+ }
316
+ return { targets, actorEntries, writes };
317
+ }
318
+ function assertProjectionTargetsUnchanged(context, prepared, operation) {
319
+ const actorRoot = path.join(context.artifactRoot, "current");
320
+ const currentActorEntries = pathExistsNoFollow(actorRoot)
321
+ ? (assertSafeDirectory(actorRoot, context.artifactRoot, "current directory"), fs.readdirSync(actorRoot).sort())
322
+ : null;
323
+ if (JSON.stringify(currentActorEntries) !== JSON.stringify(prepared.actorEntries)) {
324
+ throw new BuilderBuildRunInputError("current", `directory changed during ${operation}`);
325
+ }
326
+ for (const target of prepared.targets) {
327
+ const current = readOptionalProjectionTarget(target.file, target.root, target.field);
328
+ if (current.raw !== target.raw) {
329
+ throw new BuilderBuildRunInputError(target.field, `changed during ${operation}`);
330
+ }
331
+ }
332
+ }
333
+ function readOptionalProjectionTarget(file, root, field) {
334
+ if (!pathExistsNoFollow(file))
335
+ return { file, raw: null, root, field };
336
+ return readProjectionTarget(file, root, field);
337
+ }
338
+ function readProjectionTarget(file, root, field) {
339
+ assertSafeFile(file, root, field);
340
+ return { file, raw: fs.readFileSync(file, "utf8"), root, field };
341
+ }
342
+ function parseProjectionTarget(target) {
343
+ try {
344
+ return JSON.parse(target.raw);
345
+ }
346
+ catch (error) {
347
+ throw new BuilderBuildRunInputError("projection target", `${target.field} must contain valid JSON: ${error instanceof Error ? error.message : String(error)}`);
348
+ }
349
+ }
350
+ function assertSafeDirectory(directory, root, field) {
351
+ if (!pathExistsNoFollow(directory)) {
352
+ throw new BuilderBuildRunInputError(field, "must exist");
353
+ }
354
+ const stat = fs.lstatSync(directory);
355
+ if (stat.isSymbolicLink()) {
356
+ throw new BuilderBuildRunInputError(field, "must not be a symbolic link");
357
+ }
358
+ if (!stat.isDirectory()) {
359
+ throw new BuilderBuildRunInputError(field, "must be a directory");
360
+ }
361
+ assertContainedPath(directory, root, field);
362
+ }
363
+ function pathExistsNoFollow(candidate) {
364
+ try {
365
+ fs.lstatSync(candidate);
366
+ return true;
367
+ }
368
+ catch (error) {
369
+ if (isRecord(error) && error.code === "ENOENT")
370
+ return false;
371
+ throw error;
372
+ }
373
+ }
374
+ function assertSafeFile(file, root, field) {
375
+ const stat = fs.lstatSync(file);
376
+ if (stat.isSymbolicLink()) {
377
+ throw new BuilderBuildRunInputError(field, "must not be a symbolic link");
378
+ }
379
+ if (!stat.isFile()) {
380
+ throw new BuilderBuildRunInputError(field, "must be a regular file");
381
+ }
382
+ assertContainedPath(file, root, field);
383
+ }
384
+ function assertContainedPath(candidate, root, field) {
385
+ if (!pathIsWithin(candidate, root)) {
386
+ throw new BuilderBuildRunInputError(field, "must remain within its expected artifact root");
387
+ }
388
+ const realCandidate = fs.realpathSync(candidate);
389
+ const realRoot = fs.realpathSync(root);
390
+ if (!pathIsWithin(realCandidate, realRoot)) {
391
+ throw new BuilderBuildRunInputError(field, "must not escape its expected artifact root");
392
+ }
393
+ }
394
+ function pathIsWithin(candidate, root) {
395
+ const relative = path.relative(root, candidate);
396
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
397
+ }
398
+ function writeExistingFileNoFollow(file, content) {
399
+ const descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW);
400
+ try {
401
+ fs.writeFileSync(descriptor, content);
402
+ }
403
+ finally {
404
+ fs.closeSync(descriptor);
249
405
  }
250
406
  }
251
407
  function stepAction(stepId) {
@@ -1,20 +1,29 @@
1
1
  import { flagString, parseArgs } from "../lib/args.js";
2
- import { startBuilderFlowSession, syncBuilderFlowSession } from "../builder-flow-runtime.js";
2
+ import { recoverBuilderFlowSession, startBuilderFlowSession, syncBuilderFlowSession } from "../builder-flow-runtime.js";
3
3
  export async function main(argv) {
4
4
  const parsed = parseArgs(argv);
5
5
  const action = parsed.positionals[0];
6
6
  const sessionDir = flagString(parsed.flags, "session-dir");
7
+ const validRecoveryArguments = parsed.positionals.length === 1
8
+ && Object.keys(parsed.flags).length === 1
9
+ && typeof parsed.flags["session-dir"] === "string";
10
+ if (action === "recover" && !validRecoveryArguments) {
11
+ console.error("Usage: flow-agents builder-run <start|sync|recover> --session-dir <path>");
12
+ return 64;
13
+ }
7
14
  if (!sessionDir) {
8
15
  console.error("builder-run requires --session-dir .kontourai/flow-agents/<slug>");
9
16
  return 64;
10
17
  }
11
- if (action !== "start" && action !== "sync") {
12
- console.error("Usage: flow-agents builder-run <start|sync> --session-dir <path>");
18
+ if (action !== "start" && action !== "sync" && action !== "recover") {
19
+ console.error("Usage: flow-agents builder-run <start|sync|recover> --session-dir <path>");
13
20
  return 64;
14
21
  }
15
22
  const result = action === "start"
16
23
  ? await startBuilderFlowSession({ sessionDir })
17
- : await syncBuilderFlowSession({ sessionDir });
24
+ : action === "sync"
25
+ ? await syncBuilderFlowSession({ sessionDir })
26
+ : await recoverBuilderFlowSession({ sessionDir });
18
27
  console.log(JSON.stringify({
19
28
  run_id: result.run.runId,
20
29
  definition_id: result.run.definitionId,
@@ -1692,12 +1692,16 @@ function initSidecars(dir, slug, sourceRequest, summary, nextAction, timestamp,
1692
1692
  // existing state.json's created_at when present; stamp only on true first-creation.
1693
1693
  // updated_at still reflects "now" on every call — that field is intentionally mutable.
1694
1694
  const retainedWorkItemRefs = workItemRefs.length > 0 ? workItemRefs : (Array.isArray(existingState.work_item_refs) ? existingState.work_item_refs : []);
1695
+ const nextActionSummary = typeof nextAction === "string" ? nextAction : String(nextAction.summary ?? summary);
1696
+ const nextActionPayload = typeof nextAction === "string"
1697
+ ? { status: "continue", summary: nextAction || summary }
1698
+ : { status: "continue", ...nextAction, summary: nextActionSummary };
1695
1699
  writeJson(path.join(dir, "state.json"), {
1696
1700
  ...sidecarBase(slug), status: initialLifecycle?.status ?? "planned", phase: initialLifecycle?.phase ?? "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
1697
1701
  ...(branch ? { branch } : {}),
1698
1702
  ...(retainedWorkItemRefs.length > 0 ? { work_item_refs: retainedWorkItemRefs } : {}),
1699
1703
  artifact_paths: relArtifacts(dir),
1700
- next_action: { status: "continue", summary: nextAction || summary },
1704
+ next_action: nextActionPayload,
1701
1705
  });
1702
1706
  writeJson(path.join(dir, "acceptance.json"), {
1703
1707
  ...sidecarBase(slug), source_request: sourceRequest,
@@ -1705,7 +1709,7 @@ function initSidecars(dir, slug, sourceRequest, summary, nextAction, timestamp,
1705
1709
  goal_fit: { status: "pending", summary: "Goal fit has not been verified yet." },
1706
1710
  });
1707
1711
  writeJson(path.join(dir, "handoff.json"), {
1708
- ...sidecarBase(slug), summary, current_state_ref: "state.json", next_steps: nextAction ? [nextAction] : [], blockers: [], warnings: [],
1712
+ ...sidecarBase(slug), summary, current_state_ref: "state.json", next_steps: nextActionSummary ? [nextActionSummary] : [], blockers: [], warnings: [],
1709
1713
  });
1710
1714
  }
1711
1715
  /** Read a `--*-json` flag's value as a file path (or `-` for stdin), mirroring
@@ -1964,9 +1968,16 @@ function ensureSession(p) {
1964
1968
  if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
1965
1969
  const phaseMap = entry.flowId ? resolvePhaseMap(entry.flowId, findRepoRootFromDir(dir)) : null;
1966
1970
  const initialPhase = Object.entries(phaseMap ?? {}).find(([, step]) => step === entry.firstStep)?.[0];
1971
+ const startCommand = `flow-agents builder-run start --session-dir .kontourai/flow-agents/${slug}`;
1967
1972
  const nextAction = entry.flowId
1968
1973
  ? entry.flowId === "builder.build"
1969
- ? `Start the canonical Flow run with \`flow-agents builder-run start --session-dir .kontourai/flow-agents/${slug}\`; activate \`pull-work\` for work item ${JSON.stringify(workItem.ref)}, and satisfy the declared gate before advancing.`
1974
+ ? {
1975
+ status: "continue",
1976
+ summary: `Start the canonical Flow run; activate \`pull-work\` for work item ${JSON.stringify(workItem.ref)}, and satisfy the declared gate before advancing.`,
1977
+ skills: ["pull-work"],
1978
+ command: startCommand,
1979
+ enforcement: "before_tool_use",
1980
+ }
1970
1981
  : `Continue at Flow step ${JSON.stringify(entry.stepId)} for work item ${JSON.stringify(workItem.ref)}; satisfy its declared gate before advancing.`
1971
1982
  : opt(p, "next-action", "Continue.");
1972
1983
  initSidecars(dir, slug, opt(p, "source-request"), opt(p, "summary"), nextAction, timestamp, md, [workItem.ref], initialPhase ? { status: "new", phase: initialPhase } : undefined);
@@ -20,8 +20,8 @@
20
20
  * - FLOW_AGENTS_FLOW_DEFS_DIR resolves into a runtime artifact directory
21
21
  * - The resolved path escapes the expected root (belt-and-suspenders)
22
22
  *
23
- * When the override is unsafe, it is ignored and the resolver uses the
24
- * canonical repoRoot/kits/ source for legitimate flows.
23
+ * An unsafe explicit override fails closed. Package fallback applies only to
24
+ * canonical lookup when no override was supplied.
25
25
  */
26
26
  export declare function resolveFlowFilePath(kitId: string, flowName: string, flowId: string, repoRoot: string, allowOverride?: boolean): string | null;
27
27
  /** A single gate expectation from a FlowDefinition expects[] entry. */
@@ -54,6 +54,35 @@ function isAgentWritableDir(resolvedDir) {
54
54
  return false;
55
55
  }
56
56
  }
57
+ function installedPackageRoot() {
58
+ let directory = path.dirname(fileURLToPath(import.meta.url));
59
+ while (true) {
60
+ if (fs.existsSync(path.join(directory, "package.json")) && fs.existsSync(path.join(directory, "kits"))) {
61
+ return directory;
62
+ }
63
+ const parent = path.dirname(directory);
64
+ if (parent === directory)
65
+ return null;
66
+ directory = parent;
67
+ }
68
+ }
69
+ function packagedFlowFile(kitId, flowName, consumerRoot) {
70
+ const packageRoot = installedPackageRoot();
71
+ if (!packageRoot || path.resolve(packageRoot) === path.resolve(consumerRoot))
72
+ return null;
73
+ const kitsRoot = path.resolve(packageRoot, "kits");
74
+ const candidate = path.resolve(kitsRoot, kitId, "flows", `${flowName}.flow.json`);
75
+ if (!candidate.startsWith(kitsRoot + path.sep))
76
+ return null;
77
+ try {
78
+ const realKitsRoot = fs.realpathSync.native(kitsRoot);
79
+ const realCandidate = fs.realpathSync.native(candidate);
80
+ return realCandidate.startsWith(realKitsRoot + path.sep) ? realCandidate : null;
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
57
86
  /**
58
87
  * Build and validate the FlowDefinition file path.
59
88
  *
@@ -62,8 +91,8 @@ function isAgentWritableDir(resolvedDir) {
62
91
  * - FLOW_AGENTS_FLOW_DEFS_DIR resolves into a runtime artifact directory
63
92
  * - The resolved path escapes the expected root (belt-and-suspenders)
64
93
  *
65
- * When the override is unsafe, it is ignored and the resolver uses the
66
- * canonical repoRoot/kits/ source for legitimate flows.
94
+ * An unsafe explicit override fails closed. Package fallback applies only to
95
+ * canonical lookup when no override was supplied.
67
96
  */
68
97
  export function resolveFlowFilePath(kitId, flowName, flowId, repoRoot, allowOverride = true) {
69
98
  // Primary defense: reject any slug containing traversal chars or non-identifier chars.
@@ -72,13 +101,11 @@ export function resolveFlowFilePath(kitId, flowName, flowId, repoRoot, allowOver
72
101
  const override = allowOverride ? process.env["FLOW_AGENTS_FLOW_DEFS_DIR"] : undefined;
73
102
  let expectedRoot;
74
103
  let flowFilePath;
104
+ let canonicalLookup = false;
75
105
  if (override) {
76
106
  const resolvedOverride = path.resolve(override);
77
107
  if (isAgentWritableDir(resolvedOverride)) {
78
- // Override targets an agent-writable runtime path; ignore it and use
79
- // the canonical kit root. The session will resolve the real kit flow.
80
- expectedRoot = path.resolve(repoRoot, "kits");
81
- flowFilePath = path.join(repoRoot, "kits", kitId, "flows", `${flowName}.flow.json`);
108
+ return null;
82
109
  }
83
110
  else {
84
111
  expectedRoot = resolvedOverride;
@@ -90,6 +117,7 @@ export function resolveFlowFilePath(kitId, flowName, flowId, repoRoot, allowOver
90
117
  else {
91
118
  expectedRoot = path.resolve(repoRoot, "kits");
92
119
  flowFilePath = path.join(repoRoot, "kits", kitId, "flows", `${flowName}.flow.json`);
120
+ canonicalLookup = true;
93
121
  }
94
122
  // Belt-and-suspenders: confirm the resolved path stays within the expected root.
95
123
  // After slug validation this is theoretically unreachable, but defense-in-depth
@@ -110,6 +138,11 @@ export function resolveFlowFilePath(kitId, flowName, flowId, repoRoot, allowOver
110
138
  return realPath;
111
139
  }
112
140
  catch {
141
+ if (canonicalLookup) {
142
+ const packaged = packagedFlowFile(kitId, flowName, repoRoot);
143
+ if (packaged)
144
+ return packaged;
145
+ }
113
146
  return resolvedPath;
114
147
  }
115
148
  }
@@ -363,6 +363,7 @@ function exportClaudeSettings() {
363
363
  hooks.UserPromptSubmit.push({ hooks: [shellHook(claudePolicy("UserPromptSubmit", "workflow-steering.js"), 30, "Running Flow Agents hook policy")] });
364
364
  hooks.PostToolUse.push({ hooks: [shellHook(claudePolicy("PostToolUse", "quality-gate.js"), 30, "Running Flow Agents hook policy")] });
365
365
  hooks.PostToolUse.push({ hooks: [shellHook(claudePolicy("PostToolUse", "evidence-capture.js"), 30, "Capturing Flow Agents command evidence")] });
366
+ hooks.PreToolUse.push({ hooks: [shellHook(claudePolicy("PreToolUse", "workflow-steering.js"), 30, "Enforcing Flow Agents projected action")] });
366
367
  hooks.PreToolUse.push({ hooks: [shellHook(claudePolicy("PreToolUse", "config-protection.js"), 30, "Running Flow Agents hook policy")] });
367
368
  return `${JSON.stringify({
368
369
  statusLine: { type: "command", command: 'bash -lc \'root="${CLAUDE_PROJECT_DIR:-$(pwd)}"; node "$root/scripts/statusline/flow-agents-statusline.js"\'' },
@@ -380,6 +381,7 @@ function exportCodexHooks() {
380
381
  hooks.SessionStart.push({ hooks: [shellHook(codexPolicy("workflow-steering.js"), 30, "Running Flow Agents hook policy")] });
381
382
  hooks.UserPromptSubmit.push({ hooks: [shellHook(codexPolicy("workflow-steering.js"), 30, "Running Flow Agents hook policy")] });
382
383
  hooks.PostToolUse.push({ hooks: [shellHook(codexPolicy("evidence-capture.js"), 30, "Capturing Flow Agents command evidence")] });
384
+ hooks.PreToolUse.push({ hooks: [shellHook(codexPolicy("workflow-steering.js"), 30, "Enforcing Flow Agents projected action")] });
383
385
  return `${JSON.stringify({ hooks }, null, 2)}\n`;
384
386
  }
385
387
  function copySharedContent(targetRoot, targetName, token) {
@@ -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
  };
@@ -35,6 +35,12 @@ workflow sidecar. Start the canonical run with:
35
35
  flow-agents builder-run start --session-dir .kontourai/flow-agents/<slug>
36
36
  ```
37
37
 
38
+ The initial sidecar projects this as `next_action.command` with
39
+ `enforcement: "before_tool_use"`. Runtime adapters deny unrelated tool calls until
40
+ the projected command runs. Starting the canonical run replaces that bootstrap
41
+ action with the ordinary Flow-step projection; later actions remain advisory while
42
+ the agent performs their declared skills and operations.
43
+
38
44
  `start` requires exactly one `state.work_item_refs` entry and uses that stable Work
39
45
  Item reference as the Flow run subject. It is idempotent for an existing canonical
40
46
  run. A direct primitive session without a Builder Flow stamp remains independent and
@@ -51,6 +57,28 @@ Synchronization is digest-idempotent. The same trust bundle is not attached twic
51
57
  Inspection loads the run without evaluating it, so invalid evidence is rejected
52
58
  before Flow mutation.
53
59
 
60
+ ## Recovery
61
+
62
+ Recover an interrupted canonical Builder session with:
63
+
64
+ ```bash
65
+ flow-agents builder-run recover --session-dir .kontourai/flow-agents/<slug>
66
+ ```
67
+
68
+ `recover` derives the Flow run id from the session directory slug; callers cannot
69
+ select a run id or step. It requires exactly one non-empty
70
+ `state.work_item_refs` entry, loads the existing run through Flow's canonical load
71
+ API, and verifies that the ref matches persisted `state.subject` and, when present,
72
+ `state.params.subject`. A missing, foreign, or corrupt run fails closed and is not
73
+ created or repaired.
74
+
75
+ Recovery is load/validate/project only. It computes the complete projection before
76
+ updating `state.json`, the matching global `current.json`, and matching per-actor
77
+ current pointers. It does not inspect or attach `trust.bundle`, evaluate gates, or
78
+ write any file in `.kontourai/flow/runs/<slug>/`; the complete Flow run tree remains
79
+ byte-identical. Use `sync`, not `recover`, to attach recorded evidence and evaluate
80
+ the current gate.
81
+
54
82
  ## Trust Binding
55
83
 
56
84
  Claims relevant to the current gate must carry