@kontourai/flow-agents 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.github/CODEOWNERS +2 -0
  2. package/.github/workflows/ci.yml +4 -4
  3. package/.github/workflows/docs-pages.yml +1 -1
  4. package/.github/workflows/kit-gates-demo.yml +2 -2
  5. package/.github/workflows/publish-npm.yml +2 -2
  6. package/.github/workflows/runtime-compat.yml +2 -2
  7. package/.github/workflows/trust-reconcile.yml +1 -1
  8. package/CHANGELOG.md +29 -0
  9. package/build/src/cli/sidecar-claim-explain.d.ts +45 -0
  10. package/build/src/cli/sidecar-claim-explain.js +87 -0
  11. package/build/src/cli/telemetry-doctor.js +2 -4
  12. package/build/src/cli/usage-feedback.js +16 -8
  13. package/build/src/cli/workflow-artifact-cleanup-audit.js +4 -2
  14. package/build/src/cli/workflow-sidecar.d.ts +2 -44
  15. package/build/src/cli/workflow-sidecar.js +18 -87
  16. package/build/src/index.d.ts +1 -0
  17. package/build/src/index.js +1 -0
  18. package/build/src/lib/local-artifact-root.d.ts +11 -0
  19. package/build/src/lib/local-artifact-root.js +30 -0
  20. package/build/src/tools/validate-source-tree.js +1 -0
  21. package/context/scripts/telemetry/lib/config.sh +3 -4
  22. package/docs/adr/0018-freeze-local-shell-heuristics.md +95 -0
  23. package/docs/repository-structure.md +7 -3
  24. package/evals/integration/test_bundle_lifecycle.sh +9 -9
  25. package/evals/integration/test_gate_lockdown.sh +25 -6
  26. package/evals/integration/test_migrate_local_artifacts.sh +102 -0
  27. package/evals/integration/test_workflow_sidecar_writer.sh +34 -0
  28. package/evals/run.sh +2 -0
  29. package/evals/static/test_unit_helpers.sh +26 -0
  30. package/integrations/strands-ts/package.json +3 -0
  31. package/integrations/strands-ts/src/telemetry.ts +31 -50
  32. package/kits/knowledge/adapters/flow-runner/index.js +1 -1
  33. package/kits/knowledge/adapters/flow-runner/telemetry.js +2 -2
  34. package/package.json +2 -1
  35. package/scripts/README.md +1 -0
  36. package/scripts/ci/trust-reconcile.js +7 -23
  37. package/scripts/hooks/config-protection.js +6 -0
  38. package/scripts/hooks/evidence-capture.js +26 -47
  39. package/scripts/hooks/lib/local-artifact-paths.js +32 -0
  40. package/scripts/hooks/stop-goal-fit.js +37 -58
  41. package/scripts/hooks/workflow-steering.js +5 -3
  42. package/scripts/lib/command-log-chain.js +78 -0
  43. package/scripts/migrate-local-artifacts.mjs +230 -0
  44. package/scripts/repair-command-log.js +8 -15
  45. package/scripts/telemetry/lib/config.sh +3 -4
  46. package/src/cli/public-api.test.mjs +21 -0
  47. package/src/cli/sidecar-claim-explain.ts +130 -0
  48. package/src/cli/sidecar-pure-helpers.test.mjs +168 -0
  49. package/src/cli/telemetry-doctor.ts +2 -4
  50. package/src/cli/usage-feedback.ts +15 -8
  51. package/src/cli/workflow-artifact-cleanup-audit.ts +4 -2
  52. package/src/cli/workflow-sidecar.ts +17 -131
  53. package/src/index.ts +14 -0
  54. package/src/lib/local-artifact-root.ts +39 -0
  55. package/src/tools/validate-source-tree.ts +1 -0
@@ -14,6 +14,11 @@ import fs from "node:fs";
14
14
  import path from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { randomUUID } from "node:crypto";
17
+ import {
18
+ costForModel as pkgCostForModel,
19
+ currentPricingVersion as pkgPricingVersion,
20
+ setRegistry as setPkgRegistry
21
+ } from "@kontourai/console-telemetry";
17
22
 
18
23
  // ESM has no __dirname; derive it (this package is "type":"module").
19
24
  const __filename = fileURLToPath(import.meta.url);
@@ -308,7 +313,12 @@ export class TelemetrySink {
308
313
  }
309
314
 
310
315
  // ---------------------------------------------------------------------------
311
- // Usage / cost — mirror of scripts/telemetry/pricing.json (per 1M tokens, USD)
316
+ // Usage / cost — pricing math + registry shape come from the shared contract
317
+ // package @kontourai/console-telemetry (single source). This sink only loads
318
+ // the flow-agents authored registry (scripts/telemetry/pricing.json) and feeds
319
+ // it to the package so cost prices against pricing.json rather than the
320
+ // package's bundled fallback. Tokens are exact regardless; the console
321
+ // recomputes cost authoritatively.
312
322
  // ---------------------------------------------------------------------------
313
323
 
