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
@@ -0,0 +1,561 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // R2c-1 (#325 3b S2b RED part 1): capture + origin provider.
5
+ //
6
+ // Binding spec at the time of writing: docs/doc-workflow-reconciliation.md
7
+ // § S2b — "Activation and pilot-only scope", "Claude capture and
8
+ // origin-provider contract", "Retained live-capture gate"; owner decisions in
9
+ // dyphn1/Harness-everything#325 (issuecomment-6312323110), rows 1 and 2.
10
+ //
11
+ // Contract of this suite:
12
+ // - Every assertion runs on both legs (canonical hooks/scripts and the
13
+ // packaged plugins/harness-everything copy). Missing S2b modules or exports
14
+ // are FAIL with a module-missing/export-missing reason; the harness never
15
+ // crashes; INFRA is reserved for fixture/subprocess faults.
16
+ // - Items 1 (OFF) and the fail-open control drive the real state-persist.js
17
+ // script per leg; items 3–6 bind the future S2b origin provider module
18
+ // through the S2a resolveContext/resolveEvent seam contract.
19
+ // - Exit codes: 1 if any assertion failed (expected at RED for
20
+ // missing-implementation reasons only); 0 only when every assertion passes.
21
+
22
+ const { spawnSync } = require('child_process');
23
+ const fs = require('fs');
24
+ const os = require('os');
25
+ const path = require('path');
26
+ const H = require('./fixtures/doc-obligations/reconciliation-s2b/capture/harness');
27
+ const { DISTRIBUTIONS } = require('./lib/doc-reconciliation-prep-fixtures');
28
+
29
+ const PILOT_SWITCH = 'HARNESS_DOC_RECONCILIATION_PILOT';
30
+ const PROVIDER_REQ = [{ file: 'doc-reconciliation-provider.js', exports: ['createClaudeOriginProvider'] }];
31
+
32
+ const runner = H.createRunner('R2c-1 S2b capture/provider');
33
+ const GOLDEN = H.golden();
34
+
35
+ function legacyGoldenChecks(id, drive) {
36
+ if (drive.code !== 0) throw new Error(`state-persist exited ${drive.code}: ${(drive.stderr || drive.stdout).slice(0, 200)}`);
37
+ const names = Object.keys(drive.files);
38
+ for (const rel of names) {
39
+ if (!rel.endsWith('/handoff-state.json')) throw new Error(`OFF created an unexpected session file: ${rel}`);
40
+ }
41
+ const handoffRels = names.filter(rel => rel.endsWith('/handoff-state.json'));
42
+ if (handoffRels.length !== 1) throw new Error(`OFF expected exactly one handoff-state.json, got ${handoffRels.length}`);
43
+ const state = JSON.parse(drive.files[handoffRels[0]]);
44
+ const keys = Object.keys(state).sort();
45
+ if (JSON.stringify(keys) !== JSON.stringify([...GOLDEN.keys].sort())) {
46
+ throw new Error(`OFF handoff keyset changed: got ${JSON.stringify(keys)} expected ${JSON.stringify(GOLDEN.keys)}`);
47
+ }
48
+ for (const [key, expected] of Object.entries(GOLDEN.values)) {
49
+ if (state[key] !== expected) throw new Error(`OFF handoff ${key} changed: got ${state[key]} expected ${expected}`);
50
+ }
51
+ const captures = H.findCaptureRecords(drive.files);
52
+ if (captures.length) throw new Error(`OFF wrote a pilot capture record despite the switch being off: ${JSON.stringify(captures[0]).slice(0, 200)}`);
53
+ }
54
+
55
+ // locks: I2 — OFF is capture-only, with no pilot writes/capture and legacy OFF behavior preserved.
56
+ // F8 routes are capture-only when OFF. Drive the real source/package hook with
57
+ // an isolated state home and assert its OFF side effects (state, capture, output, exit).
58
+ const CAPTURE_ONLY_ROUTES = [
59
+ { id: 'pre-bash', event: 'PreToolUse', tool: 'Bash', input: { command: 'echo f8-probe' } },
60
+ { id: 'pre-powershell', event: 'PreToolUse', tool: 'PowerShell', input: { command: 'Write-Output f8-probe' } },
61
+ { id: 'pre-task', event: 'PreToolUse', tool: 'Task', input: { description: 'F8 capture probe' } },
62
+ { id: 'pre-agent', event: 'PreToolUse', tool: 'Agent', input: { description: 'F8 capture probe' } },
63
+ { id: 'post-task', event: 'PostToolUse', tool: 'Task', input: { description: 'F8 capture probe' } },
64
+ { id: 'post-agent', event: 'PostToolUse', tool: 'Agent', input: { description: 'F8 capture probe' } },
65
+ { id: 'subagent-start', event: 'SubagentStart' },
66
+ { id: 'subagent-stop', event: 'SubagentStop' },
67
+ ];
68
+ const OFF_SWITCH_VALUES = [
69
+ { id: 'unset' },
70
+ { id: 'zero', value: '0' },
71
+ { id: 'other', value: 'true' },
72
+ ];
73
+
74
+ function snapshotTree(root) {
75
+ const files = {};
76
+ function visit(dir) {
77
+ if (!fs.existsSync(dir)) return;
78
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
79
+ const abs = path.join(dir, entry.name);
80
+ if (entry.isDirectory()) visit(abs);
81
+ else if (entry.isFile()) files[path.relative(root, abs).split(path.sep).join('/')] = fs.readFileSync(abs, 'utf8');
82
+ }
83
+ }
84
+ visit(root);
85
+ return files;
86
+ }
87
+
88
+ function captureEventRecords(files) {
89
+ return Object.entries(files)
90
+ .filter(([name]) => /(?:^|\/)doc-reconciliation-capture\/\d{8}\.json$/.test(name))
91
+ .map(([, content]) => JSON.parse(content));
92
+ }
93
+
94
+ function driveCaptureOnlyRoute(leg, route, switchSetting) {
95
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'f10-f8-capture-'));
96
+ const stateHome = path.join(temp, 'state-home');
97
+ const wsRoot = path.join(temp, 'workspace');
98
+ fs.mkdirSync(stateHome, { recursive: true });
99
+ fs.mkdirSync(wsRoot, { recursive: true });
100
+
101
+ const sessionId = 'f8-' + route.id;
102
+ const payload = {
103
+ hook_event_name: route.event,
104
+ session_id: sessionId,
105
+ cwd: wsRoot,
106
+ };
107
+ if (route.tool) {
108
+ payload.tool_name = route.tool;
109
+ payload.tool_use_id = 'toolu-' + route.id;
110
+ payload.tool_input = route.input;
111
+ if (route.event === 'PostToolUse') {
112
+ payload.tool_response = { agentId: 'agent-f8', interrupted: false, result: 'completed' };
113
+ }
114
+ }
115
+ if (route.event === 'SubagentStart' || route.event === 'SubagentStop') {
116
+ payload.agent_id = 'agent-f8';
117
+ payload.agent_type = 'doc-reviewer';
118
+ }
119
+ if (route.event === 'SubagentStop') {
120
+ payload.last_assistant_message = 'Review verdict: approve. F8 capture probe.';
121
+ }
122
+
123
+ const env = {
124
+ ...process.env,
125
+ CLAUDE: '1',
126
+ HOME: temp,
127
+ USERPROFILE: temp,
128
+ HARNESS_STATE_HOME: stateHome,
129
+ HARNESS_WORKSPACE_ROOT: wsRoot,
130
+ };
131
+ delete env.NODE_OPTIONS;
132
+ if (switchSetting.value === undefined) delete env[PILOT_SWITCH];
133
+ else env[PILOT_SWITCH] = switchSetting.value;
134
+
135
+ try {
136
+ const result = spawnSync(process.execPath, [path.join(leg.hooksScripts, 'state-persist.js')], {
137
+ input: JSON.stringify(payload),
138
+ encoding: 'utf8',
139
+ cwd: wsRoot,
140
+ env,
141
+ timeout: 30000,
142
+ });
143
+ if (result.error) throw new H.InfraError('capture-only hook subprocess failed: ' + result.error.message);
144
+ return {
145
+ code: result.status,
146
+ stdout: result.stdout || '',
147
+ stderr: result.stderr || '',
148
+ files: snapshotTree(stateHome),
149
+ payload,
150
+ };
151
+ } finally {
152
+ fs.rmSync(temp, { recursive: true, force: true });
153
+ }
154
+ }
155
+
156
+ // locks: historical — retired 2026-10-23 (owner decision 5): the F8 preload-spy predicate (internal modules loaded in
157
+ // 24/24 OFF cases) is removed from the active set; see ci/archive/325-prep/README.md. OFF side effects stay asserted.
158
+ runner.run('R2C1-OFF-CAPTURE-ONLY-FAST-PATH', [], (_mods, leg) => {
159
+ const routeSwitchCases = CAPTURE_ONLY_ROUTES.length * OFF_SWITCH_VALUES.length;
160
+ let stateChangeCases = 0;
161
+ let outputCases = 0;
162
+ let captureCases = 0;
163
+ let badExitCases = 0;
164
+ for (const route of CAPTURE_ONLY_ROUTES) {
165
+ for (const switchSetting of OFF_SWITCH_VALUES) {
166
+ const result = driveCaptureOnlyRoute(leg, route, switchSetting);
167
+ if (result.code !== 0) badExitCases += 1;
168
+ if (result.stdout || result.stderr) outputCases += 1;
169
+ if (Object.keys(result.files).length) stateChangeCases += 1;
170
+ if (captureEventRecords(result.files).length) captureCases += 1;
171
+ }
172
+ }
173
+ const failures = [];
174
+ if (badExitCases) failures.push('nonzero exit in ' + badExitCases + '/' + routeSwitchCases + ' OFF cases');
175
+ if (outputCases) failures.push('output emitted in ' + outputCases + '/' + routeSwitchCases + ' OFF cases');
176
+ if (stateChangeCases) failures.push('state changed in ' + stateChangeCases + '/' + routeSwitchCases + ' OFF cases');
177
+ if (captureCases) failures.push('capture written in ' + captureCases + '/' + routeSwitchCases + ' OFF cases');
178
+ if (failures.length) failures.push(`observed side effects: state=${stateChangeCases}, capture=${captureCases}, output=${outputCases}, nonzero-exit=${badExitCases}`);
179
+ if (failures.length) throw new Error(failures.join('; '));
180
+ });
181
+
182
+ runner.run('R2C1-ON-CAPTURE-ONLY-ROUTES', [], (_mods, leg) => {
183
+ const failures = [];
184
+ for (const route of CAPTURE_ONLY_ROUTES) {
185
+ const result = driveCaptureOnlyRoute(leg, route, { value: '1' });
186
+ if (result.code !== 0) { failures.push(route.id + ': exit ' + result.code); continue; }
187
+ if (result.stdout || result.stderr) failures.push(route.id + ': output emitted');
188
+ const record = captureEventRecords(result.files).find(item => item.event === route.event
189
+ && item.session_id === result.payload.session_id
190
+ && (!route.tool || item.tool_name === route.tool)
191
+ && (!result.payload.tool_use_id || item.tool_use_id === result.payload.tool_use_id));
192
+ if (!record) failures.push(route.id + ': ON capture missing');
193
+ else {
194
+ if (record.cwd !== result.payload.cwd) failures.push(route.id + ': cwd mismatch');
195
+ if (!/^[0-9a-f]{64}$/.test(String(record.responseDigest))) failures.push(route.id + ': response digest missing');
196
+ if (!record.captureId || !record.responseRef) failures.push(route.id + ': capture identity missing');
197
+ }
198
+ }
199
+ if (failures.length) throw new Error(failures.join('; '));
200
+ });
201
+
202
+ // ---- Item 1: OFF must write no capture and keep legacy state identical --------
203
+
204
+ runner.run('R2C1-OFF-UNSET', [], (_mods, leg) => {
205
+ legacyGoldenChecks('R2C1-OFF-UNSET', H.driveStatePersist(leg, 'payload-post-tool-use-success.json', {}));
206
+ });
207
+
208
+ runner.run('R2C1-OFF-ZERO', [], (_mods, leg) => {
209
+ legacyGoldenChecks('R2C1-OFF-ZERO', H.driveStatePersist(leg, 'payload-post-tool-use-success.json', {}, { env: { [PILOT_SWITCH]: '0' } }));
210
+ });
211
+
212
+ runner.run('R2C1-OFF-OTHER-VALUE', [], (_mods, leg) => {
213
+ // "other values" beyond unset/0 must behave OFF: only the exact value `1`
214
+ // establishes the ON + supported decision (doc lines 833–838, 1005–1006).
215
+ legacyGoldenChecks('R2C1-OFF-OTHER-VALUE', H.driveStatePersist(leg, 'payload-post-tool-use-success.json', {}, { env: { [PILOT_SWITCH]: 'true' } }));
216
+ });
217
+
218
+ // ---- Item 2: ON capture, exact doc fields, absent fields absent, fail-open ----
219
+
220
+ runner.run('R2C1-ON-CAPTURE-POSTTOOLUSE', [], (_mods, leg) => {
221
+ const drive = H.driveStatePersist(leg, 'payload-post-tool-use-success.json', {}, { env: { [PILOT_SWITCH]: '1' } });
222
+ if (drive.code !== 0) throw new Error(`state-persist exited ${drive.code}: ${(drive.stderr || drive.stdout).slice(0, 200)}`);
223
+ const source = H.payload('payload-post-tool-use-success.json');
224
+ const records = H.findCaptureRecords(drive.files);
225
+ if (!records.length) throw new Error('ON PostToolUse wrote no pilot capture record (no qualifying file in the session subtree)');
226
+ const matching = records.filter(r => r.event === 'PostToolUse' && r.tool_use_id === source.tool_use_id);
227
+ if (!matching.length) throw new Error(`ON PostToolUse capture missing for tool_use_id ${source.tool_use_id}`);
228
+ for (const record of matching) H.assertCaptureRecordShape(record, drive.payload);
229
+ });
230
+
231
+ runner.run('R2C1-ON-CAPTURE-OPTIONAL-ABSENT', [], (_mods, leg) => {
232
+ const drive = H.driveStatePersist(leg, 'payload-post-tool-use-success-minimal.json', {}, { env: { [PILOT_SWITCH]: '1' } });
233
+ const source = H.payload('payload-post-tool-use-success-minimal.json');
234
+ const records = H.findCaptureRecords(drive.files).filter(r => r.event === 'PostToolUse' && r.tool_use_id === source.tool_use_id);
235
+ if (!records.length) throw new Error('ON minimal PostToolUse wrote no pilot capture record');
236
+ for (const record of records) H.assertCaptureRecordShape(record, drive.payload); // absent input fields must stay absent
237
+ });
238
+
239
+ runner.run('R2C1-ON-CAPTURE-FAILURE', [], (_mods, leg) => {
240
+ const drive = H.driveStatePersist(leg, 'payload-post-tool-use-failure.json', {}, { env: { [PILOT_SWITCH]: '1' } });
241
+ if (drive.code !== 0) throw new Error(`state-persist exited ${drive.code}: ${(drive.stderr || drive.stdout).slice(0, 200)}`);
242
+ const source = H.payload('payload-post-tool-use-failure.json');
243
+ const records = H.findCaptureRecords(drive.files).filter(r => r.event === 'PostToolUseFailure' && r.tool_use_id === source.tool_use_id);
244
+ if (!records.length) throw new Error('ON PostToolUseFailure wrote no pilot capture record');
245
+ for (const record of records) H.assertCaptureRecordShape(record, drive.payload);
246
+ });
247
+
248
+ runner.run('R2C1-ON-CAPTURE-FAIL-OPEN', [], (_mods, leg) => {
249
+ // A capture (or any hook) failure must never change the hook's exit/output.
250
+ // Today this passes because state-persist already fails open; it must keep
251
+ // passing after the S2b capture wiring lands.
252
+ const drive = H.driveStatePersist(leg, 'payload-post-tool-use-success.json', {}, { env: { [PILOT_SWITCH]: '1' } });
253
+ if (drive.code !== 0) throw new Error(`fail-open violated: state-persist exited ${drive.code}`);
254
+ if (/\bThrow\b|at Object|node:internal/.test(drive.stderr)) throw new Error(`fail-open violated: hook crashed with ${drive.stderr.slice(0, 200)}`);
255
+ });
256
+
257
+ // ---- Items 3–6: the S2b origin provider --------------------------------------
258
+
259
+ function makeProvider(leg, input) {
260
+ const met = H.requirementsMet(leg, PROVIDER_REQ);
261
+ if (met.error) throw new Error(met.error);
262
+ const provider = met.mods['doc-reconciliation-provider.js'].createClaudeOriginProvider(H.providerInput(input));
263
+ if (!provider || typeof provider.resolveEvent !== 'function' || typeof provider.resolveContext !== 'function') {
264
+ throw new Error('createClaudeOriginProvider must return { resolveContext, resolveEvent } implementing the S2a seam');
265
+ }
266
+ return provider;
267
+ }
268
+
269
+ function runProvider(leg, input, reference) {
270
+ const provider = makeProvider(leg, input);
271
+ const response = provider.resolveEvent({ reference, identity: H.providerIdentity() });
272
+ const out = { response, provider, context: provider.resolveContext({ workspaceRoot: H.FIXED.workspaceRoot, workflowId: H.FIXED.workflowId, sessionId: H.FIXED.sessionId }) };
273
+ return out;
274
+ }
275
+
276
+ // No-qualification cases run from an isolated copy of the provider, with no live-capture directory above it. The gate
277
+ // outcome then never depends on whether retained live evidence exists in this checkout, so the live capture can land
278
+ // without turning these gate-unmet cases into INFRA.
279
+ function isolatedRunProvider(leg, input, reference) {
280
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'r2c1-isolated-'));
281
+ try {
282
+ const lib = path.join(dir, 'hooks', 'scripts', 'lib');
283
+ fs.mkdirSync(lib, { recursive: true });
284
+ fs.copyFileSync(path.join(leg.lib, 'doc-reconciliation-provider.js'), path.join(lib, 'doc-reconciliation-provider.js'));
285
+ const provider = require(path.join(lib, 'doc-reconciliation-provider.js')).createClaudeOriginProvider(H.providerInput(input));
286
+ const response = provider.resolveEvent({ reference, identity: H.providerIdentity() });
287
+ const context = provider.resolveContext({ workspaceRoot: H.FIXED.workspaceRoot, workflowId: H.FIXED.workflowId, sessionId: H.FIXED.sessionId });
288
+ return { response, provider, context };
289
+ } finally {
290
+ fs.rmSync(dir, { recursive: true, force: true });
291
+ }
292
+ }
293
+
294
+ function assertNoExitZeroCredit(out, label) {
295
+ const { response } = out;
296
+ if (response && response.status === 'accepted') {
297
+ if (Number.isInteger(response.event && response.event.exitCode) && response.event.exitCode === 0) {
298
+ throw new Error(`${label}: accepted and mapped exit 0`);
299
+ }
300
+ throw new Error(`${label}: unexpected accepted response ${JSON.stringify(response).slice(0, 200)}`);
301
+ }
302
+ if (!(response && Array.isArray(response.diagnostics) && response.diagnostics.length)) throw new Error(`${label}: ${response && response.status} with no diagnostics`);
303
+ }
304
+
305
+ function captureDiagnosticsText(out) {
306
+ return JSON.stringify([...((out.response && out.response.diagnostics) || []), ...((out.context && out.context.diagnostics) || [])]);
307
+ }
308
+
309
+ runner.run('R2C1-EXIT-MAPPING-SUCCESS', PROVIDER_REQ, (_mods, leg) => {
310
+ const captures = [H.simulateCapture('payload-post-tool-use-success.json', { ref: 'exec-pass' })];
311
+ const out = runProvider(leg, { captures, qualification: H.fakeQualification('sim') }, 'exec-pass');
312
+ if (!out.response || out.response.status !== 'accepted') {
313
+ throw new Error(`success event not accepted: ${out.response ? out.response.status : 'no response'} ${captureDiagnosticsText(out).slice(0, 200)}`);
314
+ }
315
+ const event = out.response.event;
316
+ if (!Number.isInteger(event.exitCode) || event.exitCode !== 0) throw new Error(`owner decision 1: expected integer exit 0, got ${event.exitCode}`);
317
+ if (event.signal !== null) throw new Error(`owner decision 1: expected signal null, got ${event.signal}`);
318
+ if (event.toolCorrelationId !== 'toolu_r2c1_a') throw new Error(`toolCorrelationId mismatch: ${event.toolCorrelationId}`);
319
+ if (event.command !== H.payload('payload-post-tool-use-success.json').tool_input.command) throw new Error(`event command mismatch: ${event.command}`);
320
+ const originRef = out.response.originRef !== undefined ? out.response.originRef : out.context && out.context.originRef;
321
+ if (typeof originRef !== 'string') throw new Error(`accepted event missing originRef binding`);
322
+ });
323
+
324
+ runner.run('R2C1-EXIT-MAPPING-FAILURE-SAME-ID', PROVIDER_REQ, (_mods, leg) => {
325
+ const captures = [
326
+ H.simulateCapture('payload-post-tool-use-success.json', { ref: 'exec-pass', toolUseId: 'toolu_r2c1_b' }),
327
+ H.simulateCapture('payload-post-tool-use-failure.json', { ref: 'exec-fail', toolUseId: 'toolu_r2c1_b' }),
328
+ ];
329
+ const out = runProvider(leg, { captures, qualification: H.fakeQualification('sim') }, 'exec-pass');
330
+ assertNoExitZeroCredit(out, 'failure for the same tool_use_id must void the success mapping');
331
+ });
332
+
333
+ runner.run('R2C1-EXIT-MAPPING-FAILURE-NEVER-CREDITED', PROVIDER_REQ, (_mods, leg) => {
334
+ const captures = [H.simulateCapture('payload-post-tool-use-failure.json', { ref: 'exec-fail' })];
335
+ const out = runProvider(leg, { captures, qualification: H.fakeQualification('sim') }, 'exec-fail');
336
+ assertNoExitZeroCredit(out, 'PostToolUseFailure is never credited');
337
+ });
338
+
339
+ runner.run('R2C1-EXIT-MAPPING-INTERRUPTED', PROVIDER_REQ, (_mods, leg) => {
340
+ const captures = [H.simulateCapture('payload-post-tool-use-success.json', { ref: 'exec-pass', interrupted: true })];
341
+ const out = runProvider(leg, { captures, qualification: H.fakeQualification('sim') }, 'exec-pass');
342
+ assertNoExitZeroCredit(out, 'interrupted success must not be mapped to exit 0');
343
+ });
344
+
345
+ runner.run('R2C1-EXIT-MAPPING-MISSING-TOOL-USE-ID', PROVIDER_REQ, (_mods, leg) => {
346
+ const record = H.simulateCapture('payload-post-tool-use-success-minimal.json', { ref: 'exec-pass' });
347
+ delete record.tool_use_id;
348
+ const out = runProvider(leg, { captures: [record], qualification: H.fakeQualification('sim') }, 'exec-pass');
349
+ assertNoExitZeroCredit(out, 'missing tool_use_id must not be credited');
350
+ });
351
+
352
+ // ---- Item 4: reviewer independence via host-issued subagent principal ---------
353
+
354
+ const REVIEW_REF = 'review-1';
355
+ const REVIEW_TEAM_REF = 'review-task-result';
356
+
357
+ function reviewCaptures(agentIdStop, agentIdTask, opts = {}) {
358
+ return [
359
+ H.simulateCapture('payload-subagent-stop.json', { ref: REVIEW_REF, agentId: agentIdStop }),
360
+ H.simulateCapture('payload-task-result.json', { ref: REVIEW_TEAM_REF, agentId: agentIdTask }),
361
+ ];
362
+ }
363
+
364
+ runner.run('R2C1-REVIEWER-VALID', PROVIDER_REQ, (_mods, leg) => {
365
+ const out = runProvider(leg, { captures: reviewCaptures('agent-r2c1-rev', 'agent-r2c1-rev'), qualification: H.fakeQualification('sim') }, REVIEW_REF);
366
+ if (!out.response || out.response.status !== 'accepted') {
367
+ throw new Error(`independent reviewer event not accepted: ${out.response ? out.response.status : 'no response'} ${captureDiagnosticsText(out).slice(0, 200)}`);
368
+ }
369
+ const event = out.response.event;
370
+ if (event.kind !== 'review') throw new Error(`expected a review event, got kind ${event.kind}`);
371
+ if (event.principalId !== 'agent-r2c1-rev') throw new Error(`reviewer principal must be the host agent_id, got ${event.principalId}`);
372
+ const independentOf = Array.isArray(event.independentOf) ? event.independentOf.map(String) : [];
373
+ for (const required of [H.FIXED.sessionId]) {
374
+ if (!independentOf.includes(required)) throw new Error(`independentOf must include author ${required}: ${JSON.stringify(independentOf)}`);
375
+ }
376
+ if (independentOf.includes('agent-r2c1-rev')) throw new Error(`independentOf must not include the reviewer himself`);
377
+ const blob = JSON.stringify(event);
378
+ const stopRecord = H.simulateCapture('payload-subagent-stop.json', { ref: 'unused' });
379
+ if (!blob.includes(stopRecord.reviewDigest)) throw new Error(`review event lost the bound final review artifact digest ${stopRecord.reviewDigest}`);
380
+ });
381
+
382
+ runner.run('R2C1-REVIEWER-SAME-ID-REJECTED', PROVIDER_REQ, (_mods, leg) => {
383
+ // agent_id identical to the author session id: not independent.
384
+ const out = runProvider(leg, { captures: reviewCaptures(H.FIXED.sessionId, H.FIXED.sessionId), qualification: H.fakeQualification('sim') }, REVIEW_REF);
385
+ assertNoExitZeroCredit(out, 'reviewer equal to author must be rejected');
386
+ });
387
+
388
+ runner.run('R2C1-REVIEWER-EXECUTOR-ID-REJECTED', PROVIDER_REQ, (_mods, leg) => {
389
+ const out = runProvider(leg, {
390
+ captures: reviewCaptures('agent-exec-1', 'agent-exec-1'),
391
+ executorPrincipals: ['agent-exec-1'],
392
+ qualification: H.fakeQualification('sim'),
393
+ }, REVIEW_REF);
394
+ assertNoExitZeroCredit(out, 'reviewer equal to an executor must be rejected');
395
+ });
396
+
397
+ runner.run('R2C1-REVIEWER-SURFACE-INCONSISTENCY-REJECTED', PROVIDER_REQ, (_mods, leg) => {
398
+ // SubagentStop and the Task result name different subagent identities:
399
+ // the surfaces must be consistent or the record is rejected (doc capture table).
400
+ const out = runProvider(leg, { captures: reviewCaptures('agent-r2c1-rev', 'agent-someone-else'), qualification: H.fakeQualification('sim') }, REVIEW_REF);
401
+ assertNoExitZeroCredit(out, 'identity inconsistency between SubagentStop and the Task result must be rejected');
402
+ });
403
+
404
+ runner.run('R2C1-REVIEWER-REPLAY-COPIED-REJECTED', PROVIDER_REQ, (_mods, leg) => {
405
+ // Same host event content re-presented under a new reference/captureId:
406
+ // the fresh first resolution stays accepted, the copied second is rejected.
407
+ const first = H.simulateCapture('payload-subagent-stop.json', { ref: REVIEW_REF });
408
+ const replay = H.simulateCapture('payload-subagent-stop.json', { ref: 'review-replay' });
409
+ replay.reviewDigest = first.reviewDigest;
410
+ // F4-T5 conflict 3: the review needs its correlated Agent/Task result surface, delivered with the original.
411
+ const task = H.simulateCapture('payload-task-result.json', { ref: REVIEW_TEAM_REF, agentId: 'agent-r2c1-rev' });
412
+ const provider = makeProvider(leg, { captures: [first, task, replay], qualification: H.fakeQualification('sim') });
413
+ const firstResponse = provider.resolveEvent({ reference: REVIEW_REF, identity: H.providerIdentity() });
414
+ if (!firstResponse || firstResponse.status !== 'accepted') {
415
+ throw new Error(`copy test needs the original to be accepted first; got ${(firstResponse && firstResponse.status) || 'none'} ${JSON.stringify((firstResponse && firstResponse.diagnostics) || []).slice(0, 200)}`);
416
+ }
417
+ const replayResponse = provider.resolveEvent({ reference: 'review-replay', identity: H.providerIdentity() });
418
+ if (replayResponse && replayResponse.status === 'accepted') {
419
+ throw new Error('copied/replayed record was accepted under a new reference');
420
+ }
421
+ });
422
+
423
+ // F4-T5 conflict 1: a reviewer that ran a registered doc-check is an executor, so its review is refused. Its plain tool
424
+ // use is not execution evidence, so the same review stays accepted (the control keeps the refusal from being vacuous).
425
+ runner.run('R2C1-REVIEWER-RAN-EVIDENCE-REFUSED', PROVIDER_REQ, (_mods, leg) => {
426
+ const plain = H.simulateCapture('payload-post-tool-use-success.json', { ref: 'exec-plain', agentId: 'agent-r2c1-rev' });
427
+ const control = runProvider(leg, { captures: [plain, ...reviewCaptures('agent-r2c1-rev', 'agent-r2c1-rev')], qualification: H.fakeQualification('sim') }, REVIEW_REF);
428
+ if (!control.response || control.response.status !== 'accepted') {
429
+ throw new Error(`control: a reviewer's plain tool use must not refuse the review; got ${control.response ? control.response.status : 'no response'}`);
430
+ }
431
+ const run = H.simulateCapture('payload-post-tool-use-success.json', { ref: 'exec-pass', agentId: 'agent-r2c1-rev' });
432
+ run.command = `node ${DISTRIBUTIONS[leg.name].docCheck} check --workspace . --config .agents/harness-everything/project-docs.json --changed-path docs/a.md`;
433
+ const out = runProvider(leg, { captures: [run, ...reviewCaptures('agent-r2c1-rev', 'agent-r2c1-rev')], qualification: H.fakeQualification('sim') }, REVIEW_REF);
434
+ if (!out.response || out.response.status === 'accepted') throw new Error('reviewer that ran the registered check was accepted as independent');
435
+ if (!captureDiagnosticsText(out).includes('reviewer-not-independent')) throw new Error(`refused for another reason: ${captureDiagnosticsText(out).slice(0, 200)}`);
436
+ });
437
+
438
+ // A labelled `Review verdict: reject` never approves, and neither does free text that only says REJECT. Both are
439
+ // fixtures on the independent reviewer surface, so only the verdict line decides the outcome.
440
+ for (const [id, fixture] of [
441
+ ['R2C1-REVIEWER-REJECT-NEVER-APPROVES', 'payload-subagent-stop-reject.json'],
442
+ ['R2C1-REVIEWER-FREETEXT-REJECT-NEVER-APPROVES', 'payload-subagent-stop-freetext-reject.json'],
443
+ ]) {
444
+ runner.run(id, PROVIDER_REQ, (_mods, leg) => {
445
+ const captures = [H.simulateCapture(fixture, { ref: REVIEW_REF, agentId: 'agent-r2c1-rev' }), H.simulateCapture('payload-task-result.json', { ref: REVIEW_TEAM_REF, agentId: 'agent-r2c1-rev' })];
446
+ const out = runProvider(leg, { captures, qualification: H.fakeQualification('sim') }, REVIEW_REF);
447
+ assertNoExitZeroCredit(out, `${fixture} must never approve`);
448
+ });
449
+ }
450
+
451
+ // ---- Item 5: unsupported hosts are unavailable and never borrow Claude mapping
452
+
453
+ runner.run('R2C1-UNSUPPORTED-OPENCODE', PROVIDER_REQ, (_mods, leg) => {
454
+ const captures = [H.simulateCapture('payload-post-tool-use-success.json', { ref: 'exec-pass' })];
455
+ const out = runProvider(leg, { hostId: 'opencode', captures, qualification: H.fakeQualification('sim') }, 'exec-pass');
456
+ if (out.response && out.response.status === 'accepted') throw new Error('opencode must not be accepted');
457
+ if (out.context && out.context.status === 'accepted') throw new Error('opencode resolveContext must be unavailable');
458
+ if (!(out.response && out.response.status === 'unavailable' && out.context && out.context.status === 'unavailable')) {
459
+ throw new Error(`expected provider unavailable, got response=${out.response && out.response.status} context=${out.context && out.context.status}`);
460
+ }
461
+ if (JSON.stringify(captureDiagnosticsText(out)).length < 4) throw new Error('unavailable response carried no diagnostics');
462
+ });
463
+
464
+ runner.run('R2C1-UNSUPPORTED-CODEX', PROVIDER_REQ, (_mods, leg) => {
465
+ // Codex-shaped capture including a numeric exit on the record: the provider
466
+ // must stay unavailable and must not credit that numeric exit via any path.
467
+ const capture = H.simulateCapture('payload-post-tool-use-failure.json', { ref: 'exec-pass' });
468
+ capture.capturedExitCode = 2;
469
+ const out = runProvider(leg, { hostId: 'codex', captures: [capture], qualification: H.fakeQualification('sim') }, 'exec-pass');
470
+ if (out.response && out.response.status !== 'unavailable') throw new Error(`codex expected unavailable, got ${(out.response && out.response.status)}`);
471
+ if (out.context && out.context.status === 'accepted') throw new Error('codex resolveContext must be unavailable');
472
+ });
473
+
474
+ // ---- Item 6: retained live-capture gate disabling positive credit -------------
475
+
476
+ runner.run('R2C1-LIVE-GATE-UNMET', PROVIDER_REQ, (_mods, leg) => {
477
+ const captures = [H.simulateCapture('payload-post-tool-use-success.json', { ref: 'exec-pass' })];
478
+ const out = isolatedRunProvider(leg, { captures }, 'exec-pass'); // no qualification, isolated: production retained-evidence path
479
+ if (out.response && out.response.status === 'accepted') throw new Error('positive credit granted while the retained live gate is unmet');
480
+ if (!/live[- ]?gate|qualification/i.test(captureDiagnosticsText(out))) {
481
+ throw new Error(`gate-unmet response must say why: ${captureDiagnosticsText(out).slice(0, 200)}`);
482
+ }
483
+ });
484
+
485
+ runner.run('R2C1-LIVE-GATE-DI-QUALIFICATION-OPENS', PROVIDER_REQ, (_mods, leg) => {
486
+ const captures = [H.simulateCapture('payload-post-tool-use-success.json', { ref: 'exec-pass' })];
487
+ const out = runProvider(leg, { captures, qualification: H.fakeQualification('sim') }, 'exec-pass');
488
+ if (!out.response || out.response.status !== 'accepted') {
489
+ throw new Error(`every otherwise-valid simulated event must resolve under an explicitly fake DI qualification: ${captureDiagnosticsText(out).slice(0, 200)}`);
490
+ }
491
+ });
492
+
493
+ runner.run('R2C1-LIVE-GATE-NO-ENV-OR-FLAG-BYPASS', PROVIDER_REQ, (_mods, leg) => {
494
+ const capture = H.simulateCapture('payload-post-tool-use-success.json', { ref: 'exec-pass' });
495
+ capture.trusted = true;
496
+ // The host switch and a self-declared `trusted` capture flag must not open
497
+ // the gate: qualification resolves only from retained live evidence (DI
498
+ // excepted for tests).
499
+ const previous = process.env[PILOT_SWITCH];
500
+ process.env[PILOT_SWITCH] = '1';
501
+ try {
502
+ const out = isolatedRunProvider(leg, { captures: [capture] }, 'exec-pass'); // no DI qualification, isolated
503
+ if (out.response && out.response.status === 'accepted') {
504
+ throw new Error('live gate was bypassed by env toggle/record flag (must stay unmet without retained evidence)');
505
+ }
506
+ if (out.context && out.context.status === 'accepted') {
507
+ throw new Error('live gate was bypassed at resolveContext by env toggle/record flag');
508
+ }
509
+ } finally {
510
+ if (previous === undefined) delete process.env[PILOT_SWITCH];
511
+ else process.env[PILOT_SWITCH] = previous;
512
+ }
513
+ });
514
+
515
+ // ---- Item 7: Claude manifest declares SubagentStart/SubagentStop ---------------
516
+
517
+ runner.run('R2C1-MANIFEST-SUBAGENT-HOOKS', [], (_mods, leg) => {
518
+ const manifest = JSON.parse(fs.readFileSync(leg.hooksManifest, 'utf8'));
519
+ const hooks = (manifest && manifest.hooks) || {};
520
+ if (leg.name === 'source') {
521
+ // The source manifest is Claude's capture surface; the packaged Codex plugin
522
+ // keeps its own host-specific SubagentStart/Stop adapters below.
523
+ const hasStatePersist = group => ((group && group.hooks) || []).some(handler =>
524
+ handler.type === 'command' && /state-persist\.js/.test(handler.command || ''));
525
+ for (const route of [
526
+ { event: 'PreToolUse', matcher: 'Bash|PowerShell|Task|Agent' },
527
+ { event: 'PostToolUse', matcher: 'Task|Agent' },
528
+ { event: 'SubagentStart' },
529
+ { event: 'SubagentStop' },
530
+ ]) {
531
+ const groups = hooks[route.event];
532
+ if (!Array.isArray(groups) || !groups.some(group => group.matcher === route.matcher && hasStatePersist(group))) {
533
+ throw new Error(route.event + ' state-persist route is missing or narrowed');
534
+ }
535
+ }
536
+ }
537
+ for (const event of ['SubagentStart', 'SubagentStop']) {
538
+ const groups = hooks[event];
539
+ if (!Array.isArray(groups) || !groups.length) throw new Error(`hooks/hooks.json does not declare ${event}`);
540
+ for (const group of groups) {
541
+ const handlers = (group && group.hooks) || [];
542
+ if (!handlers.length) throw new Error(`${event} matcher group has no handlers`);
543
+ for (const handler of handlers) {
544
+ if (handler.type !== 'command') throw new Error(`${event} handler must be a command hook, got ${handler.type}`);
545
+ }
546
+ }
547
+ }
548
+ });
549
+
550
+ // ---- Summary ------------------------------------------------------------------
551
+
552
+ function finish(counts) {
553
+ if (counts.FAIL === 0 && counts.INFRA === 0) {
554
+ console.log('R2c-1 suite: all assertions PASS.');
555
+ process.exit(0);
556
+ }
557
+ console.log(`R2c-1 suite: FAIL/INFRA present (expected at RED: ${counts.FAIL}, infra faults: ${counts.INFRA}).`);
558
+ process.exit(1);
559
+ }
560
+
561
+ finish(runner.summary());