harness-everything 0.40.0 → 0.41.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 (160) hide show
  1. package/.agents/harness-everything/project-docs.json +9 -0
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/CHANGELOG.md +43 -0
  5. package/benchmarks/results/f1-doc-path-baseline/1-source-clean.stderr.txt +0 -0
  6. package/benchmarks/results/f1-doc-path-baseline/1-source-clean.stdout.json +20 -0
  7. package/benchmarks/results/f1-doc-path-baseline/2-injected-architecture.md +229 -0
  8. package/benchmarks/results/f1-doc-path-baseline/2-source-injected.stderr.txt +0 -0
  9. package/benchmarks/results/f1-doc-path-baseline/2-source-injected.stdout.json +33 -0
  10. package/benchmarks/results/f1-doc-path-baseline/3-source-reverted.stderr.txt +0 -0
  11. package/benchmarks/results/f1-doc-path-baseline/3-source-reverted.stdout.json +20 -0
  12. package/benchmarks/results/f1-doc-path-baseline/4a-packaged-clean.stderr.txt +0 -0
  13. package/benchmarks/results/f1-doc-path-baseline/4a-packaged-clean.stdout.json +20 -0
  14. package/benchmarks/results/f1-doc-path-baseline/4b-packaged-injected.stderr.txt +0 -0
  15. package/benchmarks/results/f1-doc-path-baseline/4b-packaged-injected.stdout.json +33 -0
  16. package/benchmarks/results/f1-doc-path-baseline/5-source-missing-config.stderr.txt +0 -0
  17. package/benchmarks/results/f1-doc-path-baseline/5-source-missing-config.stdout.json +15 -0
  18. package/benchmarks/results/f1-doc-path-baseline/5b-packaged-missing-config.stderr.txt +0 -0
  19. package/benchmarks/results/f1-doc-path-baseline/5b-packaged-missing-config.stdout.json +15 -0
  20. package/benchmarks/results/f1-doc-path-baseline/README.md +54 -0
  21. package/benchmarks/results/f1-doc-path-baseline/before-fix/1-source-clean.stderr.txt +0 -0
  22. package/benchmarks/results/f1-doc-path-baseline/before-fix/1-source-clean.stdout.json +115 -0
  23. package/benchmarks/results/f1-doc-path-baseline/before-fix/2-injected-document.md +328 -0
  24. package/benchmarks/results/f1-doc-path-baseline/before-fix/2-source-injected.stderr.txt +0 -0
  25. package/benchmarks/results/f1-doc-path-baseline/before-fix/2-source-injected.stdout.json +127 -0
  26. package/benchmarks/results/f1-doc-path-baseline/before-fix/3-source-reverted.stderr.txt +0 -0
  27. package/benchmarks/results/f1-doc-path-baseline/before-fix/3-source-reverted.stdout.json +115 -0
  28. package/benchmarks/results/f1-doc-path-baseline/before-fix/4a-packaged-clean.stderr.txt +0 -0
  29. package/benchmarks/results/f1-doc-path-baseline/before-fix/4a-packaged-clean.stdout.json +115 -0
  30. package/benchmarks/results/f1-doc-path-baseline/before-fix/4b-packaged-injected.stderr.txt +0 -0
  31. package/benchmarks/results/f1-doc-path-baseline/before-fix/4b-packaged-injected.stdout.json +127 -0
  32. package/benchmarks/results/f1-doc-path-baseline/before-fix/5-source-missing-config.stderr.txt +0 -0
  33. package/benchmarks/results/f1-doc-path-baseline/before-fix/5-source-missing-config.stdout.json +15 -0
  34. package/benchmarks/results/f1-doc-path-baseline/before-fix/5b-packaged-missing-config.stderr.txt +0 -0
  35. package/benchmarks/results/f1-doc-path-baseline/before-fix/5b-packaged-missing-config.stdout.json +15 -0
  36. package/benchmarks/results/f1-doc-path-baseline/before-fix/6-supplementary-fixture-ok.input.md +8 -0
  37. package/benchmarks/results/f1-doc-path-baseline/before-fix/6-supplementary-fixture-ok.stdout.json +16 -0
  38. package/benchmarks/results/f1-doc-path-baseline/before-fix/README.md +34 -0
  39. package/benchmarks/results/f1-doc-path-baseline/probe-31-docs/after.json +248 -0
  40. package/benchmarks/results/f1-doc-path-baseline/probe-31-docs/before.json +254 -0
  41. package/benchmarks/results/f1-doc-path-baseline/probe-31-docs/probe.js +26 -0
  42. package/benchmarks/results/f2-doc-craft-baseline/README.md +32 -0
  43. package/benchmarks/results/f2-doc-craft-baseline/arm-f1-report.md +84 -0
  44. package/benchmarks/results/f2-doc-craft-baseline/arm-f1-task.md +21 -0
  45. package/benchmarks/results/f2-doc-craft-baseline/arm-f1.diff +19 -0
  46. package/benchmarks/results/f2-doc-craft-baseline/arm-f2-report.md +110 -0
  47. package/benchmarks/results/f2-doc-craft-baseline/arm-f2-task.md +21 -0
  48. package/benchmarks/results/f2-doc-craft-baseline/arm-f2.diff +19 -0
  49. package/benchmarks/results/f3-derived-views-baseline/README.md +34 -0
  50. package/benchmarks/results/f3-derived-views-baseline/arm-f2-report.md +43 -0
  51. package/benchmarks/results/f3-derived-views-baseline/arm-f2-task.md +21 -0
  52. package/benchmarks/results/f3-derived-views-baseline/arm-f2.diff +21 -0
  53. package/benchmarks/results/f3-derived-views-baseline/arm-f3-report.md +57 -0
  54. package/benchmarks/results/f3-derived-views-baseline/arm-f3-task.md +21 -0
  55. package/benchmarks/results/f3-derived-views-baseline/arm-f3.diff +21 -0
  56. package/benchmarks/results/live-host/claude-325-3b-s2b/README.md +99 -0
  57. package/benchmarks/results/live-host/claude-325-3b-s2b/capture.sanitized.jsonl +26 -0
  58. package/ci/archive/325-prep/README.md +71 -0
  59. package/ci/{prep → archive/325-prep}/doc-reconciliation-red.js +9 -7
  60. package/ci/archive/325-prep/f4-expected-red.json +34 -0
  61. package/ci/archive/325-prep/historical-qualification.test.js +143 -0
  62. package/ci/fixtures/doc-obligations/reconciliation-prep/cases.json +17 -4
  63. package/ci/fixtures/doc-obligations/reconciliation-prep/pending-red.json +8 -56
  64. package/ci/fixtures/doc-obligations/reconciliation-prep/trust-contract.json +2 -2
  65. package/ci/fixtures/doc-obligations/reconciliation-s1/cases.json +17 -5
  66. package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/harness.js +415 -0
  67. package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/off-handoff-golden.json +16 -0
  68. package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-post-tool-use-failure.json +11 -0
  69. package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-post-tool-use-success-minimal.json +9 -0
  70. package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-post-tool-use-success.json +13 -0
  71. package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-subagent-start.json +7 -0
  72. package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-subagent-stop-freetext-reject.json +11 -0
  73. package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-subagent-stop-reject.json +11 -0
  74. package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-subagent-stop.json +11 -0
  75. package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-task-result.json +19 -0
  76. package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/off-capture-only.json +99 -0
  77. package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/off-legacy.json +67 -0
  78. package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/on-concurrent-writers.json +62 -0
  79. package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/on-lookalikes-runtime-mutations.json +67 -0
  80. package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/on-malformed.json +85 -0
  81. package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/on-normal-session.json +74 -0
  82. package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/on-runtime-drift-later-npm-test.json +58 -0
  83. package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/on-valid-direct-quoted-review.json +85 -0
  84. package/ci/fixtures/doc-obligations/reconciliation-s2b/f4/cases.json +2267 -0
  85. package/ci/fixtures/doc-obligations/reconciliation-s2b/wiring/cases.json +1110 -0
  86. package/ci/fixtures/doc-obligations/reconciliation-s2b/wiring/legacy-off-golden.json +674 -0
  87. package/ci/lib/doc-reconciliation-file-spy.js +23 -0
  88. package/ci/lib/doc-reconciliation-prep-fixtures.js +136 -10
  89. package/ci/lib/doc-reconciliation-qualification-preload.js +37 -0
  90. package/ci/lib/doc-reconciliation-replay.js +104 -0
  91. package/ci/lib/doc-reconciliation-staged-tree.js +102 -0
  92. package/ci/mechanism-2p-opencode-v2-entrypoint.test.js +179 -0
  93. package/ci/mechanism-325-3b-preparation.test.js +27 -101
  94. package/ci/mechanism-325-3b-s2b-capture.test.js +561 -0
  95. package/ci/mechanism-325-3b-s2b-wiring.test.js +668 -0
  96. package/ci/mechanism-325-f1-doc-check.test.js +674 -0
  97. package/ci/mechanism-325-f2-doc-craft.test.js +597 -0
  98. package/ci/mechanism-325-f3-derived-views.test.js +798 -0
  99. package/ci/mechanism-325-f4-invariants.test.js +606 -0
  100. package/ci/mechanism-325-f4-projections.test.js +382 -0
  101. package/ci/mechanism-325-f4-qualification.test.js +228 -0
  102. package/ci/mechanism-325-f4-trust-boundary.test.js +880 -0
  103. package/ci/mutation-check.js +26 -3
  104. package/docs/architecture.md +5 -0
  105. package/docs/doc-workflow-reconciliation.md +331 -4
  106. package/docs/document-lifecycle.md +11 -0
  107. package/docs/governed-index.md +20 -0
  108. package/docs/platform-capabilities.md +22 -2
  109. package/grill-with-docs/SKILL.md +1 -1
  110. package/grill-with-docs/references/session-playbook.md +4 -0
  111. package/harness-everything/SKILL.md +1 -1
  112. package/harness-everything/references/doc-check.md +186 -0
  113. package/harness-everything/references/documentation/_shared/maintenance.md +41 -0
  114. package/harness-everything/references/documentation/_shared/writing-craft.md +59 -0
  115. package/harness-everything/references/triage-and-tiers.md +4 -0
  116. package/harness-everything/scripts/doc-check.js +442 -0
  117. package/harness-everything/scripts/doc-lifecycle.js +3 -0
  118. package/hooks/hooks.json +46 -0
  119. package/hooks/scripts/lib/doc-reconciliation-capture.sanitized.jsonl +26 -0
  120. package/hooks/scripts/lib/doc-reconciliation-pilot.js +171 -2
  121. package/hooks/scripts/lib/doc-reconciliation-provider.js +1059 -0
  122. package/hooks/scripts/lib/doc-reconciliation-qualification.json +34 -0
  123. package/hooks/scripts/lib/workflow-obligations.js +7 -0
  124. package/hooks/scripts/state-persist.js +78 -2
  125. package/hooks/scripts/workflow-stop-gate.js +7 -2
  126. package/opencode-plugin/README.md +15 -0
  127. package/opencode-plugin/index.mjs +77 -0
  128. package/opencode-plugin/plugin.json +1 -1
  129. package/package.json +1 -1
  130. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  131. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-capture.sanitized.jsonl +26 -0
  132. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-pilot.js +171 -2
  133. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-provider.js +1059 -0
  134. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-qualification.json +34 -0
  135. package/plugins/harness-everything/hooks/scripts/lib/workflow-obligations.js +7 -0
  136. package/plugins/harness-everything/hooks/scripts/state-persist.js +78 -2
  137. package/plugins/harness-everything/hooks/scripts/workflow-stop-gate.js +7 -2
  138. package/plugins/harness-everything/plugin.json +1 -1
  139. package/plugins/harness-everything/skills/grill-with-docs/SKILL.md +1 -1
  140. package/plugins/harness-everything/skills/grill-with-docs/references/session-playbook.md +4 -0
  141. package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
  142. package/plugins/harness-everything/skills/harness-everything/references/doc-check.md +186 -0
  143. package/plugins/harness-everything/skills/harness-everything/references/documentation/_shared/maintenance.md +41 -0
  144. package/plugins/harness-everything/skills/harness-everything/references/documentation/_shared/writing-craft.md +59 -0
  145. package/plugins/harness-everything/skills/harness-everything/references/triage-and-tiers.md +4 -0
  146. package/plugins/harness-everything/skills/harness-everything/scripts/doc-check.js +442 -0
  147. package/plugins/harness-everything/skills/harness-everything/scripts/doc-lifecycle.js +3 -0
  148. package/plugins/harness-everything/skills/repo-docs/SKILL.md +1 -1
  149. package/plugins/harness-everything/skills/repo-docs/references/process-guide.md +4 -0
  150. package/plugins/harness-everything/skills/to-spec/SKILL.md +1 -1
  151. package/plugins/harness-everything/skills/to-spec/references/process.md +4 -0
  152. package/plugins/harness-everything/skills/to-tickets/SKILL.md +1 -1
  153. package/plugins/harness-everything/skills/to-tickets/references/publishing-and-templates.md +4 -0
  154. package/repo-docs/SKILL.md +1 -1
  155. package/repo-docs/references/process-guide.md +4 -0
  156. package/sbom.cdx.json +7 -7
  157. package/to-spec/SKILL.md +1 -1
  158. package/to-spec/references/process.md +4 -0
  159. package/to-tickets/SKILL.md +1 -1
  160. package/to-tickets/references/publishing-and-templates.md +4 -0
