@kontourai/flow-agents 3.9.0 → 3.10.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.
Files changed (106) hide show
  1. package/.github/workflows/ci.yml +8 -0
  2. package/CHANGELOG.md +17 -0
  3. package/README.md +1 -1
  4. package/build/src/cli/assignment-provider.js +4 -18
  5. package/build/src/cli/continuation-adapter.d.ts +10 -8
  6. package/build/src/cli/continuation-adapter.js +22 -4
  7. package/build/src/cli/validate-workflow-artifacts.js +21 -1
  8. package/build/src/cli/workflow-sidecar.js +15 -20
  9. package/build/src/cli/workflow.js +58 -11
  10. package/build/src/continuation-driver.d.ts +27 -3
  11. package/build/src/continuation-driver.js +97 -14
  12. package/build/src/index.d.ts +2 -2
  13. package/build/src/index.js +1 -1
  14. package/build/src/tools/build-universal-bundles.js +52 -25
  15. package/build/src/tools/validate-source-tree.js +5 -2
  16. package/context/scripts/hooks/lib/continuation-turn-authority.js +386 -0
  17. package/context/scripts/hooks/stop-goal-fit.js +244 -73
  18. package/context/scripts/hooks/workflow-steering.js +24 -15
  19. package/context/scripts/telemetry/lib/pricing.sh +18 -6
  20. package/context/scripts/telemetry/lib/transport.sh +139 -37
  21. package/context/scripts/telemetry/lib/usage.sh +282 -32
  22. package/context/scripts/telemetry/lib/usage_model_guard.sh +42 -0
  23. package/context/scripts/telemetry/telemetry.sh +76 -1
  24. package/docs/coordination-guide.md +21 -5
  25. package/docs/fixture-ownership.md +2 -1
  26. package/docs/public-workflow-cli.md +44 -0
  27. package/docs/spec/runtime-hook-surface.md +8 -4
  28. package/evals/acceptance/prove-capture-teeth-declared.sh +13 -0
  29. package/evals/acceptance/prove-capture-teeth.sh +13 -0
  30. package/evals/ci/run-baseline.sh +2 -0
  31. package/evals/fixtures/codex-legacy-agents/5273878130bdafc8a024a650bb5b66c9b003f1f859b5dc6e5b588cbf4ab23228.md +25 -0
  32. package/evals/fixtures/telemetry/usage-transcript-adversarial.jsonl +2 -0
  33. package/evals/fixtures/telemetry/usage-transcript-malformed-sandwich.jsonl +3 -0
  34. package/evals/fixtures/telemetry/usage-transcript-negative-tokens.jsonl +1 -0
  35. package/evals/fixtures/telemetry/usage-transcript-oversized-prefix.jsonl +201 -0
  36. package/evals/fixtures/telemetry/usage-transcript-oversized-tokens.jsonl +1 -0
  37. package/evals/fixtures/telemetry/usage-transcript-prefixed-secret-lower.jsonl +1 -0
  38. package/evals/fixtures/telemetry/usage-transcript-prefixed-secret-upper.jsonl +1 -0
  39. package/evals/fixtures/telemetry/usage-transcript-secret-model-jwt.jsonl +1 -0
  40. package/evals/fixtures/telemetry/usage-transcript-secret-model.jsonl +1 -0
  41. package/evals/fixtures/telemetry/usage-transcript-torn-trailing.jsonl +2 -0
  42. package/evals/integration/test_actor_identity.sh +63 -0
  43. package/evals/integration/test_assignment_provider_local_file.sh +34 -0
  44. package/evals/integration/test_builder_entry_enforcement.sh +5 -1
  45. package/evals/integration/test_bundle_install.sh +106 -17
  46. package/evals/integration/test_bundle_lifecycle.sh +17 -3
  47. package/evals/integration/test_captured_fail_reconciliation.sh +11 -0
  48. package/evals/integration/test_ci_actor_identity.sh +2 -2
  49. package/evals/integration/test_command_log_concurrency.sh +11 -0
  50. package/evals/integration/test_command_log_fork_classification.sh +11 -0
  51. package/evals/integration/test_command_log_integrity.sh +11 -0
  52. package/evals/integration/test_current_json_per_actor.sh +416 -21
  53. package/evals/integration/test_enforcer_expects_driven.sh +11 -0
  54. package/evals/integration/test_ensure_session_ownership_guard.sh +39 -0
  55. package/evals/integration/test_evidence_capture_hook.sh +25 -6
  56. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  57. package/evals/integration/test_flowdef_union_floor_regression.sh +11 -0
  58. package/evals/integration/test_gate_bypass_chain.sh +11 -0
  59. package/evals/integration/test_gate_lockdown.sh +10 -0
  60. package/evals/integration/test_goal_fit_ghost_session.sh +11 -0
  61. package/evals/integration/test_goal_fit_hook.sh +257 -9
  62. package/evals/integration/test_goal_fit_rederive.sh +11 -0
  63. package/evals/integration/test_install_merge.sh +141 -3
  64. package/evals/integration/test_liveness_conflict_injection.sh +39 -3
  65. package/evals/integration/test_liveness_heartbeat.sh +46 -9
  66. package/evals/integration/test_phase_map_and_gate_claim.sh +10 -1
  67. package/evals/integration/test_public_workflow_cli.sh +6 -6
  68. package/evals/integration/test_session_resume_roundtrip.sh +30 -0
  69. package/evals/integration/test_stop_hook_release.sh +111 -11
  70. package/evals/integration/test_telemetry_tool_usage.sh +394 -0
  71. package/evals/integration/test_verify_hold.sh +64 -1
  72. package/evals/integration/test_workflow_sidecar_writer.sh +10 -1
  73. package/evals/integration/test_workflow_steering_hook.sh +20 -4
  74. package/evals/run.sh +2 -0
  75. package/evals/static/test_universal_bundles.sh +123 -0
  76. package/package.json +1 -1
  77. package/packaging/README.md +21 -0
  78. package/packaging/codex-legacy-agents-fingerprints.json +33 -0
  79. package/scripts/README.md +2 -1
  80. package/scripts/audit-codex-legacy-agents.js +43 -0
  81. package/scripts/classify-codex-legacy-agents.js +71 -0
  82. package/scripts/hooks/evidence-capture.js +18 -6
  83. package/scripts/hooks/lib/actor-identity.js +62 -29
  84. package/scripts/hooks/lib/continuation-turn-authority.js +386 -0
  85. package/scripts/hooks/lib/current-pointer.js +114 -12
  86. package/scripts/hooks/lib/liveness-heartbeat.js +15 -12
  87. package/scripts/hooks/stop-goal-fit.js +244 -73
  88. package/scripts/hooks/workflow-steering.js +24 -15
  89. package/scripts/install-codex-home.sh +8 -7
  90. package/scripts/telemetry/lib/pricing.sh +18 -6
  91. package/scripts/telemetry/lib/transport.sh +71 -0
  92. package/scripts/telemetry/lib/usage.sh +282 -32
  93. package/scripts/telemetry/lib/usage_model_guard.sh +42 -0
  94. package/scripts/telemetry/telemetry.sh +76 -1
  95. package/src/cli/assignment-provider.ts +5 -26
  96. package/src/cli/builder-flow-runtime.test.mjs +93 -6
  97. package/src/cli/continuation-adapter.ts +33 -7
  98. package/src/cli/continuation-driver.test.mjs +741 -2
  99. package/src/cli/public-api.test.mjs +1 -0
  100. package/src/cli/validate-workflow-artifacts.ts +19 -1
  101. package/src/cli/workflow-sidecar.ts +18 -23
  102. package/src/cli/workflow.ts +71 -11
  103. package/src/continuation-driver.ts +125 -19
  104. package/src/index.ts +4 -0
  105. package/src/tools/build-universal-bundles.ts +61 -25
  106. package/src/tools/validate-source-tree.ts +5 -2