314
324
  export interface TokenCounts {
@@ -331,37 +341,15 @@ interface NormalizedTokens {
331
341
  cacheRead: number;
332
342
  }
333
343
 
334
- // Pricing is read from the single-source registry (scripts/telemetry/pricing.json),
335
- // never hand-maintained here. Resolution: TELEMETRY_PRICING_FILE /
336
- // FLOW_AGENTS_PRICING_FILE env path, else the repo-relative registry, else a
337
- // minimal fallback. Tokens are exact regardless; the console recomputes cost
338
- // authoritatively, so a missing file only degrades the sink's stamped estimate.
339
- interface PricingVersionBlock {
340
- cache_multipliers: { write_5m: number; write_1h: number; read: number };
341
- models: Record<string, { input: number; output: number }>;
342
- default: { input: number; output: number };
343
- zero_cost_models: string[];
344
- }
345
- interface PricingRegistry {
346
- current_version: string;
347
- versions: Record<string, PricingVersionBlock>;
348
- }
349
-
350
- const FALLBACK_REGISTRY: PricingRegistry = {
351
- current_version: "fallback",
352
- versions: {
353
- fallback: {
354
- cache_multipliers: { write_5m: 1.25, write_1h: 2.0, read: 0.1 },
355
- models: {},
356
- default: { input: 5.0, output: 25.0 },
357
- zero_cost_models: ["<synthetic>", "synthetic", "unknown", ""]
358
- }
359
- }
360
- };
361
-
362
- let cachedRegistry: PricingRegistry | null = null;
363
- function loadRegistry(): PricingRegistry {
364
- if (cachedRegistry) return cachedRegistry;
344
+ // Feed the package the flow-agents authored registry when present (its single
345
+ // source of truth). Resolution: TELEMETRY_PRICING_FILE / FLOW_AGENTS_PRICING_FILE
346
+ // env path, else the repo-relative registry. Runs once; on failure the package
347
+ // keeps its bundled fallback. The console recomputes cost authoritatively, so a
348
+ // missing file only degrades the sink's stamped estimate.
349
+ let registryLoaded = false;
350
+ function ensureRegistry(): void {
351
+ if (registryLoaded) return;
352
+ registryLoaded = true;
365
353
  const candidates = [
366
354
  process.env.TELEMETRY_PRICING_FILE,
367
355
  process.env.FLOW_AGENTS_PRICING_FILE,
@@ -372,19 +360,18 @@ function loadRegistry(): PricingRegistry {
372
360
  try {
373
361
  const parsed = JSON.parse(fs.readFileSync(candidate, "utf8"));
374
362
  if (parsed && typeof parsed.current_version === "string" && parsed.versions) {
375
- cachedRegistry = parsed as PricingRegistry;
376
- return cachedRegistry;
363
+ setPkgRegistry(parsed);
364
+ return;
377
365
  }
378
366
  } catch {
379
367
  // try next candidate
380
368
  }
381
369
  }
382
- cachedRegistry = FALLBACK_REGISTRY;
383
- return cachedRegistry;
384
370
  }
385
371
 
386
372
  function pricingVersion(): string {
387
- return loadRegistry().current_version;
373
+ ensureRegistry();
374
+ return pkgPricingVersion();
388
375
  }
389
376
 
390
377
  function num(value: number | undefined): number {
@@ -405,17 +392,11 @@ function normalizeTokens(tokens: TokenCounts): NormalizedTokens {
405
392
  }
406
393
 
407
394
  function costForModel(model: string | undefined, tokens: NormalizedTokens): number {
408
- const registry = loadRegistry();
409
- const block = registry.versions[registry.current_version] ?? FALLBACK_REGISTRY.versions.fallback;
410
- const key = (model ?? "").trim();
411
- if (block.zero_cost_models.includes(key)) return 0;
412
- const rate = block.models[key] ?? block.default;
413
- const cm = block.cache_multipliers;
414
- return round6(
415
- (tokens.input * rate.input +
416
- tokens.output * rate.output +
417
- tokens.cacheCreation * rate.input * cm.write_5m +
418
- tokens.cacheRead * rate.input * cm.read) /
419
- 1_000_000
420
- );
395
+ ensureRegistry();
396
+ return pkgCostForModel(model, {
397
+ inputTokens: tokens.input,
398
+ outputTokens: tokens.output,
399
+ cacheCreationInputTokens: tokens.cacheCreation,
400
+ cacheReadInputTokens: tokens.cacheRead
401
+ });
421
402
  }
@@ -17,7 +17,7 @@
17
17
  * - defaultSimilarityDetector — pluggable similarity interface default (R3)
18
18
  *
19
19
  * Telemetry:
20
- * Gate events are emitted to <workspace>/.telemetry/full.jsonl using
20
+ * Gate events are emitted to <workspace>/.kontourai/telemetry/full.jsonl using
21
21
  * canonical schema v0.3.0 events (preToolUse at gate entry, postToolUse at gate exit).
22
22
  *
23
23
  * Similarity Interface (R3):
@@ -8,7 +8,7 @@
8
8
  * Zero runtime dependencies beyond Node.js built-ins.
9
9
  * Fails open: telemetry errors never block kit operations.
10
10
  *
11
- * Sink path: <workspace>/.telemetry/full.jsonl
11
+ * Sink path: <workspace>/.kontourai/telemetry/full.jsonl
12
12
  * The workspace is resolved from FLOW_AGENTS_WORKSPACE env var, falling back
13
13
  * to process.cwd().
14
14
  *
@@ -47,7 +47,7 @@ function schemaEventType(canonical) {
47
47
 
48
48
  function resolveSinkPath(workspace) {
49
49
  const ws = workspace || process.env.FLOW_AGENTS_WORKSPACE || process.cwd();
50
- return path.join(ws, ".telemetry", "full.jsonl");
50
+ return path.join(ws, ".kontourai", "telemetry", "full.jsonl");
51
51
  }
52
52
 
53
53
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/flow-agents",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
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",
@@ -99,6 +99,7 @@
99
99
  "scripts": {
100
100
  "build": "tsc -p tsconfig.json",
101
101
  "typecheck": "tsc -p tsconfig.json --noEmit",
102
+ "test:unit": "npm run build --silent && node --test src/cli/*.test.mjs",
102
103
  "validate:source": "npm run build --silent && node build/src/cli.js validate-source",
103
104
  "validate:artifacts": "npm run build --silent && node build/src/cli/validate-workflow-artifacts.js",
104
105
  "context-map": "npm run build --silent && node build/src/cli.js context-map",
package/scripts/README.md CHANGED
@@ -70,6 +70,7 @@ renamed, or changes category, update the table and the validator together.
70
70
  | `lib/audit-transport.sh` | shared hook library | `evals/integration/test_hook_category_behaviors.sh`, `evals/integration/test_telemetry.sh` | Shared audit event transport functions. |
71
71
  | `lib/hook-flags.js` | shared hook library | `evals/integration/test_hook_category_behaviors.sh` | Shared profile/disable flag parsing. |
72
72
  | `lib/liveness-read.js` | shared hook library | `evals/integration/test_session_resume_roundtrip.sh` | Shared liveness event reader + freshness check (`readLivenessEvents`, `freshHolders`); consumed by the reground hook and `workflow-sidecar liveness status`. |
73
+ | `lib/local-artifact-paths.js` | shared hook library | `evals/integration/test_migrate_local_artifacts.sh`, `evals/integration/test_workflow_sidecar_writer.sh` | Shared `.kontourai/flow-agents` artifact-root helpers with legacy `.flow-agents` read compatibility for CJS hooks. |
73
74
  | `lib/patterns.sh` | shared hook library | `evals/integration/test_hook_category_behaviors.sh`, `evals/integration/test_telemetry.sh` | Shared shell pattern constants. |
74
75
  | `lib/resolve-formatter.js` | shared hook library | `evals/integration/test_hook_category_behaviors.sh` | Shared formatter resolution helper. |
75
76
 
@@ -61,6 +61,10 @@ const { spawnSync } = require('child_process');
61
61
  const fs = require('fs');
62
62
  const os = require('os');
63
63
  const path = require('path');
64
+ // One normative definition shared with scripts/hooks/stop-goal-fit.js — the local
65
+ // copy here had drifted (it was missing the trailing `/bin/true` check), which is
66
+ // exactly why this is now imported rather than duplicated.
67
+ const { hasLaunderingOperator } = require('../lib/command-log-chain.js');
64
68
 
65
69
  // ---------------------------------------------------------------------------
66
70
  // Helpers
@@ -80,29 +84,9 @@ function isPassingValue(v) {
80
84
  return v === true || v === 1 || v === 'true' || v === 'pass';
81
85
  }
82
86
 
83
- /**
84
- * Returns true when a command string contains an exit-code-laundering operator.
85
- * These operators mask real exit codes so the real sub-command may have failed silently.
86
- *
87
- * Rules (applied to claimed verification commands only):
88
- * - ANY || operator — verify commands must not contain ||. This catches:
89
- * || exit 0, || echo ok, || /bin/true, || true, || :, etc.
90
- * - ; or newline followed by true / : / exit 0 — trailing success injection
91
- *
92
- * NOTE: Logic must stay identical to scripts/hooks/stop-goal-fit.js hasLaunderingOperator.
93
- * Centralize into a shared module as a follow-up (coordinate-free duplication for now).
94
- */
95
- function hasLaunderingOperator(cmd) {
96
- // Flag ANY || operator — masks the exit code of the left-hand command.
97
- if (/\|\|/.test(cmd)) return true;
98
- // Flag ; or newline followed by true / : / exit 0
99
- if (/[;\n]\s*true\b/.test(cmd)) return true;
100
- if (/[;\n]\s*:\s*(?:$|\s|;|\n)/.test(cmd)) return true;
101
- if (/[;\n]\s*exit\s+0\b/.test(cmd)) return true;
102
- // Flag pipe to true (pipeline absorbs exit code)
103
- if (/\|\s*true\b/.test(cmd)) return true;
104
- return false;
105
- }
87
+ // hasLaunderingOperator is imported from ../lib/command-log-chain.js (above) so this
88
+ // CI reconciler and the stop-goal-fit verifier apply the identical exit-code-mask
89
+ // heuristic see that module for the rules.
106
90
 
107
91
  /**
108
92
  * Run a single shell command under bash, capturing exit code.
@@ -40,6 +40,12 @@
40
40
  * any interpreter not in the list (ruby, php, etc.), and multiline here-docs.
41
41
  * The real anchor remains external (clean CI env + human review).
42
42
  *
43
+ * FROZEN bar-raiser (ADR 0018). The redirect / interpreter-write / cp-target detection
44
+ * is intentionally non-exhaustive and is NOT to be grown with new evasion-pattern rules.
45
+ * A newly-discovered self-tamper vector belongs in the external CI anchor (trust-reconcile
46
+ * does not trust agent-written files) and the required anti-gaming suite — not another
47
+ * local string matcher. Bug fixes that don't expand the pattern surface are still welcome.
48
+ *
43
49
  * Exit codes: 0 = allow, 2 = block
44
50
  */
45
51
 
@@ -48,6 +48,7 @@
48
48
 
49
49
  const fs = require('fs');
50
50
  const path = require('path');
51
+ const { flowAgentsArtifactRootsForRead } = require('./lib/local-artifact-paths');
51
52
  const crypto = require('crypto');
52
53
 
53
54
  const MAX_STDIN = 1024 * 1024;
@@ -62,43 +63,16 @@ const COMMAND_TOOL_NAME = /(^|[^a-z])(bash|shell|sh|exec|run|command|terminal|cm
62
63
 
63
64
  // ─── Hash-chain integrity (tamper-EVIDENCE) ───────────────────────────────────
64
65
  //
65
- // Genesis prevHash: a fixed arbitrary sentinel used when the log is empty or
66
- // the last entry has no _chain field (legacy record). This is NOT the SHA256 of
67
- // any specific input string it is a fixed constant chosen for the original
68
- // implementation. (A previous comment incorrectly claimed it was
69
- // sha256("flow-agents:command-log:genesis"); that is wrong.)
70
- //
71
- // Writer (this file, CHAIN_GENESIS) and verifier (stop-goal-fit.js,
72
- // CHAIN_GENESIS_VERIFY) MUST use the same value. Do not change one without
73
- // changing the other — existing chained logs depend on this constant.
74
- //
75
- // HONEST FRAMING: this makes alteration DETECTABLE, not impossible. An agent
76
- // that rewrites all hashes can still forge the chain. The real tamper-proof
77
- // boundary is the signed checkpoint (B1). We do not oversell this boundary.
78
- const CHAIN_GENESIS = 'a3f9e2b7d5c84f1e6a0d2c3b9f7e1a4d8c6b5f2e9a0d3c7b1f4e8a2d6c0b9f3';
79
-
80
- /**
81
- * Stable canonical JSON for the chain input: the record WITHOUT the `_chain`
82
- * field, keys sorted alphabetically. This ensures the hash is independent of
83
- * key insertion order and that `_chain` itself does not contribute to its own
84
- * hash (circular dependency).
85
- */
86
- function canonicalJsonForChain(record) {
87
- // Strip _chain if present (should not be, but defensive).
88
- const keys = Object.keys(record).filter(k => k !== '_chain').sort();
89
- const obj = {};
90
- for (const k of keys) obj[k] = record[k];
91
- return JSON.stringify(obj);
92
- }
93
-
94
- /**
95
- * Compute the sha256 hex hash for this chain link.
96
- * hash = sha256(prevHash + canonicalJson(record))
97
- */
98
- function computeChainHash(prevHash, record) {
99
- const input = prevHash + canonicalJsonForChain(record);
100
- return crypto.createHash('sha256').update(input, 'utf8').digest('hex');
101
- }
66
+ // CHAIN_GENESIS is a fixed arbitrary sentinel NOT the SHA256 of any specific
67
+ // input string (a previous comment incorrectly claimed sha256("…:genesis")). The
68
+ // writer here and the verifier in stop-goal-fit.js MUST canonicalize and seed
69
+ // identically, so the genesis constant and the canonicalJson/hash helpers live in
70
+ // ONE shared module that both import — divergence is structurally impossible.
71
+ const {
72
+ CHAIN_GENESIS,
73
+ canonicalJsonForChain,
74
+ computeChainHash,
75
+ } = require('../lib/command-log-chain.js');
102
76
 
103
77
  /**
104
78
  * Read the last entry from command-log.jsonl that has a `_chain` block.
@@ -224,18 +198,23 @@ function latestStateDir(flowAgentsDir) {
224
198
  * to the newest-mtime state.json directory.
225
199
  */
226
200
  function resolveArtifactDir(root) {
227
- const flowAgentsDir = path.join(root, '.flow-agents');
228
- const current = readJsonFile(path.join(flowAgentsDir, 'current.json'));
229
- if (current) {
230
- const slug = current.artifact_dir || current.active_slug;
231
- if (typeof slug === 'string' && slug.trim()) {
232
- // Guard against path traversal in the slug.
233
- const safe = slug.replace(/\.\.+/g, '').replace(/^[/\\]+/, '');
234
- const dir = path.join(flowAgentsDir, safe);
235
- if (dir.startsWith(flowAgentsDir + path.sep) && fs.existsSync(dir)) return dir;
201
+ for (const flowAgentsDir of flowAgentsArtifactRootsForRead(root)) {
202
+ const current = readJsonFile(path.join(flowAgentsDir, 'current.json'));
203
+ if (current) {
204
+ const slug = current.artifact_dir || current.active_slug;
205
+ if (typeof slug === 'string' && slug.trim()) {
206
+ // Guard against path traversal in the slug.
207
+ const safe = slug.replace(/\.\.+/g, '').replace(/^[/\\]+/, '');
208
+ const dir = path.join(flowAgentsDir, safe);
209
+ if (dir.startsWith(flowAgentsDir + path.sep) && fs.existsSync(dir)) return dir;
210
+ }
236
211
  }
237
212
  }
238
- return latestStateDir(flowAgentsDir);
213
+ for (const flowAgentsDir of flowAgentsArtifactRootsForRead(root)) {
214
+ const latest = latestStateDir(flowAgentsDir);
215
+ if (latest) return latest;
216
+ }
217
+ return null;
239
218
  }
240
219
 
241
220
  function isCommandTool(toolName, command) {
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const KONTOURAI_DIR = '.kontourai';
7
+ const LEGACY_FLOW_AGENTS_DIR = '.flow-agents';
8
+
9
+ function flowAgentsArtifactRoot(cwd = process.cwd()) {
10
+ return path.resolve(cwd, KONTOURAI_DIR, 'flow-agents');
11
+ }
12
+
13
+ function legacyFlowAgentsArtifactRoot(cwd = process.cwd()) {
14
+ return path.resolve(cwd, LEGACY_FLOW_AGENTS_DIR);
15
+ }
16
+
17
+ function flowAgentsArtifactRootsForRead(cwd = process.cwd()) {
18
+ const roots = [flowAgentsArtifactRoot(cwd), legacyFlowAgentsArtifactRoot(cwd)];
19
+ return roots.filter((root, index) => roots.indexOf(root) === index && fs.existsSync(root));
20
+ }
21
+
22
+ function defaultArtifactRootForRead(cwd = process.cwd()) {
23
+ const roots = flowAgentsArtifactRootsForRead(cwd);
24
+ return roots[0] || flowAgentsArtifactRoot(cwd);
25
+ }
26
+
27
+ module.exports = {
28
+ flowAgentsArtifactRoot,
29
+ legacyFlowAgentsArtifactRoot,
30
+ flowAgentsArtifactRootsForRead,
31
+ defaultArtifactRootForRead,
32
+ };
@@ -29,6 +29,20 @@ const path = require('path');
29
29
  const { spawnSync } = require('child_process');
30
30
  const crypto = require('crypto');
31
31
 
32
+ // Hash-chain primitives + the exit-code-laundering heuristic come from ONE shared
33
+ // module, so this verifier can never drift from the writer (evidence-capture.js).
34
+ // CHAIN_GENESIS is re-aliased to CHAIN_GENESIS_VERIFY to preserve the long-standing
35
+ // export name consumed by repair-command-log.js and the fork-classification eval.
36
+ const {
37
+ CHAIN_GENESIS: CHAIN_GENESIS_VERIFY,
38
+ canonicalJsonForChain,
39
+ hasLaunderingOperator,
40
+ } = require('../lib/command-log-chain.js');
41
+ const {
42
+ flowAgentsArtifactRoot,
43
+ flowAgentsArtifactRootsForRead,
44
+ } = require('./lib/local-artifact-paths');
45
+
32
46
  const MAX_STDIN = 1024 * 1024;
33
47
  const ACTIVE_STATUSES = new Set([
34
48
  'planning',
@@ -733,36 +747,10 @@ function claimAcknowledgesFailure(status, value) {
733
747
  || v === 'fail' || v === 'failed' || v === 'not_verified' || v === 'failing';
734
748
  }
735
749
 
736
- /**
737
- * Returns true when a command string contains an exit-code-neutralizing operator.
738
- * A claimed-pass check whose captured command uses one of these cannot be accepted as a
739
- * deterministic pass — the real sub-command may have failed silently.
740
- *
741
- * R6 extended logic (identical patterns used by scripts/ci/trust-reconcile.js — centralize
742
- * as a follow-up if drift becomes a maintenance concern):
743
- * - ANY || operator is flagged. A legitimate verification command never needs || — its
744
- * only purpose in a verification command is to mask the real exit code (e.g.
745
- * `npm test || exit 0`, `npm test || echo ok`, `npm test || /bin/true`, `npm test || (exit 0)`).
746
- * - | true (single pipe into true — always exits 0)
747
- * - Trailing ; or newline followed by: true : exit 0 /bin/true
748
- *
749
- * Fix D: applied in captureCrossReference's satisfied path and capturedFailReconciliation.
750
- */
751
- function hasLaunderingOperator(cmd) {
752
- // ANY || in a claimed verification command is an exit-code mask.
753
- // Legitimate verification commands never need || — its only purpose there is to
754
- // suppress the real exit code (|| exit 0, || echo ok, || /bin/true, || (exit 0), etc.).
755
- if (/\|\|/.test(cmd)) return true;
756
- // | true — single-pipe into true: `cmd | true` always exits 0 regardless of left-side exit code.
757
- if (/\|\s*true\b/.test(cmd)) return true;
758
- // Trailing ; or \n followed by exit-neutralizing commands (same threat, appended after the real cmd):
759
- // ; true ; : ; exit 0 ; /bin/true (and \n variants)
760
- if (/[;\n]\s*true\b/.test(cmd)) return true;
761
- if (/[;\n]\s*:\s*(?:$|\s|;)/.test(cmd)) return true;
762
- if (/[;\n]\s*exit\s+0\b/.test(cmd)) return true;
763
- if (/[;\n]\s*\/bin\/true\b/.test(cmd)) return true;
764
- return false;
765
- }
750
+ // hasLaunderingOperator (the exit-code-mask heuristic) is imported from
751
+ // ../lib/command-log-chain.js so this verifier and scripts/ci/trust-reconcile.js
752
+ // share one normative definition. Applied in captureCrossReference's satisfied
753
+ // path and capturedFailReconciliation.
766
754
 
767
755
  // ─── Hash-chain integrity verification (Increment B2, tamper-EVIDENCE) ────────
768
756
  //
@@ -786,20 +774,9 @@ function hasLaunderingOperator(cmd) {
786
774
  // The genesis prevHash is a fixed arbitrary sentinel — NOT the SHA256 of any
787
775
  // specific input string. The comment in evidence-capture.js previously (and
788
776
  // incorrectly) claimed it was sha256("flow-agents:command-log:genesis"); it is not.
789
- // Writer (evidence-capture.js CHAIN_GENESIS) and verifier (CHAIN_GENESIS_VERIFY here)
790
- // MUST use the same value. Do not change one without changing the other.
791
- const CHAIN_GENESIS_VERIFY = 'a3f9e2b7d5c84f1e6a0d2c3b9f7e1a4d8c6b5f2e9a0d3c7b1f4e8a2d6c0b9f3';
792
-
793
- /**
794
- * Canonical JSON for chain verification: record WITHOUT `_chain`, keys sorted.
795
- * Must be byte-identical to canonicalJsonForChain() in evidence-capture.js.
796
- */
797
- function canonicalJsonForVerify(record) {
798
- const keys = Object.keys(record).filter(k => k !== '_chain').sort();
799
- const obj = {};
800
- for (const k of keys) obj[k] = record[k];
801
- return JSON.stringify(obj);
802
- }
777
+ // Both the genesis (CHAIN_GENESIS_VERIFY, imported above) and the canonical-JSON
778
+ // helper (canonicalJsonForChain) come from ../lib/command-log-chain.js, the single
779
+ // source the writer in evidence-capture.js imports too — so they cannot diverge.
803
780
 
804
781
  /**
805
782
  * Verify the hash chain of command-log.jsonl.
@@ -870,7 +847,7 @@ function verifyCommandLogChain(artifactDir) {
870
847
  // (a) Self-consistency. A content edit without rehashing fails here.
871
848
  if (typeof chain.prevHash !== 'string') return { status: 'broken', brokenAt: i, forkAt: null };
872
849
  const selfHash = crypto.createHash('sha256')
873
- .update(chain.prevHash + canonicalJsonForVerify(entry), 'utf8')
850
+ .update(chain.prevHash + canonicalJsonForChain(entry), 'utf8')
874
851
  .digest('hex');
875
852
  if (chain.hash !== selfHash) return { status: 'broken', brokenAt: i, forkAt: null };
876
853
 
@@ -1602,11 +1579,11 @@ const HARD_BLOCK = /contradicts evidence\.json|caught false-completion|evidence
1602
1579
  const FULL_BLOCK = /status:|Definition Of Done|Goal Fit|sidecar validation:|contradicts evidence\.json|workflow state|evidence verdict|evidence check|NOT_VERIFIED gap|critique status|critique open|next action|caught false-completion|NOT_VERIFIED —|command-log integrity check FAILED|gate misconfiguration:|surface unavailable —|expected capture log is missing|exit-code-laundered/;
1603
1580
 
1604
1581
  async function analyze(root, now = Date.now()) {
1605
- const flowAgentsDir = path.join(root, '.flow-agents');
1582
+ const flowAgentsDirs = flowAgentsArtifactRootsForRead(root);
1606
1583
  // Scope to the session's current task when current.json names one, so an
1607
1584
  // unrelated active workflow elsewhere in the repo does not gate this stop.
1608
- const scoped = preferredArtifactDir(flowAgentsDir);
1609
- const searchDirs = scoped ? [scoped] : [flowAgentsDir];
1585
+ const scoped = flowAgentsDirs.map(preferredArtifactDir).find(Boolean);
1586
+ const searchDirs = scoped ? [scoped] : flowAgentsDirs;
1610
1587
  const artifacts = searchDirs
1611
1588
  .flatMap(dir => walkMarkdown(dir))
1612
1589
  .map(readArtifact)
@@ -1616,6 +1593,7 @@ async function analyze(root, now = Date.now()) {
1616
1593
  if (artifacts.length === 0) return { warnings: [], blocking: false };
1617
1594
 
1618
1595
  const latest = artifacts[0];
1596
+ const latestArtifactDir = path.dirname(latest.file);
1619
1597
  const warnings = [];
1620
1598
  const relPath = relative(root, latest.file);
1621
1599
  const status = latest.status || 'unknown';
@@ -1631,11 +1609,11 @@ async function analyze(root, now = Date.now()) {
1631
1609
 
1632
1610
  // ADR 0016 P-c: load the active FlowDefinition gate (fail-open: null when absent).
1633
1611
  // Null → existing workflow.* fallback path unchanged. Non-null → expects[]-driven claim selection.
1634
- const activeFlowStep = loadActiveFlowStep(flowAgentsDir);
1612
+ const activeFlowStep = loadActiveFlowStep(path.dirname(latestArtifactDir));
1635
1613
 
1636
- warnings.push(...sidecarValidation(root, path.dirname(latest.file)));
1637
- warnings.push(...sidecarGuidance(root, path.dirname(latest.file), activeFlowStep));
1638
- const captureWarnings = captureCrossReference(root, path.dirname(latest.file), activeFlowStep);
1614
+ warnings.push(...sidecarValidation(root, latestArtifactDir));
1615
+ warnings.push(...sidecarGuidance(root, latestArtifactDir, activeFlowStep));
1616
+ const captureWarnings = captureCrossReference(root, latestArtifactDir, activeFlowStep);
1639
1617
  warnings.push(...captureWarnings);
1640
1618
  // Dedup: bundleEnforcement and captureCrossReference can both fire "caught false-completion"
1641
1619
  // for the same disputed claim. Suppress the bundleEnforcement warning ONLY when
@@ -1649,7 +1627,7 @@ async function analyze(root, now = Date.now()) {
1649
1627
  const m = /evidence check ([^\s:]+):/.exec(w);
1650
1628
  if (m) captureHardBlockIds.add(m[1]);
1651
1629
  }
1652
- const bundleWarnings = (await bundleEnforcement(path.dirname(latest.file), activeFlowStep)).filter(w => {
1630
+ const bundleWarnings = (await bundleEnforcement(latestArtifactDir, activeFlowStep)).filter(w => {
1653
1631
  if (!captureHardBlockIds.size) return true;
1654
1632
  // bundleEnforcement warns: "trust.bundle claim disputed: <subjectId> ..."
1655
1633
  const m = /trust\.bundle claim (?:disputed|tampered): ([^\s(]+)/.exec(w);
@@ -1661,21 +1639,21 @@ async function analyze(root, now = Date.now()) {
1661
1639
  return !captureHardBlockIds.has(checkId);
1662
1640
  });
1663
1641
  warnings.push(...bundleWarnings);
1664
- warnings.push(...missingBundleOrStateSignal(path.dirname(latest.file), activeFlowStep));
1642
+ warnings.push(...missingBundleOrStateSignal(latestArtifactDir, activeFlowStep));
1665
1643
 
1666
1644
  // A pre-execution task (not started) OR a terminal task (which is itself a
1667
1645
  // completion *claim*) must not block on mere incompleteness — but a FALSE claim
1668
1646
  // (capture/evidence contradiction) still blocks at any phase. This is the whole
1669
1647
  // point of the capture cross-reference: catch a task that falsely claims done.
1670
- const gateState = readJsonFile(path.join(path.dirname(latest.file), 'state.json'));
1648
+ const gateState = readJsonFile(path.join(latestArtifactDir, 'state.json'));
1671
1649
  const taskStatus = gateState ? normalizedStatus(gateState.status) : normalizedStatus(status);
1672
- const preExecution = isPreExecution(path.dirname(latest.file), status);
1650
+ const preExecution = isPreExecution(latestArtifactDir, status);
1673
1651
  const terminal = TERMINAL_STATUSES.has(taskStatus);
1674
1652
 
1675
1653
  // Namespace-agnostic captured-FAIL reconciliation (AC1 — closes the allowlist bypass).
1676
1654
  // Fix A: status-independent — runs on EVERY stop. A claim contradicting the capture
1677
1655
  // is a false-completion whether or not the agent says the task is 'done'.
1678
- warnings.push(...capturedFailReconciliation(root, path.dirname(latest.file), taskStatus));
1656
+ warnings.push(...capturedFailReconciliation(root, latestArtifactDir, taskStatus));
1679
1657
 
1680
1658
  // Use module-scope HARD_BLOCK / FULL_BLOCK (defined above analyze()).
1681
1659
  // pre-execution/terminal tasks: only HARD_BLOCK signals cause a block.
@@ -1713,7 +1691,8 @@ function resolveMaxBlocks() {
1713
1691
  }
1714
1692
 
1715
1693
  function blockStreakFile(root) {
1716
- return path.join(root, '.flow-agents', '.goal-fit-block-streak.json');
1694
+ const artifactRoot = flowAgentsArtifactRoot(root);
1695
+ return path.join(artifactRoot, '.goal-fit-block-streak.json');
1717
1696
  }
1718
1697
 
1719
1698
  function reasonsHash(warnings) {
@@ -17,6 +17,7 @@
17
17
  const fs = require('fs');
18
18
  const path = require('path');
19
19
  const { readLivenessEvents, freshHolders } = require('./lib/liveness-read');
20
+ const { flowAgentsArtifactRootsForRead } = require('./lib/local-artifact-paths');
20
21
 
21
22
  const STEERING = {
22
23
  'tool-planner': [
@@ -101,7 +102,8 @@ function readJson(file) {
101
102
  }
102
103
 
103
104
  function latestWorkflowState(root) {
104
- const states = walkStateFiles(path.join(root, '.flow-agents'))
105
+ const states = flowAgentsArtifactRootsForRead(root)
106
+ .flatMap(artifactRoot => walkStateFiles(artifactRoot))
105
107
  .map(file => {
106
108
  let stat;
107
109
  try { stat = fs.statSync(file); } catch { return null; }
@@ -295,8 +297,8 @@ function resumeSteering(root, current) {
295
297
 
296
298
  // Liveness advisory
297
299
  try {
298
- const livenessFile = path.join(root, '.flow-agents', 'liveness', 'events.jsonl');
299
- const events = readLivenessEvents(livenessFile);
300
+ const events = flowAgentsArtifactRootsForRead(root)
301
+ .flatMap(artifactRoot => readLivenessEvents(path.join(artifactRoot, 'liveness', 'events.jsonl')));
300
302
  if (events.length > 0) {
301
303
  const selfActor = (process.env.FLOW_AGENTS_ACTOR || '').trim() || 'local';
302
304
  const holders = freshHolders(events, slug, selfActor, Date.now());
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+ //
3
+ // Single normative source for the command-log hash-chain primitives and the
4
+ // exit-code-laundering heuristic.
5
+ //
6
+ // These were previously copy-pasted across the writer (hooks/evidence-capture.js),
7
+ // the verifier (hooks/stop-goal-fit.js), the repair tool (repair-command-log.js),
8
+ // and CI reconcile (ci/trust-reconcile.js) under "keep byte-identical" comments —
9
+ // the most security-sensitive path in the bundle, since the chain's integrity
10
+ // claim rests on writer and verifier canonicalizing identically. The copies had
11
+ // ALREADY drifted (ci/trust-reconcile's hasLaunderingOperator was missing the
12
+ // trailing `/bin/true` check), which is exactly the failure mode duplication
13
+ // invites. Importing from one module makes that divergence structurally impossible.
14
+ //
15
+ const crypto = require('crypto');
16
+
17
+ // The genesis prevHash is a FIXED ARBITRARY SENTINEL — NOT the SHA256 of any
18
+ // specific input string. (An earlier comment incorrectly claimed it was
19
+ // sha256("flow-agents:command-log:genesis"); that is wrong.) Writer and verifier
20
+ // MUST share this exact value — existing chained logs depend on it.
21
+ //
22
+ // HONEST FRAMING: this makes alteration DETECTABLE, not impossible. An agent that
23
+ // rewrites all hashes can still forge the chain. The real tamper-proof boundary is
24
+ // the signed checkpoint (B1). We do not oversell this boundary.
25
+ const CHAIN_GENESIS = 'a3f9e2b7d5c84f1e6a0d2c3b9f7e1a4d8c6b5f2e9a0d3c7b1f4e8a2d6c0b9f3';
26
+
27
+ /**
28
+ * Stable canonical JSON for a chain link: the record WITHOUT its `_chain` field,
29
+ * keys sorted alphabetically. This makes the hash independent of key insertion
30
+ * order and keeps `_chain` from contributing to its own hash.
31
+ */
32
+ function canonicalJsonForChain(record) {
33
+ const keys = Object.keys(record).filter((k) => k !== '_chain').sort();
34
+ const obj = {};
35
+ for (const k of keys) obj[k] = record[k];
36
+ return JSON.stringify(obj);
37
+ }
38
+
39
+ /** Chain link hash: sha256(prevHash + canonicalJsonForChain(record)), hex. */
40
+ function computeChainHash(prevHash, record) {
41
+ return crypto
42
+ .createHash('sha256')
43
+ .update(prevHash + canonicalJsonForChain(record), 'utf8')
44
+ .digest('hex');
45
+ }
46
+
47
+ /**
48
+ * True when a claimed verification command contains an exit-code-laundering
49
+ * operator. Legitimate verification commands never need these — their only
50
+ * purpose is to suppress a real non-zero exit:
51
+ * - ANY `||` (e.g. `npm test || exit 0`, `|| echo ok`, `|| /bin/true`)
52
+ * - `| true` (pipe into true — the pipeline absorbs the exit code)
53
+ * - trailing `; true` / `; :` / `; exit 0` / `; /bin/true` (and `\n` variants)
54
+ *
55
+ * FROZEN bar-raiser (ADR 0018). Do NOT add new evasion-pattern rules here; route new
56
+ * laundering shapes to the external CI anchor (trust-reconcile + the anti-gaming suite).
57
+ * Accepted limitation: the blanket `||` rule over-blocks legitimate control-flow `||`
58
+ * (e.g. `test -d node_modules || npm ci`) — it fails toward blocking by design.
59
+ */
60
+ function hasLaunderingOperator(cmd) {
61
+ // ANY || in a claimed verification command is an exit-code mask.
62
+ if (/\|\|/.test(cmd)) return true;
63
+ // | true — single-pipe into true always exits 0 regardless of the left side.
64
+ if (/\|\s*true\b/.test(cmd)) return true;
65
+ // Trailing ; or \n followed by an exit-neutralizing command:
66
+ if (/[;\n]\s*true\b/.test(cmd)) return true;
67
+ if (/[;\n]\s*:\s*(?:$|\s|;)/.test(cmd)) return true;
68
+ if (/[;\n]\s*exit\s+0\b/.test(cmd)) return true;
69
+ if (/[;\n]\s*\/bin\/true\b/.test(cmd)) return true;
70
+ return false;
71
+ }
72
+
73
+ module.exports = {
74
+ CHAIN_GENESIS,
75
+ canonicalJsonForChain,
76
+ computeChainHash,
77
+ hasLaunderingOperator,
78
+ };