@@ -25,9 +25,15 @@ const PILOT_SWITCH = 'HARNESS_DOC_RECONCILIATION_PILOT';
25
25
  const PILOT_REPORT_FILE = 'doc-reconciliation-pilot.json';
26
26
  const BASELINE_AUTHOR = Object.freeze({ name: 'doc-recon-replay', email: 'doc-recon-replay@invalid' });
27
27
  const BASELINE_DATE = '2026-01-01T00:00:00Z';
28
- const FIXTURE_OPS = new Set(['provideEvidence', 'mutateIdentity', 'timestampHints', 'seedState', 'gitCommit']);
28
+ const FIXTURE_OPS = new Set(['provideEvidence', 'mutateIdentity', 'timestampHints', 'seedState', 'gitCommit', 'editJournal', 'editDocCheck', 'observeStateTree']);
29
29
  const CONTROLLER_OPS = new Set(['plan', 'start', 'binding', 'stageBinding', 'step', 'obligation']);
30
- const ALL_OPS = new Set(['route', 'stop', 'stopGeneric', ...CONTROLLER_OPS, ...FIXTURE_OPS]);
30
+ // Host-transport ops (F4): hostRun executes a registered command the way the host would; hookDeliver sends one raw
31
+ // host payload through the real hook entry (state-persist.js). Neither writes a journal or accepted record itself.
32
+ const HOST_OPS = new Set(['hostRun', 'hookDeliver']);
33
+ const ALL_OPS = new Set(['route', 'stop', 'stopGeneric', ...CONTROLLER_OPS, ...FIXTURE_OPS, ...HOST_OPS]);
34
+ // CI-only qualification preloader. Passed as `node --require <file>` to every child of a replay that asks for it.
35
+ const QUALIFICATION_PRELOAD = path.join(__dirname, 'doc-reconciliation-qualification-preload.js');
36
+ const CAPTURE_JOURNAL_FILE = 'doc-reconciliation-capture.json';
31
37
  // The only session files a case may seed, and only inside its own temporary state home.