@@ -45,10 +45,22 @@ const {
45
45
  warnIfFailingOpenInsideGitTree,
46
46
  } = require('./lib/local-artifact-paths');
47
47
  const { resolveActor, isUnresolvedActor, detectRuntime } = require('./lib/actor-identity.js');
48
- const { readCurrentPointer } = require('./lib/current-pointer.js');
48
+ const { readCurrentPointer, readOwnCurrentPointer } = require('./lib/current-pointer.js');
49
49
  const { isRunnableCommandText, isAmbiguousAbsenceCommand } = require('./lib/runnable-command.js');
50
+ let validateActiveTurnAuthority = () => ({ valid: false, reason: 'continuation authority validator is unavailable' });
51
+ let validateSignedActiveTurnAssignmentAuthority = validateActiveTurnAuthority;
52
+ try {
53
+ ({ validateActiveTurnAuthority, validateSignedActiveTurnAssignmentAuthority } = require('./lib/continuation-turn-authority.js'));
54
+ } catch {
55
+ // A partial legacy install must retain ordinary hard Stop blocking, not fail
56
+ // open because the optional continuation feature is unavailable.
57
+ }
50
58
 
51
59
  const MAX_STDIN = 1024 * 1024;
60
+ const MAX_CANONICAL_FLOW_STATE_BYTES = 1024 * 1024;
61
+ const MAX_CANONICAL_FLOW_DEFINITION_BYTES = 1024 * 1024;
62
+ const CANONICAL_FLOW_STATUSES = new Set(['active', 'blocked', 'needs_decision', 'paused', 'canceled', 'completed', 'failed', 'accepted_by_exception']);
63
+ const CANONICAL_FLOW_TERMINAL_STATUSES = new Set(['completed', 'canceled', 'failed']);
52
64
  const ACTIVE_STATUSES = new Set([
53
65
  'planning',
54
66
  'planned',
@@ -164,15 +176,35 @@ function isEnvironmentError(line) {
164
176
  return /tsc[:\s]|command not found|npm ERR!|npm error|ENOENT|EACCES|Cannot find module|node_modules\/.bin|TypeScript version|version conflict|error TS[0-9]/i.test(line);
165
177
  }
166
178
 
179
+ function readPackageManifest(packageRoot) {
180
+ try {
181
+ const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
182
+ return manifest && typeof manifest === 'object' && !Array.isArray(manifest) ? manifest : null;
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ function resolveArtifactValidator() {
189
+ let packageRoot;
190
+ try { packageRoot = fs.realpathSync(path.resolve(__dirname, '..', '..')); } catch { return null; }
191
+ const rootManifest = readPackageManifest(packageRoot);
192
+ const buildManifest = readPackageManifest(path.join(packageRoot, 'build'));
193
+ if (rootManifest?.name !== '@kontourai/flow-agents'
194
+ && buildManifest?.name !== '@kontourai/flow-agents') return null;
195
+ const builtValidator = path.join(packageRoot, 'build', 'src', 'cli', 'validate-workflow-artifacts.js');
196
+ try {
197
+ const stat = fs.lstatSync(builtValidator);
198
+ if (stat.isFile() && !stat.isSymbolicLink()) return { packageRoot, builtValidator };
199
+ } catch { /* validator is unavailable until this package is built */ }
200
+ return null;
201
+ }
202
+
167
203
  function sidecarValidation(root, artifactDir) {
168
204
  const requireSidecars = String(process.env.FLOW_AGENTS_REQUIRE_SIDECARS || '').toLowerCase() === 'true';
169
205
  const requireCritique = String(process.env.FLOW_AGENTS_REQUIRE_CRITIQUE || '').toLowerCase() === 'true';
170
206
  if (!requireSidecars && !requireCritique && !hasSidecars(artifactDir)) return [];
171
207
 
172
- const packageRoot = fs.existsSync(path.join(root, 'package.json'))
173
- ? root
174
- : path.resolve(__dirname, '..', '..');
175
-
176
208
  let sidecarFiles = [];
177
209
  try {
178
210
  sidecarFiles = fs.readdirSync(artifactDir)
@@ -210,36 +242,25 @@ function sidecarValidation(root, artifactDir) {
210
242
 
211
243
  if (sidecarFiles.length === 0) return [];
212
244
 
213
- // Part 1 fix: invoke the already-built validator directly via `node`, bypassing
214
- // `npm run build` (tsc). npm-installed packages ship build/ in the package files,
215
- // so the compiled JS is always available. Only fall back to npm run if build/ is
216
- // absent (a raw dev checkout that hasn't been built yet).
217
- const builtValidator = path.join(packageRoot, 'build', 'src', 'cli', 'validate-workflow-artifacts.js');
218
- const hasBuild = fs.existsSync(builtValidator);
245
+ // Resolve validation from Flow Agents itself. A consumer package.json does not imply
246
+ // that the consumer owns this script; installed hooks commonly run inside unrelated
247
+ // Node repositories whose npm scripts must not become Flow Agents validators.
248
+ const validator = resolveArtifactValidator();
249
+ if (!validator) {
250
+ return [`${relative(root, artifactDir)} sidecar validation skipped: Flow Agents validator is unavailable`];
251
+ }
219
252
 
220
253
  const validatorArgs = ['--skip-markdown-validation'];
221
254
  if (requireSidecars) validatorArgs.push('--require-sidecars');
222
255
  if (requireCritique) validatorArgs.push('--require-critique');
223
256
  validatorArgs.push(artifactDir);
224
257
 
225
- let result;
226
- if (hasBuild) {
227
- // Direct node invocation: no tsc, no npm build step, works from any npm install.
228
- result = spawnSync(process.execPath, [builtValidator, ...validatorArgs], {
229
- cwd: packageRoot,
230
- encoding: 'utf8',
231
- timeout: 30000,
232
- });
233
- } else {
234
- // Dev checkout without build/: fall back to npm run (may trigger tsc).
235
- // If this also fails due to environment issues, Part 2 handles it below.
236
- const npmArgs = ['run', 'workflow:validate-artifacts', '--silent', '--', ...validatorArgs];
237
- result = spawnSync('npm', npmArgs, {
238
- cwd: packageRoot,
239
- encoding: 'utf8',
240
- timeout: 30000,
241
- });
242
- }
258
+ // Direct node invocation: no PATH executable lookup and no consumer npm script.
259
+ const result = spawnSync(process.execPath, [validator.builtValidator, ...validatorArgs], {
260
+ cwd: validator.packageRoot,
261
+ encoding: 'utf8',
262
+ timeout: 30000,
263
+ });
243
264
 
244
265
  // Part 2 fix: treat validator-environment failures as SKIP, never as blocking.
245
266
  // A spawn error (ENOENT, timeout) means the validator couldn't run at all.
@@ -335,14 +356,14 @@ async function tryLoadSurface() {
335
356
  // returns null when build/ is absent, require throws, or current.json has no
336
357
  // active_flow_id / active_step_id. The caller (bundleEnforcement, sidecarGuidance)
337
358
  // treats null as "no active FlowDefinition" and falls back to the workflow.* path.
338
- function loadActiveFlowStep(flowAgentsDir) {
359
+ function loadActiveFlowStep(flowAgentsDir, actorKey) {
339
360
  const packageRoot = path.resolve(__dirname, '..', '..');
340
361
  const builtResolver = path.join(packageRoot, 'build', 'src', 'lib', 'flow-resolver.js');
341
362
  if (!fs.existsSync(builtResolver)) return null; // hasBuild guard: no build/ yet
342
363
  try {
343
364
  const resolver = require(builtResolver);
344
365
  if (typeof resolver.resolveActiveFlowStep !== 'function') return null;
345
- return resolver.resolveActiveFlowStep(flowAgentsDir);
366
+ return resolver.resolveActiveFlowStep(flowAgentsDir, actorKey);
346
367
  } catch {
347
368
  return null; // require failed or resolver threw — fail-open
348
369
  }
@@ -1745,7 +1766,7 @@ async function bundleEnforcement(artifactDir, activeFlowStep) {
1745
1766
  * to scanning all of .kontourai/flow-agents (newest-mtime).
1746
1767
  */
1747
1768
  function preferredArtifactDir(flowAgentsDir) {
1748
- const { payload: current } = readCurrentPointer(flowAgentsDir, resolveActor(process.env).actor);
1769
+ const { payload: current } = readOwnCurrentPointer(flowAgentsDir, resolveActor(process.env).actor);
1749
1770
  if (!current) return null;
1750
1771
  const slug = current.artifact_dir || current.active_slug;
1751
1772
  if (typeof slug !== 'string' || !slug.trim()) return null;
@@ -1760,9 +1781,9 @@ function hasSidecarPresence(artifactDir) {
1760
1781
  }
1761
1782
 
1762
1783
  function canonicalFlowState(root, artifactDir) {
1763
- if (!artifactDir) return { state: null, error: null };
1784
+ if (!artifactDir) return { state: null, definition: null, error: null };
1764
1785
  const slug = path.basename(artifactDir);
1765
- if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) return { state: null, error: 'canonical Flow run slug is malformed' };
1786
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) return { state: null, definition: null, error: 'canonical Flow run slug is malformed' };
1766
1787
  const runDir = path.join(root, '.kontourai', 'flow', 'runs', slug);
1767
1788
  const components = [
1768
1789
  path.join(root, '.kontourai'),
@@ -1771,22 +1792,105 @@ function canonicalFlowState(root, artifactDir) {
1771
1792
  runDir,
1772
1793
  ];
1773
1794
  try {
1774
- for (const component of components) {
1775
- const stat = fs.lstatSync(component);
1776
- if (stat.isSymbolicLink() || !stat.isDirectory()) return { state: null, error: `canonical Flow run has an unsafe parent component: ${component}` };
1777
- }
1778
- const file = path.join(runDir, 'state.json');
1779
- const stat = fs.lstatSync(file);
1780
- if (stat.isSymbolicLink() || !stat.isFile()) return { state: null, error: 'canonical Flow state must be a non-symlink regular file' };
1781
- const state = JSON.parse(fs.readFileSync(file, 'utf8'));
1795
+ const parents = components.map(component => secureDirectoryIdentity(component, 'canonical Flow run parent'));
1796
+ const stateRead = readSecureCanonicalJson(path.join(runDir, 'state.json'), 'canonical Flow state', parents, MAX_CANONICAL_FLOW_STATE_BYTES);
1797
+ const definitionRead = readSecureCanonicalJson(path.join(runDir, 'definition.json'), 'canonical Flow definition', parents, MAX_CANONICAL_FLOW_DEFINITION_BYTES);
1798
+ const state = stateRead.value;
1799
+ const definition = definitionRead.value;
1782
1800
  if (!state || typeof state !== 'object' || Array.isArray(state)
1783
- || typeof state.status !== 'string' || !state.status.trim()
1801
+ || !CANONICAL_FLOW_STATUSES.has(state.status)
1802
+ || state.run_id !== slug
1803
+ || typeof state.definition_id !== 'string' || !state.definition_id
1804
+ || typeof state.definition_version !== 'string' || !state.definition_version
1784
1805
  || typeof state.current_step !== 'string' || !state.current_step.trim()) {
1785
- return { state: null, error: 'canonical Flow state is malformed' };
1806
+ return { state: null, definition: null, error: 'canonical Flow state is malformed' };
1807
+ }
1808
+ if (!definition || typeof definition !== 'object' || Array.isArray(definition)
1809
+ || typeof definition.id !== 'string' || !definition.id
1810
+ || typeof definition.version !== 'string' || !definition.version
1811
+ || !Array.isArray(definition.steps) || definition.steps.length === 0
1812
+ || definition.steps.some(step => !step || typeof step !== 'object' || Array.isArray(step) || typeof step.id !== 'string' || !step.id)) {
1813
+ return { state: null, definition: null, error: 'canonical Flow definition is malformed' };
1814
+ }
1815
+ if (state.definition_id !== definition.id || state.definition_version !== definition.version) {
1816
+ return { state: null, definition: null, error: 'canonical Flow definition identity does not match state' };
1786
1817
  }
1787
- return { state, error: null };
1818
+ if (!definition.steps.some(step => step.id === state.current_step)) {
1819
+ return { state: null, definition: null, error: 'canonical Flow current_step is not present in definition' };
1820
+ }
1821
+ assertSecureCanonicalReadStable(stateRead);
1822
+ assertSecureCanonicalReadStable(definitionRead);
1823
+ assertSecureDirectoriesStable(parents);
1824
+ return { state, definition, error: null };
1788
1825
  } catch (error) {
1789
- return { state: null, error: `canonical Flow state is unavailable or malformed: ${safeOneLine(error && error.message || error, 120)}` };
1826
+ return { state: null, definition: null, error: `canonical Flow state is unavailable or malformed: ${safeOneLine(error && error.message || error, 120)}` };
1827
+ }
1828
+ }
1829
+
1830
+ function readSecureCanonicalJson(file, label, parents, maxBytes) {
1831
+ const expected = secureFileIdentity(file, label);
1832
+ if (expected.size > maxBytes) throw new Error(`${label} exceeds maximum size`);
1833
+ assertSecureDirectoriesStable(parents);
1834
+ const noFollow = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
1835
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
1836
+ let bytes;
1837
+ try {
1838
+ const opened = fs.fstatSync(fd);
1839
+ if (!opened.isFile() || opened.size > maxBytes || !sameFileIdentity(expected, opened)) throw new Error(`${label} identity changed while opening`);
1840
+ bytes = fs.readFileSync(fd);
1841
+ const after = fs.fstatSync(fd);
1842
+ if (!sameFileIdentity(expected, after)) throw new Error(`${label} identity changed while reading`);
1843
+ } finally {
1844
+ fs.closeSync(fd);
1845
+ }
1846
+ const final = secureFileIdentity(file, label);
1847
+ if (!sameFileIdentity(expected, final)) throw new Error(`${label} identity changed after reading`);
1848
+ assertSecureDirectoriesStable(parents);
1849
+ return { value: JSON.parse(bytes.toString('utf8')), file, label, identity: expected };
1850
+ }
1851
+
1852
+ function assertSecureCanonicalReadStable(read) {
1853
+ const final = secureFileIdentity(read.file, read.label);
1854
+ if (!sameFileIdentity(read.identity, final)) throw new Error(`${read.label} identity changed before authorization`);
1855
+ }
1856
+
1857
+ function secureDirectoryIdentity(target, label) {
1858
+ const stat = fs.lstatSync(target);
1859
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${label} must be a non-symlink directory: ${target}`);
1860
+ return { path: target, realpath: fs.realpathSync(target), dev: stat.dev, ino: stat.ino };
1861
+ }
1862
+
1863
+ function secureFileIdentity(target, label) {
1864
+ const stat = fs.lstatSync(target);
1865
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`${label} must be a non-symlink regular file`);
1866
+ return { dev: stat.dev, ino: stat.ino, size: stat.size };
1867
+ }
1868
+
1869
+ function sameFileIdentity(left, right) {
1870
+ return Boolean(left && right && left.dev === right.dev && left.ino === right.ino);
1871
+ }
1872
+
1873
+ function assertSecureDirectoriesStable(parents) {
1874
+ for (const parent of parents) {
1875
+ const current = secureDirectoryIdentity(parent.path, 'canonical Flow run parent');
1876
+ if (!sameFileIdentity(parent, current) || current.realpath !== parent.realpath) throw new Error('canonical Flow run parent identity changed');
1877
+ }
1878
+ }
1879
+
1880
+ function validatedActiveTurnScope(root) {
1881
+ try {
1882
+ const runId = process.env.FLOW_AGENTS_CONTINUATION_RUN_ID;
1883
+ const turnSecret = process.env.FLOW_AGENTS_CONTINUATION_TURN_SECRET;
1884
+ if (typeof runId !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(runId) || typeof turnSecret !== 'string' || !turnSecret) return null;
1885
+ const artifactRoot = path.resolve(flowAgentsArtifactRoot(root));
1886
+ const candidate = path.resolve(artifactRoot, runId);
1887
+ if (path.dirname(candidate) !== artifactRoot) return null;
1888
+ const base = validateSignedActiveTurnAssignmentAuthority({ sessionDir: candidate, runId, turnSecret });
1889
+ if (!base.valid) return null;
1890
+ const canonicalFlow = canonicalFlowState(root, candidate);
1891
+ return { artifactDir: candidate, canonicalFlow, baseAuthority: base };
1892
+ } catch {
1893
+ return null;
1790
1894
  }
1791
1895
  }
1792
1896
 
@@ -1794,7 +1898,7 @@ function canonicalFlowState(root, artifactDir) {
1794
1898
  // return that slug so analyze() can log the staleness rather than silently falling back to
1795
1899
  // a global mtime scan that could resurface an abandoned/never-real session as active.
1796
1900
  function staleCurrentSlug(flowAgentsDir) {
1797
- const { payload: current } = readCurrentPointer(flowAgentsDir, resolveActor(process.env).actor);
1901
+ const { payload: current } = readOwnCurrentPointer(flowAgentsDir, resolveActor(process.env).actor);
1798
1902
  if (!current) return null;
1799
1903
  const slug = current.artifact_dir || current.active_slug;
1800
1904
  if (typeof slug !== 'string' || !slug.trim()) return null;
@@ -1908,9 +2012,35 @@ const FULL_BLOCK = /status:|Definition Of Done|Goal Fit|sidecar validation:|cont
1908
2012
 
1909
2013
  async function analyze(root, now = Date.now()) {
1910
2014
  const flowAgentsDirs = flowAgentsArtifactRootsForRead(root);
2015
+ const { actor: actorKey } = resolveActor(process.env);
2016
+ const activeTurnScope = validatedActiveTurnScope(root);
1911
2017
  // Scope to the session's current task when current.json names one, so an
1912
2018
  // unrelated active workflow elsewhere in the repo does not gate this stop.
1913
- const scoped = flowAgentsDirs.map(preferredArtifactDir).find(Boolean);
2019
+ const scoped = activeTurnScope?.artifactDir || flowAgentsDirs.map(preferredArtifactDir).find(Boolean);
2020
+
2021
+ // #440 D1/D2: a RESOLVED actor with no scoped own artifactDir (no per-actor pointer, or an
2022
+ // own pointer naming a nonexistent dir) never falls back to the legacy current.json or a
2023
+ // global newest-mtime scan for BLOCKING purposes — those are informational-only for a
2024
+ // resolved actor. Accepted gap: this actor's stop stays ungated until its next
2025
+ // workflow-sidecar command re-establishes its own per-actor pointer; it is never gated on
2026
+ // another actor's unrelated work. #589: a signed continuation active-turn scope (validated via
2027
+ // FLOW_AGENTS_CONTINUATION_RUN_ID/TURN_SECRET, independent of actor identity/ownership) is
2028
+ // resolved into `activeTurnScope` above and OR'd into `scoped` first, so a resolved actor
2029
+ // legitimately handling an authorized continuation-driver turn is never treated as "no own
2030
+ // work to scope to" here, even without its own per-actor current pointer — this check only
2031
+ // fires when NEITHER mechanism finds a scope.
2032
+ if (!scoped && !isUnresolvedActor(actorKey)) {
2033
+ const ownStale = flowAgentsDirs.map(staleCurrentSlug).find(Boolean);
2034
+ process.stderr.write(ownStale
2035
+ ? `[Hook] Goal Fit: actor "${safeOneLine(actorKey, 80)}"'s own current-pointer names slug "${safeOneLine(ownStale, 80)}" but no such session directory exists — ignoring the stale own pointer; other sessions' sidecars are informational only, not blocking (#440).\n`
2036
+ : `[Hook] Goal Fit: no per-actor current-pointer for actor "${safeOneLine(actorKey, 80)}" — stop-gate evidence scoping finds no own work to scope to; not blocking (any workflow-sidecar command re-establishes it; other sessions' sidecars are informational only, #440).\n`);
2037
+ return { warnings: [], blocking: false, activeFlowRun: false, latestArtifactDir: null };
2038
+ }
2039
+
2040
+ // Everything below this point is UNCHANGED for: (a) a resolved actor with a valid `scoped`
2041
+ // own artifactDir (the common, post-fix path), (b) an unresolved actor (full legacy +
2042
+ // global-scan compat, D3), and (c) an authorized continuation-driver turn (#589, scoped via
2043
+ // activeTurnScope regardless of actor ownership).
1914
2044
  // WS8 (AC10a): if current.json points at a nonexistent slug, LOG the staleness and, in
1915
2045
  // the global fallback, require sidecar presence so a stale pointer cannot resurface an
1916
2046
  // abandoned/never-real markdown-only directory as "the active session".
@@ -1928,7 +2058,9 @@ async function analyze(root, now = Date.now()) {
1928
2058
  artifacts = artifacts.filter(a => a && hasSidecarPresence(path.dirname(a.file)));
1929
2059
  }
1930
2060
 
1931
- const scopedCanonicalFlow = canonicalFlowState(root, scoped);
2061
+ const scopedCanonicalFlow = activeTurnScope && scoped === activeTurnScope.artifactDir
2062
+ ? activeTurnScope.canonicalFlow
2063
+ : canonicalFlowState(root, scoped);
1932
2064
  const scopedState = scoped ? readJsonFile(path.join(scoped, 'state.json')) : null;
1933
2065
  const scopedProjectedActive = Boolean(scopedState && scopedState.flow_run && normalizedStatus(scopedState.flow_run.status) === 'active');
1934
2066
  if (scopedProjectedActive && scopedCanonicalFlow.error) {
@@ -1940,18 +2072,25 @@ async function analyze(root, now = Date.now()) {
1940
2072
  gatePrefix: '[stop-gate]',
1941
2073
  };
1942
2074
  }
2075
+ if (activeTurnScope && scoped === activeTurnScope.artifactDir && scopedCanonicalFlow.error) {
2076
+ return {
2077
+ warnings: [`workflow state: canonical Flow state is unsafe or malformed for the signed continuation session: ${scopedCanonicalFlow.error}. Resolve the canonical run before stopping.`],
2078
+ blocking: true,
2079
+ activeFlowRun: false,
2080
+ activeTurnAuthority: false,
2081
+ latestArtifactDir: scoped,
2082
+ gatePrefix: '[stop-gate]',
2083
+ };
2084
+ }
1943
2085
  if (artifacts.length === 0) {
1944
- if (normalizedStatus(scopedCanonicalFlow.state?.status) === 'active') {
1945
- const activeStep = safeOneLine(scopedCanonicalFlow.state.current_step || 'unknown', 80);
1946
- return {
1947
- warnings: [`workflow state: canonical Flow run remains active at step ${activeStep}; complete or explicitly cancel the run before stopping.`],
1948
- blocking: true,
1949
- activeFlowRun: true,
1950
- latestArtifactDir: scoped,
1951
- gatePrefix: '[stop-gate]',
1952
- };
2086
+ if (scoped) {
2087
+ const stateFile = path.join(scoped, 'state.json');
2088
+ let mtimeMs = now;
2089
+ try { mtimeMs = fs.statSync(stateFile).mtimeMs; } catch { /* missing state is evaluated below */ }
2090
+ artifacts = [{ file: stateFile, status: normalizedStatus(scopedState?.status || 'unknown'), type: 'deliver', mtimeMs }];
2091
+ } else {
2092
+ return { warnings: [], blocking: false, activeFlowRun: false, latestArtifactDir: null };
1953
2093
  }
1954
- return { warnings: [], blocking: false, activeFlowRun: false, latestArtifactDir: null };
1955
2094
  }
1956
2095
 
1957
2096
  const latest = artifacts[0];
@@ -1971,7 +2110,7 @@ async function analyze(root, now = Date.now()) {
1971
2110
 
1972
2111
  // ADR 0016 P-c: load the active FlowDefinition gate (fail-open: null when absent).
1973
2112
  // Null → existing workflow.* fallback path unchanged. Non-null → expects[]-driven claim selection.
1974
- const activeFlowStep = loadActiveFlowStep(path.dirname(latestArtifactDir));
2113
+ const activeFlowStep = loadActiveFlowStep(path.dirname(latestArtifactDir), actorKey);
1975
2114
 
1976
2115
  warnings.push(...sidecarValidation(root, latestArtifactDir));
1977
2116
  warnings.push(...sidecarGuidance(root, latestArtifactDir, activeFlowStep));
@@ -2020,7 +2159,9 @@ async function analyze(root, now = Date.now()) {
2020
2159
  // Use module-scope HARD_BLOCK / FULL_BLOCK (defined above analyze()).
2021
2160
  // pre-execution/terminal tasks: only HARD_BLOCK signals cause a block.
2022
2161
  // execution-onward tasks: FULL_BLOCK signals cause a block.
2023
- const canonicalFlow = canonicalFlowState(root, latestArtifactDir);
2162
+ const canonicalFlow = activeTurnScope && latestArtifactDir === activeTurnScope.artifactDir
2163
+ ? activeTurnScope.canonicalFlow
2164
+ : canonicalFlowState(root, latestArtifactDir);
2024
2165
  const activeProjectedFlow = gateState && gateState.flow_run && normalizedStatus(gateState.flow_run.status) === 'active';
2025
2166
  const unsafeActiveCanonical = Boolean(activeProjectedFlow && canonicalFlow.error);
2026
2167
  if (unsafeActiveCanonical) {
@@ -2033,12 +2174,38 @@ async function analyze(root, now = Date.now()) {
2033
2174
  warnings.push(`workflow state: canonical Flow run remains active at step ${activeStep}; complete or explicitly cancel the run before stopping.`);
2034
2175
  }
2035
2176
  const blockRe = ((preExecution && !activeFlowRun) || terminal) ? HARD_BLOCK : FULL_BLOCK;
2036
- const blocking = activeFlowRun || warnings.some(w => {
2177
+ const activeTurnAuthority = activeFlowRun && canonicalFlow.state && !unsafeActiveCanonical
2178
+ ? validateActiveTurnAuthority({
2179
+ sessionDir: latestArtifactDir,
2180
+ runId: process.env.FLOW_AGENTS_CONTINUATION_RUN_ID,
2181
+ turnSecret: process.env.FLOW_AGENTS_CONTINUATION_TURN_SECRET,
2182
+ canonicalState: canonicalFlow.state,
2183
+ })
2184
+ : { valid: false, reason: 'canonical Flow is not safely active' };
2185
+ if (activeTurnAuthority.valid) {
2186
+ warnings.push('continuation driver active turn is authorized; the ordinary unfinished canonical Flow gate is advisory until this adapter turn returns.');
2187
+ }
2188
+ const blockingRe = activeTurnAuthority.valid ? FULL_BLOCK : blockRe;
2189
+ const blocking = (activeFlowRun && !activeTurnAuthority.valid) || warnings.some(w => {
2037
2190
  // Capture cross-reference warn-mode notes never block (operator opted out).
2038
2191
  if (/\[backstop in warn mode — not blocking\]/.test(w)) return false;
2039
- return blockRe.test(w);
2192
+ if (activeTurnAuthority.valid && isOrdinaryActiveGateWarning(w, relPath)) return false;
2193
+ return blockingRe.test(w);
2040
2194
  });
2041
- return { warnings, blocking, activeFlowRun, preExecution, gatePrefix: gateLabel(activeFlowStep), latestArtifactDir };
2195
+ return { warnings, blocking, activeFlowRun, activeTurnAuthority: activeTurnAuthority.valid, preExecution, gatePrefix: gateLabel(activeFlowStep), latestArtifactDir };
2196
+ }
2197
+
2198
+ function isOrdinaryActiveGateWarning(warning, relPath) {
2199
+ return warning.startsWith(`${relPath} is still status:`)
2200
+ || /(?:^|\/)\.kontourai\/flow-agents\/[^/]+ workflow state:/.test(warning)
2201
+ || /(?:^|\/)\.kontourai\/flow-agents\/[^/]+ (?:next action|required skills|required operations|next command):/.test(warning)
2202
+ || /canonical Flow run remains active at step .+; complete or explicitly cancel the run before stopping\.$/.test(warning);
2203
+ }
2204
+
2205
+ function isHardStopWarning(warning, relPath, activeTurnAuthority) {
2206
+ if (/\[backstop in warn mode — not blocking\]/.test(warning)) return false;
2207
+ if (!activeTurnAuthority) return HARD_BLOCK.test(warning);
2208
+ return FULL_BLOCK.test(warning) && !isOrdinaryActiveGateWarning(warning, relPath);
2042
2209
  }
2043
2210
 
2044
2211
  /**
@@ -2286,9 +2453,16 @@ function releaseOnNonTerminalStop(root, artifactDir) {
2286
2453
 
2287
2454
  const state = readJsonFile(path.join(artifactDir, 'state.json'));
2288
2455
  if (!state) return; // AC5: no state.json — nothing to gate a release decision on.
2289
- const canonicalFlow = canonicalFlowState(root, artifactDir);
2290
- if (normalizedStatus(canonicalFlow.state?.status) === 'active' || (state.flow_run && normalizedStatus(state.flow_run.status) === 'active')) {
2291
- process.stderr.write(`[Hook] Goal Fit: stop-hook release skipped for active Flow run "${safeOneLine(state.flow_run?.run_id || state.task_slug || path.basename(artifactDir), 80)}"; continuation remains governed by Flow state.\n`);
2456
+ const signedScope = validatedActiveTurnScope(root);
2457
+ const exactSignedScope = signedScope && signedScope.artifactDir === artifactDir;
2458
+ const canonicalFlow = exactSignedScope ? signedScope.canonicalFlow : canonicalFlowState(root, artifactDir);
2459
+ const canonicalStatus = normalizedStatus(canonicalFlow.state?.status);
2460
+ const projectedCanonicalStatus = normalizedStatus(state.flow_run?.status);
2461
+ const canonicalOwned = CANONICAL_FLOW_STATUSES.has(canonicalStatus) && !CANONICAL_FLOW_TERMINAL_STATUSES.has(canonicalStatus);
2462
+ const projectedCanonicalOwned = CANONICAL_FLOW_STATUSES.has(projectedCanonicalStatus) && !CANONICAL_FLOW_TERMINAL_STATUSES.has(projectedCanonicalStatus);
2463
+ if ((exactSignedScope && canonicalFlow.error) || canonicalOwned || projectedCanonicalOwned) {
2464
+ const ownershipLabel = canonicalStatus === 'active' || projectedCanonicalStatus === 'active' ? 'active Flow run' : 'nonterminal Flow run';
2465
+ process.stderr.write(`[Hook] Goal Fit: stop-hook release skipped for ${ownershipLabel} "${safeOneLine(state.flow_run?.run_id || state.task_slug || path.basename(artifactDir), 80)}"; continuation remains governed by canonical Flow state.\n`);
2292
2466
  return;
2293
2467
  }
2294
2468
 
@@ -2434,15 +2608,12 @@ async function run(rawInput) {
2434
2608
  // with runtime-constructed paths or by modifying the warning
2435
2609
  // text so the hash changes. The real anchor is external (signed checkpoints + human
2436
2610
  // review). This raises the cost of the burn-through-the-counter escape vector.
2437
- const isHardBlock = result.activeFlowRun || result.warnings.some(w => {
2438
- if (/\[backstop in warn mode — not blocking\]/.test(w)) return false;
2439
- return HARD_BLOCK.test(w);
2440
- });
2611
+ const isHardBlock = (result.activeFlowRun && !result.activeTurnAuthority) || result.warnings.some(w => isHardStopWarning(w, relative(root, result.latestArtifactDir || root), result.activeTurnAuthority));
2441
2612
  if (isHardBlock) {
2442
2613
  // Do NOT clear the streak — keep accumulating so the same hard block stays visible.
2443
2614
  return {
2444
2615
  stdout: rawInput,
2445
- stderr: result.activeFlowRun
2616
+ stderr: result.activeFlowRun && !result.activeTurnAuthority
2446
2617
  ? `${message}\n${gatePrefix} max-blocks reached but canonical Flow remains active — not auto-releasing; complete or explicitly cancel the run.`
2447
2618
  : `${message}\n${gatePrefix} max-blocks reached but the block is a caught false-completion / integrity failure — not auto-releasing; requires a real fix or operator override.`,
2448
2619
  exitCode: 2,
@@ -2493,4 +2664,4 @@ if (require.main === module) {
2493
2664
  });
2494
2665
  }
2495
2666
 
2496
- module.exports = { analyze, run, resolveGoalFitMode, uncheckedInSection, findRepoRoot, sidecarGuidance, safeOneLine, captureCrossReference, bundleEnforcement, loadActiveFlowStep, readCommandLog, resolveTrustedCommand, declaredManifestTarget, verifyCommandLogChain, CHAIN_GENESIS_VERIFY, hasLaunderingOperator, releaseOnNonTerminalStop };
2667
+ module.exports = { analyze, run, resolveGoalFitMode, uncheckedInSection, findRepoRoot, sidecarGuidance, safeOneLine, captureCrossReference, bundleEnforcement, loadActiveFlowStep, readCommandLog, resolveTrustedCommand, declaredManifestTarget, verifyCommandLogChain, CHAIN_GENESIS_VERIFY, hasLaunderingOperator, releaseOnNonTerminalStop, isHardStopWarning, canonicalFlowState };
@@ -17,9 +17,9 @@
17
17
  const fs = require('fs');
18
18
  const path = require('path');
19
19
  const { readLivenessEvents, freshHolders } = require('./lib/liveness-read');
20
- const { resolveActor } = require('./lib/actor-identity');
20
+ const { resolveActor, isUnresolvedActor } = require('./lib/actor-identity');
21
21
  const { flowAgentsArtifactRootsForRead, resolveSharedRepoRoot, warnIfFailingOpenInsideGitTree } = require('./lib/local-artifact-paths');
22
- const { readCurrentPointer } = require('./lib/current-pointer');
22
+ const { readOwnCurrentPointer } = require('./lib/current-pointer');
23
23
  const { workflowTriggersFor } = require('./lib/kit-catalog');
24
24
 
25
25
  const STEERING = {
@@ -129,17 +129,20 @@ function readJson(file) {
129
129
  /**
130
130
  * #291 (Conflict #2, Wave 2 Task 2.4): actor-scoped preference consulted BEFORE the global
131
131
  * newest-mtime scan below. For each read-root, resolve the "current" pointer via
132
- * `readCurrentPointer` (per-actor `current/<actor>.json` preferred, legacy global
133
- * `current.json` fallback the same single choke point every other Wave 2 consumer uses) and,
134
- * if it names a session dir whose `state.json` is still in an ACTIVE_STATE_STATUSES status,
135
- * return that state immediately so actor A's own ambient steering hint always prefers A's own
136
- * current task over a globally-newer-mtime `state.json` some other actor (B) happened to touch
137
- * more recently. Returns null (never throws) when no root yields an actor-scoped resolution,
138
- * so the caller falls through to the unchanged global scan below — this is the compat-shim
139
- * guarantee: when `actorKey` is empty/unresolved and/or no per-actor file exists yet, this
140
- * degrades to the SAME legacy-`current.json`-or-nothing lookup the compat shim already performs
141
- * for every other Wave 2 reader, so a single-session/CI/legacy-only fixture cannot fail to
142
- * resolve here in a way it wouldn't already have resolved via the global scan.
132
+ * `readOwnCurrentPointer` and, if it names a session dir whose `state.json` is still in an
133
+ * ACTIVE_STATE_STATUSES status, return that state immediately so actor A's own ambient
134
+ * steering hint always prefers A's own current task over a globally-newer-mtime `state.json`
135
+ * some other actor (B) happened to touch more recently. Returns null (never throws) when no root
136
+ * yields an actor-scoped resolution.
137
+ *
138
+ * #440: for a RESOLVED `actorKey`, `readOwnCurrentPointer` reads ONLY that actor's own per-actor
139
+ * `current/<actor>.json` projection it never falls back to the shared legacy `current.json`.
140
+ * The caller (`latestWorkflowState`) gates its own global-scan fallback below to UNRESOLVED
141
+ * actors only (D1) so a resolved actor with no own per-actor pointer yet now correctly returns
142
+ * null all the way up (no banner) rather than grounding onto another actor's globally-newer
143
+ * `state.json`, the exact cross-actor steering bug #440 fixes. Only for an empty/unresolved
144
+ * `actorKey` does `readOwnCurrentPointer` delegate to `readCurrentPointer`, which DOES fall back
145
+ * to the legacy global file — that compat-shim degrade (D3) is unchanged from before #291/#440.
143
146
  *
144
147
  * @param {string} root
145
148
  * @param {string} actorKey
@@ -147,7 +150,7 @@ function readJson(file) {
147
150
  */
148
151
  function actorScopedWorkflowState(root, actorKey) {
149
152
  for (const flowAgentsDir of flowAgentsArtifactRootsForRead(root)) {
150
- const { payload: current } = readCurrentPointer(flowAgentsDir, actorKey);
153
+ const { payload: current } = readOwnCurrentPointer(flowAgentsDir, actorKey);
151
154
  if (!current) continue;
152
155
  const slug = current.artifact_dir || current.active_slug;
153
156
  if (typeof slug !== 'string' || !slug.trim()) continue;
@@ -165,9 +168,15 @@ function actorScopedWorkflowState(root, actorKey) {
165
168
  }
166
169
 
167
170
  function latestWorkflowState(root) {
168
- const preferred = actorScopedWorkflowState(root, resolveActor(process.env).actor);
171
+ const actorKey = resolveActor(process.env).actor;
172
+ const preferred = actorScopedWorkflowState(root, actorKey);
169
173
  if (preferred) return preferred;
170
174
 
175
+ // #440 D1/D2: a resolved actor's RESUME/STATE/SUPERSESSION banners never re-ground onto
176
+ // another actor's session via the global newest-mtime scan below — that fallback is
177
+ // legacy/unresolved-actor compat only (D3). No own active state this turn -> no banner.
178
+ if (!isUnresolvedActor(actorKey)) return null;
179
+
171
180
  const states = flowAgentsArtifactRootsForRead(root)
172
181
  .flatMap(artifactRoot => walkStateFiles(artifactRoot))
173
182
  .map(file => {
@@ -32,14 +32,26 @@ pricing_registry() {
32
32
  age=$(( now - mtime ))
33
33
  if [[ "$age" -lt "$ttl" ]] && pricing_registry_valid_json < "$cache"; then cat "$cache"; return 0; fi
34
34
  fi
35
- if curl -fsS --max-time 5 "$url" -o "${cache}.tmp" 2>/dev/null && [[ -s "${cache}.tmp" ]]; then
36
- if pricing_registry_valid_json < "${cache}.tmp"; then
37
- mv "${cache}.tmp" "$cache"
38
- cat "$cache"
39
- return 0
35
+ # Cache-only callers (per-tool-call join; see usage.sh's usage_last_turn_usage,
36
+ # which runs once per tool call, not once per session) never perform a
37
+ # network fetch here. A STALE-but-JSON-valid cache is still served (by the
38
+ # unconditional `[[ -f "$cache" ]] && pricing_registry_valid_json ...`
39
+ # fallback just below) before ever falling through to the bundled
40
+ # snapshot -- only a genuinely MISSING (or invalid) cache reaches the
41
+ # bundled snapshot. This still avoids blocking on a `curl --max-time 5`
42
+ # round-trip (and racing this function's fixed ${cache}.tmp path) on
43
+ # every tool call. session.usage (once per session, at stop) is
44
+ # unaffected and still resolves the registry normally (network-eligible).
45
+ if [[ "${TELEMETRY_PRICING_CACHE_ONLY:-}" != "1" ]]; then
46
+ if curl -fsS --max-time 5 "$url" -o "${cache}.tmp" 2>/dev/null && [[ -s "${cache}.tmp" ]]; then
47
+ if pricing_registry_valid_json < "${cache}.tmp"; then
48
+ mv "${cache}.tmp" "$cache"
49
+ cat "$cache"
50
+ return 0
51
+ fi
40
52
  fi
53
+ rm -f "${cache}.tmp" 2>/dev/null
41
54
  fi
42
- rm -f "${cache}.tmp" 2>/dev/null
43
55
  [[ -f "$cache" ]] && pricing_registry_valid_json < "$cache" && { cat "$cache"; return 0; }
44
56
  fi
45
57