32
38
  const SEEDABLE_FILES = new Set(['workflow-stages.json']);
33
39
 
@@ -38,14 +44,18 @@ const DISTRIBUTIONS = Object.freeze({
38
44
  controller: path.join(REPO_ROOT, 'hooks', 'scripts', 'workflow-disposition.js'),
39
45
  stopGate: path.join(REPO_ROOT, 'hooks', 'scripts', 'workflow-stop-gate.js'),
40
46
  kernel: path.join(REPO_ROOT, 'harness-everything', 'scripts', 'kernel-router.js'),
41
- genericStopGate: path.join(REPO_ROOT, 'hooks', 'scripts', 'stop-gate.js')
47
+ genericStopGate: path.join(REPO_ROOT, 'hooks', 'scripts', 'stop-gate.js'),
48
+ stateHook: path.join(REPO_ROOT, 'hooks', 'scripts', 'state-persist.js'),
49
+ docCheck: path.join(REPO_ROOT, 'harness-everything', 'scripts', 'doc-check.js')
42
50
  },
43
51
  packaged: {
44
52
  root: path.join(REPO_ROOT, 'plugins', 'harness-everything'),
45
53
  controller: path.join(REPO_ROOT, 'plugins', 'harness-everything', 'hooks', 'scripts', 'workflow-disposition.js'),
46
54
  stopGate: path.join(REPO_ROOT, 'plugins', 'harness-everything', 'hooks', 'scripts', 'workflow-stop-gate.js'),
47
55
  kernel: path.join(REPO_ROOT, 'plugins', 'harness-everything', 'skills', 'harness-everything', 'scripts', 'kernel-router.js'),
48
- genericStopGate: path.join(REPO_ROOT, 'plugins', 'harness-everything', 'hooks', 'scripts', 'stop-gate.js')
56
+ genericStopGate: path.join(REPO_ROOT, 'plugins', 'harness-everything', 'hooks', 'scripts', 'stop-gate.js'),
57
+ stateHook: path.join(REPO_ROOT, 'plugins', 'harness-everything', 'hooks', 'scripts', 'state-persist.js'),
58
+ docCheck: path.join(REPO_ROOT, 'plugins', 'harness-everything', 'skills', 'harness-everything', 'scripts', 'doc-check.js')
49
59
  }
50
60
  });
51
61
 
@@ -94,13 +104,26 @@ function childEnv(workspace, stateHome, pilot) {
94
104
  return env;
95
105
  }
96
106
 
107
+ // A distribution is a named one above, or an explicit set of entry paths (F4-T2: a test runs the real entries from its own
108
+ // staged tree, with its own export). Either way every entry point is an absolute path.
109
+ const DISTRIBUTION_KEYS = ['controller', 'stopGate', 'kernel', 'genericStopGate', 'stateHook', 'docCheck'];
110
+ function resolveDistribution(distribution) {
111
+ if (typeof distribution === 'string') {
112
+ if (!DISTRIBUTIONS[distribution]) throw new Error(`unknown distribution ${distribution}`);
113
+ return DISTRIBUTIONS[distribution];
114
+ }
115
+ if (distribution && typeof distribution === 'object' && DISTRIBUTION_KEYS.every(key => typeof distribution[key] === 'string')) return distribution;
116
+ throw new Error('unknown distribution');
117
+ }
118
+
97
119
  function createSession(distribution, caseId, pilot) {
98
- if (!DISTRIBUTIONS[distribution]) throw new Error(`unknown distribution ${distribution}`);
120
+ const dist = resolveDistribution(distribution);
99
121
  const token = crypto.randomBytes(4).toString('hex');
100
122
  const workspace = fs.mkdtempSync(path.join(os.tmpdir(), `doc-recon-${caseId}-${distribution}-ws-${token}-`));
101
123
  const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), `doc-recon-${caseId}-${distribution}-state-${token}-`));
102
124
  return {
103
- distribution,
125
+ dist,
126
+ distribution: typeof distribution === 'string' ? distribution : 'custom',
104
127
  caseId,
105
128
  pilot,
106
129
  workspace,
@@ -114,8 +137,11 @@ function disposeSession(handle) {
114
137
  fs.rmSync(handle.stateHome, { recursive: true, force: true });
115
138
  }
116
139
 
140
+ // The preloader is a test-driver argument only: a replay with `preload: true` adds it to every child it launches.
117
141
  function runScript(handle, script, args, stdin, pilot) {
118
- return spawnSync(process.execPath, [script, ...args], {
142
+ const preload = handle.preload ? ['--require', QUALIFICATION_PRELOAD] : [];
143
+ // Test-only extra arguments (for example a file-access spy). They come before the preloader, so the spy sees everything.
144
+ return spawnSync(process.execPath, [...(handle.nodeArgs || []), ...preload, script, ...args], {
119
145
  cwd: handle.workspace,
120
146
  encoding: 'utf8',
121
147
  input: stdin === undefined ? undefined : JSON.stringify(stdin),
@@ -159,10 +185,13 @@ function readJsonOrNull(file) {
159
185
  function snapshot(handle) {
160
186
  const dir = sessionDirOf(handle);
161
187
  if (!dir) return { workflow: null, obligations: null, handoff: null };
188
+ // The capture journal is included only when a hook actually wrote it, so legacy replays keep their shape.
189
+ const capture = readJsonOrNull(path.join(dir, CAPTURE_JOURNAL_FILE));
162
190
  return {
163
191
  workflow: readJsonOrNull(path.join(dir, 'workflow-run.json')),
164
192
  obligations: readJsonOrNull(path.join(dir, 'workflow-obligations.json')),
165
- handoff: readJsonOrNull(path.join(dir, 'handoff-state.json'))
193
+ handoff: readJsonOrNull(path.join(dir, 'handoff-state.json')),
194
+ ...(capture ? { capture } : {})
166
195
  };
167
196
  }
168
197
 
@@ -207,6 +236,20 @@ function runFixture(handle, action, pilot) {
207
236
  fs.writeFileSync(target, String(p.content), 'utf8');
208
237
  return fixtureResult(handle, pilot, { op: 'mutateIdentity', kind: p.kind, path: p.path });
209
238
  }
239
+ if (action.op === 'observeStateTree') {
240
+ // Read-only: lists the relative paths the replay wrote under its state home and workspace (F4-T12 matrix; no contents).
241
+ const list = root => { const out = []; const walk = dir => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) walk(full); else out.push(path.relative(root, full).split(path.sep).join('/')); } }; if (fs.existsSync(root)) walk(root); return out.sort(); };
242
+ return fixtureResult(handle, pilot, { op: 'observeStateTree', stateFiles: list(handle.stateHome), workspaceFiles: list(handle.workspace) });
243
+ }
244
+ if (action.op === 'editDocCheck') {
245
+ // Appends to the leg's installed doc-check file after a capture (F4-T6: the assessment-time digest check). Only a staged
246
+ // copy may change: the target must sit outside the repository.
247
+ if (typeof p.append !== 'string') throw new Error('editDocCheck needs an append string');
248
+ const target = handle.dist.docCheck;
249
+ if (path.basename(target) !== 'doc-check.js' || target.startsWith(REPO_ROOT + path.sep)) throw new Error('editDocCheck may only edit a staged doc-check copy');
250
+ fs.appendFileSync(target, p.append, 'utf8');
251
+ return fixtureResult(handle, pilot, { op: 'editDocCheck' });
252
+ }
210
253
  if (action.op === 'gitCommit') {
211
254
  // Commits the replay workspace's current tree into HEAD mid-workflow. The baseline recorded at plan is not moved by
212
255
  // this fixture: the case exists to show that scope is still assessed against that recorded baseline. A clean tree
@@ -230,6 +273,17 @@ function runFixture(handle, action, pilot) {
230
273
  fs.writeFileSync(target, JSON.stringify(p.json, null, 2), 'utf8');
231
274
  return fixtureResult(handle, pilot, { op: 'seedState', file: p.file });
232
275
  }
276
+ if (action.op === 'editJournal') {
277
+ // Rewrites one retained field of the replay's own capture journal, as an edited payload or result would appear on disk.
278
+ const dir = sessionDirOf(handle);
279
+ if (!dir) throw new Error('editJournal needs an existing session');
280
+ const file = path.join(dir, CAPTURE_JOURNAL_FILE);
281
+ const doc = readJsonOrNull(file);
282
+ if (!doc || !Array.isArray(doc.records) || !doc.records[p.index]) throw new Error('editJournal needs an existing journal record');
283
+ doc.records[p.index][p.key] = p.value;
284
+ fs.writeFileSync(file, JSON.stringify(doc, null, 2), 'utf8');
285
+ return fixtureResult(handle, pilot, { op: 'editJournal', key: p.key });
286
+ }
233
287
  if (action.op === 'timestampHints') {
234
288
  const dir = sessionDirOf(handle);
235
289
  if (!dir) throw new Error('timestampHints needs an existing session');
@@ -244,13 +298,82 @@ function runFixture(handle, action, pilot) {
244
298
  throw new Error(`unknown fixture op ${action.op}`);
245
299
  }
246
300
 
301
+ function resultOf(handle, result, pilot) {
302
+ return {
303
+ exitCode: result.status,
304
+ signal: result.signal,
305
+ error: result.error ? result.error.message : null,
306
+ errorCode: result.error ? result.error.code || null : null,
307
+ stdout: result.stdout || '',
308
+ stderr: result.stderr || '',
309
+ json: parseJsonOrNull(result.stdout || ''),
310
+ state: snapshot(handle),
311
+ pilot
312
+ };
313
+ }
314
+
315
+ const DOC_CHECK_TOKEN = '{{DOC_CHECK}}';
316
+ const CAPTURE_HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'SubagentStart', 'SubagentStop']);
317
+
318
+ // Host-transport ops (F4). hostRun executes the distribution's registered doc-check entry by argv, with no shell, the
319
+ // way the host runs a registered command. hookDeliver sends one raw host payload to the real state-persist hook, with
320
+ // the session and workspace fields the host would send. Payload content is declared by the case and then the entry
321
+ // itself records it; the replay never writes a journal record or accepted event directly.
322
+ function runHostOp(handle, action, pilot) {
323
+ const dist = handle.dist;
324
+ const p = action.payload || {};
325
+ if (action.op === 'hostRun') {
326
+ if (p.command !== 'doc-check' || !Array.isArray(p.args) || !p.args.every(arg => typeof arg === 'string')) {
327
+ throw new Error('hostRun needs command "doc-check" and string args');
328
+ }
329
+ return resultOf(handle, runScript(handle, scriptFor(handle, 'docCheck', dist.docCheck), p.args, undefined, pilot), pilot);
330
+ }
331
+ if (!CAPTURE_HOOK_EVENTS.has(p.event)) throw new Error(`hookDeliver event ${p.event} is not a capture event`);
332
+ const body = {
333
+ session_id: p.sessionId === undefined ? handle.sessionId : p.sessionId,
334
+ cwd: handle.workspace,
335
+ hook_event_name: p.event
336
+ };
337
+ if (p.event === 'PreToolUse' || p.event === 'PostToolUse' || p.event === 'PostToolUseFailure') {
338
+ if (typeof p.command !== 'string' || typeof p.toolUseId !== 'string') throw new Error('hookDeliver tool events need command and toolUseId');
339
+ body.tool_name = p.toolName === undefined ? 'Bash' : p.toolName;
340
+ // {{DOC_CHECK}} is the installed doc-check path of the leg under test, so a positive fixture invokes the path the
341
+ // host would really run; a relative spelling stays a literal negative case.
342
+ body.tool_input = { command: p.command.split(DOC_CHECK_TOKEN).join(dist.docCheck) };
343
+ body.tool_use_id = p.toolUseId;
344
+ // A subagent's own tool call carries its host agent_id at the top level (F4 executor identity).
345
+ if (p.agentId !== undefined) body.agent_id = p.agentId;
346
+ }
347
+ if (p.event === 'PostToolUse') {
348
+ if (p.fromRun !== undefined) {
349
+ const run = handle.actions[p.fromRun];
350
+ if (!run) throw new Error(`hookDeliver fromRun ${p.fromRun} names no earlier action`);
351
+ body.tool_response = { stdout: run.stdout, stderr: '', interrupted: false };
352
+ } else {
353
+ body.tool_response = p.response;
354
+ }
355
+ if (p.interrupted !== undefined) body.tool_response.interrupted = p.interrupted;
356
+ if (p.omitInterrupted === true) delete body.tool_response.interrupted;
357
+ } else if (p.event === 'PostToolUseFailure') {
358
+ body.error = p.error;
359
+ body.is_interrupt = p.isInterrupt === true;
360
+ } else {
361
+ body.agent_id = p.agentId;
362
+ body.agent_type = p.agentType;
363
+ if (p.event === 'SubagentStop') body.last_assistant_message = p.lastAssistantMessage;
364
+ }
365
+ // The hook is fail-open and prints nothing, so only its exit status and the journal it writes are observable.
366
+ return resultOf(handle, runScript(handle, scriptFor(handle, 'hookDeliver', dist.stateHook), [], body, pilot), pilot);
367
+ }
368
+
247
369
  // Actions are the only way replay state changes. Each controller/stop op maps to the existing public entry point.
248
370
  function runAction(handle, action, pilot) {
249
- const dist = DISTRIBUTIONS[handle.distribution];
371
+ const dist = handle.dist;
250
372
  const sid = handle.sessionId;
251
373
  const p = action.payload || {};
252
374
  if (!ALL_OPS.has(action.op)) throw new Error(`unsupported replay op ${action.op}`);
253
375
  if (FIXTURE_OPS.has(action.op)) return runFixture(handle, action, pilot);
376
+ if (HOST_OPS.has(action.op)) return runHostOp(handle, action, pilot);
254
377
  let result;
255
378
  if (action.op === 'route') {
256
379
  result = runScript(handle, scriptFor(handle, 'route', dist.kernel), [], { session_id: sid, cwd: handle.workspace, prompt: p.prompt }, pilot);
@@ -376,13 +499,16 @@ function runtimePilotOf(handle) {
376
499
  // Replays one case through one distribution. Actions run in order and the replay stops at its first infrastructure
377
500
  // fault: the faulted action is recorded, no later action runs, and the temporary directories are still removed.
378
501
  // A crash is recorded, not hidden.
502
+ // `options.preload: true` adds the CI-only qualification preloader to every child (F4 test transport). Production
503
+ // replays never set it, so the production gate stays unmet.
379
504
  function replayCase(caseDef, distribution, options = {}) {
380
505
  const limits = limitsFor(caseDef);
381
506
  const mode = options.pilot === undefined ? 'off' : 'on';
382
- const handle = { ...createSession(distribution, caseDef.id, options.pilot), distribution, limits };
507
+ const handle = { ...createSession(distribution, caseDef.id, options.pilot), limits, preload: options.preload === true, nodeArgs: Array.isArray(options.nodeArgs) ? options.nodeArgs : [] };
383
508
  try {
384
509
  materialiseInput(handle, caseDef.input);
385
510
  const actions = {};
511
+ handle.actions = actions;
386
512
  for (const action of caseDef.actions) {
387
513
  if (Object.prototype.hasOwnProperty.call(actions, action.id)) throw new Error(`duplicate action id ${action.id}`);
388
514
  const payload = action.payload && action.payload.requirements === '$requirements'
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ // #325 Phase 3b F4 CI-only qualification preloader (docs/doc-workflow-reconciliation.md, F4 "Test transport").
4
+ //
5
+ // Loaded only by the test driver: `node --require <this file> <real entry> ...`. It is not packaged, no
6
+ // production code reads it, and no environment or argv switch enables it.
7
+ //
8
+ // Boundary: it supplies only the explicitly fake qualification object through the origin-provider factory,
9
+ // and only when the caller omitted qualification. It never touches the snapshot, resolveEvent outcomes,
10
+ // principals, capability, accepted records or core eligibility.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const FAKE_QUALIFICATION = Object.freeze({
16
+ source: 'fake-test-fixture',
17
+ faked: true,
18
+ ref: 'ci-qualification-preload',
19
+ toolUseIdPopulated: true,
20
+ agentIdPopulated: true,
21
+ });
22
+
23
+ // The entry decides which provider copy is patched, so the canonical and packaged legs are each covered.
24
+ const entry = process.argv[1] ? path.resolve(process.argv[1]) : '';
25
+ const providerFile = path.join(path.dirname(entry), 'lib', 'doc-reconciliation-provider.js');
26
+
27
+ if (fs.existsSync(providerFile)) {
28
+ const provider = require(providerFile);
29
+ const factory = provider.createClaudeOriginProvider;
30
+ provider.createClaudeOriginProvider = input => {
31
+ const safe = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
32
+ return factory({
33
+ ...safe,
34
+ qualification: safe.qualification === undefined ? FAKE_QUALIFICATION : safe.qualification,
35
+ });
36
+ };
37
+ }
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { spawn, spawnSync } = require('child_process');
7
+ const fx = require('./doc-reconciliation-prep-fixtures');
8
+
9
+ const CORPUS = path.join(__dirname, '..', 'fixtures', 'doc-obligations', 'reconciliation-s2b', 'corpus');
10
+ const CASES = path.join(__dirname, '..', 'fixtures', 'doc-obligations', 'reconciliation-s2b', 'f4', 'cases.json');
11
+ const EVENT_HOOKS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'SubagentStart', 'SubagentStop']);
12
+ const SITES = ['moduleLoads', 'stateFiles', 'captureRecords', 'pilotReport', 'workflowStep', 'workflowStop', 'genericStop'];
13
+
14
+ function load(streamId) {
15
+ const file = path.join(CORPUS, `${streamId}.json`);
16
+ const stream = JSON.parse(fs.readFileSync(file, 'utf8'));
17
+ if (!stream.id || !Array.isArray(stream.events) || !stream.expectations) throw new Error(`invalid stream ${file}`);
18
+ for (const site of SITES) if (!stream.expectations[site]) throw new Error(`${stream.id} missing expectation site ${site}`);
19
+ return stream;
20
+ }
21
+
22
+ function tracePreloader(file) {
23
+ fs.writeFileSync(file, `const M=require('module'), fs=require('fs'); const old=M._load; M._load=function(id,p,m){const x=old.apply(this,arguments);if(typeof id==='string'&&/(doc-reconciliation|state-persist|workflow-disposition|stop-gate)/.test(id))fs.appendFileSync(process.env.F4_C1_TRACE,JSON.stringify({pid:process.pid,module:id,requester:m&&m.filename})+'\\n');return x};\n`);
24
+ }
25
+
26
+ function actionsFor(stream, template) {
27
+ const actions = template.actions.filter(a => !['lint-run','post-lint','verify-pass','stop','stop-generic'].includes(a.id));
28
+ stream.events.forEach((event, i) => {
29
+ if (!EVENT_HOOKS.has(event.event)) throw new Error(`${stream.id}: unsupported event ${event.event}`);
30
+ const id = `capture-${String(i + 1).padStart(2, '0')}`;
31
+ if (event.runArgs) actions.push({ id: `${id}-run`, op: 'hostRun', payload: { command: 'doc-check', args: event.runArgs } });
32
+ const payload = { event: event.event };
33
+ if (event.toolName) payload.toolName = event.toolName;
34
+ if (event.command !== undefined) payload.command = event.command;
35
+ if (event.toolUseId) payload.toolUseId = event.toolUseId;
36
+ if (event.runArgs && event.event === 'PostToolUse') payload.fromRun = `${id}-run`;
37
+ if (event.event === 'PostToolUse') payload.response = { stdout: event.runArgs ? 'doc-check completed' : 'completed', stderr: '', interrupted: false };
38
+ if (event.event === 'PostToolUseFailure') { payload.error = 'stable fixture failure'; payload.isInterrupt = false; }
39
+ if (event.event === 'SubagentStart' || event.event === 'SubagentStop') {
40
+ payload.agentId = `agent-${i + 1}`; payload.agentType = 'reviewer';
41
+ if (event.event === 'SubagentStop') payload.lastAssistantMessage = 'APPROVE: reviewed the governed document.';
42
+ }
43
+ actions.push({ id, op: 'hookDeliver', payload });
44
+ });
45
+ actions.push({ id: 'verify-pass', op: 'step', payload: { stepId: 'req-verify', disposition: 'pass', evidence: 'recorded document verification' }, expectExit: { on: [0, 2] } });
46
+ actions.push({ id: 'stop', op: 'stop' }, { id: 'stop-generic', op: 'stopGeneric' });
47
+ return actions;
48
+ }
49
+
50
+ function concurrent(stream, leg, mode) {
51
+ const dist = fx.DISTRIBUTIONS[leg];
52
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'f4c1-concurrent-'));
53
+ const stateHome = path.join(root, 'state'); const workspace = path.join(root, 'workspace');
54
+ fs.mkdirSync(workspace); fs.mkdirSync(stateHome);
55
+ const barrier = path.join(root, 'barrier'); fs.mkdirSync(barrier);
56
+ const worker = path.join(root, 'worker.js');
57
+ fs.writeFileSync(worker, `const fs=require('fs'),cp=require('child_process'),path=require('path');const [hook,barrier,home,workspace,sid,index,pilot]=process.argv.slice(2);fs.writeFileSync(path.join(barrier,'ready-'+index),'ready');while(!fs.existsSync(path.join(barrier,'go')))Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,20);const p={session_id:sid,cwd:workspace,hook_event_name:'PostToolUse',tool_name:'Bash',tool_input:{command:'npm test'},tool_use_id:'concurrent-'+index,tool_response:{stdout:'ok',stderr:'',interrupted:false}};const r=cp.spawnSync(process.execPath,[hook],{input:JSON.stringify(p),encoding:'utf8',env:{...process.env,HARNESS_STATE_HOME:home,HARNESS_WORKSPACE_ROOT:workspace,...(pilot==='on'?{HARNESS_DOC_RECONCILIATION_PILOT:'1'}:{})}});process.exit(r.status||0);`);
58
+ const children = [1, 2].map(i => spawn(process.execPath, [worker, dist.stateHook, barrier, stateHome, workspace, 'concurrent-session', String(i), mode], { stdio: 'ignore' }));
59
+ while (![1, 2].every(i => fs.existsSync(path.join(barrier, `ready-${i}`)))) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
60
+ fs.writeFileSync(path.join(barrier, 'go'), 'release');
61
+ const exits = children.map(child => new Promise(resolve => child.on('exit', (code, signal) => resolve({ code, signal }))));
62
+ return Promise.all(exits).then(results => {
63
+ const files=[]; const walk=d=>{for(const e of fs.readdirSync(d,{withFileTypes:true})){const f=path.join(d,e.name);if(e.isDirectory())walk(f);else files.push({path:path.relative(stateHome,f),json:f.endsWith('.json')?safeJson(f):undefined,provenance:'concurrent state-persist process'});}};walk(stateHome);
64
+ fs.rmSync(root,{recursive:true,force:true}); return {results,files};
65
+ });
66
+ }
67
+ function safeJson(file){try{return JSON.parse(fs.readFileSync(file,'utf8'));}catch(_){return null;}}
68
+
69
+ async function replay(streamId, leg='source', mode='on') {
70
+ if (!fx.DISTRIBUTIONS[leg]) throw new Error(`unknown distribution ${leg}`);
71
+ if (!['on','off'].includes(mode)) throw new Error(`unknown mode ${mode}`);
72
+ const stream=load(streamId); const cases=JSON.parse(fs.readFileSync(CASES,'utf8')).cases;
73
+ const template=cases.find(item=>item.id==='F4-POS-body-edit');
74
+ const definition={...template,id:stream.id,actions:actionsFor(stream,template)};
75
+ const scratch=fs.mkdtempSync(path.join(os.tmpdir(),'f4c1-')); const spy=path.join(scratch,'trace.js'); const trace=path.join(scratch,'modules.jsonl'); tracePreloader(spy);
76
+ const previous=process.env.F4_C1_TRACE; process.env.F4_C1_TRACE=trace;
77
+ let root;
78
+ try { root=fx.replayCase(definition,leg,{...(mode==='on'?{pilot:'1'}:{}),nodeArgs:['--require',spy]}); }
79
+ finally { if(previous===undefined)delete process.env.F4_C1_TRACE;else process.env.F4_C1_TRACE=previous; }
80
+ const modules=fs.existsSync(trace)?fs.readFileSync(trace,'utf8').trim().split('\n').filter(Boolean).map(JSON.parse):[];
81
+ const stateFiles=[]; const provenance=[];
82
+ for(const [actionId,action] of Object.entries(root.actions)) for(const key of ['workflow','obligations','handoff','capture']) if(action.state&&action.state[key]) {
83
+ stateFiles.push({actionId,key,provenance:`snapshot after ${actionId}`,value:action.state[key]});
84
+ if(key==='capture') for(const record of action.state.capture.records||[]) provenance.push({actionId,event:record.event,toolUseId:record.tool_use_id||record.toolUseId,provenance:'state-persist capture journal'});
85
+ }
86
+ const result={stream:stream.id,leg,mode,infrastructureErrors:fx.infrastructureFaults(definition,root,mode),moduleLoads:modules,stateFiles,captureRecords:provenance,pilotReport:root.pilot,pilotReportOrigin:root.pilotReportOrigin,workflowStep:Object.fromEntries(Object.entries(root.actions).filter(([id])=>id==='verify-pass').map(([id,a])=>[id,{exitCode:a.exitCode,stdout:a.stdout,stderr:a.stderr}])),workflowStop:root.actions.stop&&{exitCode:root.actions.stop.exitCode,stdout:root.actions.stop.stdout,stderr:root.actions.stop.stderr},genericStop:root.actions['stop-generic']&&{exitCode:root.actions['stop-generic'].exitCode,stdout:root.actions['stop-generic'].stdout,stderr:root.actions['stop-generic'].stderr},expectations:stream.expectations};
87
+ if(stream.concurrency) result.concurrency=await concurrent(stream,leg,mode);
88
+ fs.rmSync(scratch,{recursive:true,force:true});
89
+ return result;
90
+ }
91
+
92
+ async function selfCheck(){
93
+ const ids=fs.readdirSync(CORPUS).filter(f=>f.endsWith('.json')).map(f=>path.basename(f,'.json')).sort();
94
+ let failed=false;
95
+ for(const leg of ['source','packaged']) for(const id of ids){const stream=load(id);const result=await replay(id,leg,stream.mode);const journal=result.concurrency&&result.concurrency.files.find(file=>file.path.endsWith('doc-reconciliation-capture.json'));const concurrentBad=result.concurrency&&(result.concurrency.results.some(x=>x.code!==0)||!journal||journal.json.records.length!==2);const bad=result.infrastructureErrors.length||concurrentBad;console.log(`${bad?'INFRA':'OK'} ${leg}/${id}${bad?`: ${JSON.stringify(result.infrastructureErrors)} ${JSON.stringify(result.concurrency&&result.concurrency.results)} journalRecords=${journal&&journal.json.records.length}`:''}`);failed ||= Boolean(bad);}
96
+ return failed?1:0;
97
+ }
98
+
99
+ if(require.main===module){
100
+ if(process.argv[2]==='--worker')process.exit(0);
101
+ if(process.argv[2]==='--self-check')selfCheck().then(code=>process.exit(code));
102
+ else replay(process.argv[2],process.argv[3]||'source',process.argv[4]||'on').then(r=>console.log(JSON.stringify(r,null,2)));
103
+ }
104
+ module.exports={load,replay,selfCheck};
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ // #325 F4-T2 test helper: stages a scratch copy of one leg's real entries (hooks, harness scripts and the retained
4
+ // live-host capture), so a test chooses the qualification export itself. A staged tree never carries the repository's
5
+ // export, and nothing here writes into the repository. The real entries run from the copy, with copied siblings, so
6
+ // the provider walks up to the copied retained directory exactly as it would in the checkout.
7
+
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const crypto = require('crypto');
12
+
13
+ const ROOT = path.resolve(__dirname, '..', '..');
14
+ const LIVE_REL = path.join('benchmarks', 'results', 'live-host', 'claude-325-3b-s2b');
15
+ const EXPORT_FILE = 'doc-reconciliation-qualification.json';
16
+ const CAPTURE_FILE = 'capture.sanitized.jsonl';
17
+ // The export names its sanitized capture as a sibling in the same lib directory, so source and packaged copies resolve it
18
+ // the same way (the provider reads it from there).
19
+ const SIBLING_CAPTURE = 'doc-reconciliation-capture.sanitized.jsonl';
20
+ // Facts observed in the retained Run 2b capture (see benchmarks/results/live-host/claude-325-3b-s2b/README.md). A valid
21
+ // export states exactly these; the runtime checks them against the sibling file.
22
+ const OBSERVED_PAYLOAD_KINDS = {
23
+ PostToolUse: ['agent_id', 'interrupted', 'tool_use_id'],
24
+ PreToolUse: ['agent_id', 'tool_use_id'],
25
+ SubagentStart: ['agent_id'],
26
+ SubagentStop: ['agent_id'],
27
+ };
28
+ const OBSERVED_CORRELATION = { preToPostByToolUseId: true, interruptedExplicit: true, subagentStartStopSameAgent: true, agentResultAliasCorrelated: true };
29
+
30
+ // Top-level entries the source leg never reads: VCS and dependency trees, the worktrees, the test corpus, the evidence
31
+ // directories and the packaging copies. The live-host capture is copied separately, below.
32
+ const SOURCE_EXCLUDE = new Set(['.git', 'node_modules', '.worktrees', 'ci', 'benchmarks', 'behavioral-evals', 'evals', 'docs', 'plugins', 'submission', 'opencode-plugin']);
33
+
34
+ function sha256File(file) {
35
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
36
+ }
37
+
38
+ // Stages one leg. Returns the replay distribution (explicit entry paths inside the copy) and the helpers a test needs.
39
+ // The live-host directory is copied to the top of the tree, which is where the provider's upward walk finds it.
40
+ function stage(leg) {
41
+ if (leg !== 'source' && leg !== 'packaged') throw new Error(`unknown leg ${leg}`);
42
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `f4-staged-${leg}-`));
43
+ let base;
44
+ if (leg === 'source') {
45
+ // The source package: the root carries the hooks, the harness scripts, the shared libraries and the root skill
46
+ // folders that bindings resolve. Only data that the entries never read is left out.
47
+ for (const entry of fs.readdirSync(ROOT)) {
48
+ if (SOURCE_EXCLUDE.has(entry)) continue;
49
+ fs.cpSync(path.join(ROOT, entry), path.join(root, entry), { recursive: true });
50
+ }
51
+ base = root;
52
+ } else {
53
+ fs.cpSync(path.join(ROOT, 'plugins', 'harness-everything'), path.join(root, 'plugin'), { recursive: true });
54
+ base = path.join(root, 'plugin');
55
+ }
56
+ fs.cpSync(path.join(ROOT, LIVE_REL), path.join(root, LIVE_REL), { recursive: true });
57
+ const libDir = path.join(base, 'hooks', 'scripts', 'lib');
58
+ // The copied tree may carry a checkout's own export: a staged tree starts without one.
59
+ fs.rmSync(path.join(libDir, EXPORT_FILE), { force: true });
60
+ fs.rmSync(path.join(libDir, SIBLING_CAPTURE), { force: true });
61
+ fs.copyFileSync(path.join(root, LIVE_REL, CAPTURE_FILE), path.join(libDir, SIBLING_CAPTURE));
62
+ const skillScripts = leg === 'source' ? path.join(base, 'harness-everything', 'scripts') : path.join(base, 'skills', 'harness-everything', 'scripts');
63
+ const dist = {
64
+ controller: path.join(base, 'hooks', 'scripts', 'workflow-disposition.js'),
65
+ stopGate: path.join(base, 'hooks', 'scripts', 'workflow-stop-gate.js'),
66
+ kernel: path.join(skillScripts, 'kernel-router.js'),
67
+ genericStopGate: path.join(base, 'hooks', 'scripts', 'stop-gate.js'),
68
+ stateHook: path.join(base, 'hooks', 'scripts', 'state-persist.js'),
69
+ docCheck: path.join(skillScripts, 'doc-check.js'),
70
+ };
71
+ return {
72
+ root,
73
+ leg,
74
+ dist,
75
+ libDir,
76
+ exportFile: path.join(libDir, EXPORT_FILE),
77
+ captureFile: path.join(libDir, SIBLING_CAPTURE),
78
+ retainedCaptureFile: path.join(root, LIVE_REL, CAPTURE_FILE),
79
+ // The export that binds the staged capture bytes and records the two populated fields.
80
+ // The valid export for this capture. `overrides` lets a test change one field, to show the runtime refuses it.
81
+ validExport(overrides = {}) {
82
+ return {
83
+ schema: 'harness.doc-reconciliation.qualification.v1',
84
+ host: 'claude-code',
85
+ hostVersion: '2.1.308',
86
+ captureRecordVersion: 'claude-capture-record/1',
87
+ payloadKinds: OBSERVED_PAYLOAD_KINDS,
88
+ correlation: OBSERVED_CORRELATION,
89
+ sanitizedCapture: { file: SIBLING_CAPTURE, sha256: sha256File(path.join(libDir, SIBLING_CAPTURE)) },
90
+ ...overrides,
91
+ };
92
+ },
93
+ writeExport(content) {
94
+ fs.writeFileSync(this.exportFile, typeof content === 'string' ? content : JSON.stringify(content, null, 2) + '\n', 'utf8');
95
+ },
96
+ cleanup() {
97
+ fs.rmSync(root, { recursive: true, force: true });
98
+ },
99
+ };
100
+ }
101
+
102
+ module.exports = { stage, EXPORT_FILE, SIBLING_CAPTURE, LIVE_REL, CAPTURE_FILE };