harness-everything 0.40.0 → 0.41.1

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 (161) 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 +51 -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/mechanism-365-step-edit-accounting.test.js +395 -0
  104. package/ci/mutation-check.js +26 -3
  105. package/docs/architecture.md +5 -0
  106. package/docs/doc-workflow-reconciliation.md +331 -4
  107. package/docs/document-lifecycle.md +11 -0
  108. package/docs/governed-index.md +20 -0
  109. package/docs/platform-capabilities.md +22 -2
  110. package/grill-with-docs/SKILL.md +1 -1
  111. package/grill-with-docs/references/session-playbook.md +4 -0
  112. package/harness-everything/SKILL.md +1 -1
  113. package/harness-everything/references/doc-check.md +186 -0
  114. package/harness-everything/references/documentation/_shared/maintenance.md +41 -0
  115. package/harness-everything/references/documentation/_shared/writing-craft.md +59 -0
  116. package/harness-everything/references/triage-and-tiers.md +4 -0
  117. package/harness-everything/scripts/doc-check.js +442 -0
  118. package/harness-everything/scripts/doc-lifecycle.js +3 -0
  119. package/hooks/hooks.json +46 -0
  120. package/hooks/scripts/lib/doc-reconciliation-capture.sanitized.jsonl +26 -0
  121. package/hooks/scripts/lib/doc-reconciliation-pilot.js +171 -2
  122. package/hooks/scripts/lib/doc-reconciliation-provider.js +1059 -0
  123. package/hooks/scripts/lib/doc-reconciliation-qualification.json +34 -0
  124. package/hooks/scripts/lib/workflow-obligations.js +7 -0
  125. package/hooks/scripts/state-persist.js +105 -3
  126. package/hooks/scripts/workflow-stop-gate.js +7 -2
  127. package/opencode-plugin/README.md +15 -0
  128. package/opencode-plugin/index.mjs +77 -0
  129. package/opencode-plugin/plugin.json +1 -1
  130. package/package.json +1 -1
  131. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  132. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-capture.sanitized.jsonl +26 -0
  133. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-pilot.js +171 -2
  134. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-provider.js +1059 -0
  135. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-qualification.json +34 -0
  136. package/plugins/harness-everything/hooks/scripts/lib/workflow-obligations.js +7 -0
  137. package/plugins/harness-everything/hooks/scripts/state-persist.js +105 -3
  138. package/plugins/harness-everything/hooks/scripts/workflow-stop-gate.js +7 -2
  139. package/plugins/harness-everything/plugin.json +1 -1
  140. package/plugins/harness-everything/skills/grill-with-docs/SKILL.md +1 -1
  141. package/plugins/harness-everything/skills/grill-with-docs/references/session-playbook.md +4 -0
  142. package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
  143. package/plugins/harness-everything/skills/harness-everything/references/doc-check.md +186 -0
  144. package/plugins/harness-everything/skills/harness-everything/references/documentation/_shared/maintenance.md +41 -0
  145. package/plugins/harness-everything/skills/harness-everything/references/documentation/_shared/writing-craft.md +59 -0
  146. package/plugins/harness-everything/skills/harness-everything/references/triage-and-tiers.md +4 -0
  147. package/plugins/harness-everything/skills/harness-everything/scripts/doc-check.js +442 -0
  148. package/plugins/harness-everything/skills/harness-everything/scripts/doc-lifecycle.js +3 -0
  149. package/plugins/harness-everything/skills/repo-docs/SKILL.md +1 -1
  150. package/plugins/harness-everything/skills/repo-docs/references/process-guide.md +4 -0
  151. package/plugins/harness-everything/skills/to-spec/SKILL.md +1 -1
  152. package/plugins/harness-everything/skills/to-spec/references/process.md +4 -0
  153. package/plugins/harness-everything/skills/to-tickets/SKILL.md +1 -1
  154. package/plugins/harness-everything/skills/to-tickets/references/publishing-and-templates.md +4 -0
  155. package/repo-docs/SKILL.md +1 -1
  156. package/repo-docs/references/process-guide.md +4 -0
  157. package/sbom.cdx.json +7 -7
  158. package/to-spec/SKILL.md +1 -1
  159. package/to-spec/references/process.md +4 -0
  160. package/to-tickets/SKILL.md +1 -1
  161. package/to-tickets/references/publishing-and-templates.md +4 -0
@@ -0,0 +1,1059 @@
1
+ 'use strict';
2
+
3
+ // S2b Claude origin provider (docs/doc-workflow-reconciliation.md § S2b, "Claude
4
+ // capture and origin-provider contract" and "Retained live-capture gate";
5
+ // owner decision rows 1 and 2 are reproduced verbatim there as the binding
6
+ // trust-contract). Implements the existing S2a resolveContext/resolveEvent seam
7
+ // consumed by doc-reconciliation-producer.js / doc-reconciliation-events.js.
8
+ //
9
+ // Binding properties:
10
+ // - Positive credit is disabled until qualification resolves. The production
11
+ // path validates the qualification export next to this file and the sanitized
12
+ // capture it names (no directory search, no size-based acceptance).
13
+ // A process environment toggle or a self-declared capture flag can never
14
+ // substitute for that evidence. Dependency injection of an explicitly fake
15
+ // qualification object ({ source: 'fake-test-fixture', faked: true }) is a
16
+ // test-only construct for mechanism tests; production passes no
17
+ // qualification and stays gated.
18
+ // - Unsupported hosts (opencode/codex) never borrow the Claude exit mapping:
19
+ // both seam methods report `unavailable` with diagnostics.
20
+ // - Exit mapping (owner decision row 1): for a given `tool_use_id`, a captured
21
+ // `PostToolUse` with `interrupted === false` and no `PostToolUseFailure`
22
+ // arrival maps to exit 0. This is a named mapping of the host's own
23
+ // success/failure classification, not a null→0 conversion. A
24
+ // `PostToolUseFailure` is never credited; failure for the same invocation
25
+ // voids the success mapping; interrupted or ambiguous outcomes are not
26
+ // credited; missing `tool_use_id` is not credited.
27
+ // - Reviewer principal (owner decision row 2): the reviewer is the host-issued
28
+ // `agent_id`, must differ from the author session and every executor
29
+ // principal, must be correlated consistently with the Agent/Task result
30
+ // surface, and a copied/replayed record cannot become a new accepted event
31
+ // by changing its reference. Local journal forgery is not defended against;
32
+ // host payload fields are re-validated at assessment time.
33
+
34
+ const crypto = require('crypto');
35
+ const fs = require('fs');
36
+ const path = require('path');
37
+
38
+ const HOST_ID = 'claude-code';
39
+ const CAPTURE_RECORD_VERSION = 'claude-capture-record/1';
40
+ const QUALIFICATION_EXPORT_SCHEMA = 'harness.doc-reconciliation.qualification.v1';
41
+ const HEX64 = /^[0-9a-f]{64}$/;
42
+ const PRE_CAPTURE_TOOLS = ['Bash', 'PowerShell', 'Task', 'Agent'];
43
+ const STDOUT_CAP_BYTES = 1024 * 1024;
44
+ const DOC_CHECK_FLAGS = new Set(['--workspace', '--config', '--changed-path', '--base']);
45
+ const DOC_CHECK_SCHEMA = 'harness.doc-check.check.v1';
46
+ const DOC_CHECK_SHELLS = new Set(['sh', 'bash', 'dash', 'zsh', 'ksh', 'fish']);
47
+ const DOC_CHECK_LAUNCHERS = new Set(['node', 'nodejs', 'bun', 'deno', 'tsx', 'ts-node']);
48
+ const DOC_CHECK_LAUNCHER_VALUE_FLAGS = new Set(['-r', '--require', '--import', '--loader', '--experimental-loader', '--inspect-port', '--input-type', '--conditions', '--env-file']);
49
+ const DOC_CHECK_WRAPPERS = new Set(['env', 'npx']);
50
+ const DOC_CHECK_ENV_VALUE_FLAGS = new Set(['-u', '--unset', '-C', '--chdir']);
51
+ const DOC_CHECK_NPX_VALUE_FLAGS = new Set(['-p', '--package', '--prefix', '--cache', '--userconfig', '--registry', '--workspace', '-w']);
52
+ // Backslashes are shell escapes except when a rooted Windows path token makes them separators.
53
+ function isWindowsPathToken(token) {
54
+ return token.startsWith('\\\\') || /^[A-Za-z]:$/.test(token) || /^[A-Za-z]:\\/.test(token)
55
+ || (process.platform === 'win32' && !token.includes('/'));
56
+ }
57
+ function isDoubleQuotedEscapeTarget(value) {
58
+ return value === '"' || value === '\\' || value === '$' || value === '\x60';
59
+ }
60
+ function isUncPathPrefix(token, command, index) {
61
+ return token === '' && command[index] === '\\' && command[index + 1] === '\\';
62
+ }
63
+ // Canonical SHA-256 of harness-everything/scripts/doc-check.js (CRLF normalised to LF). Re-pin deliberately when that script changes.
64
+ const SHIPPED_DOC_CHECK_DIGEST = 'da5e51a763eda6680afb63ee781b566253df9d358eec1e65436786c9ecebbbdb';
65
+ // This install's own doc-check runtime, relative to hooks/scripts/lib: the source checkout or the packaged plugin.
66
+ const DOC_CHECK_INSTALL_LAYOUTS = [
67
+ ['..', '..', '..', 'harness-everything', 'scripts', 'doc-check.js'],
68
+ ['..', '..', '..', 'skills', 'harness-everything', 'scripts', 'doc-check.js'],
69
+ ];
70
+
71
+ // The resolved path of this install's own doc-check.js, or null when neither layout exists.
72
+ function canonicalPath(value) { return fs.realpathSync.native(path.resolve(value)); }
73
+ function installedDocCheck() {
74
+ for (const layout of DOC_CHECK_INSTALL_LAYOUTS) {
75
+ try { return canonicalPath(path.resolve(__dirname, ...layout)); } catch { /* try the next layout */ }
76
+ }
77
+ return null;
78
+ }
79
+ // Canonical doc-check digest (UTF-8, CRLF normalised to LF): capture and assessment compare the same value.
80
+ function fileDigest(file) { return crypto.createHash('sha256').update(fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n')).digest('hex'); }
81
+
82
+ // Tokenize only the shell-word subset needed for a direct node invocation. Expansion and shell control syntax are refused.
83
+ function safeTokenize(command) {
84
+ if (typeof command !== 'string') return { ok: false, reason: 'invalid-command' };
85
+ const tokens = [];
86
+ let token = '';
87
+ let active = false;
88
+ let quote = null;
89
+ const push = () => {
90
+ if (active) tokens.push(token);
91
+ token = '';
92
+ active = false;
93
+ };
94
+
95
+ for (let index = 0; index < command.length; index += 1) {
96
+ const ch = command[index];
97
+ if (ch === '%' && /^%[^%\s]+%/.test(command.slice(index))) return { ok: false, reason: 'shell-expansion' };
98
+ if (ch === '\\' && isUncPathPrefix(token, command, index)) {
99
+ token += '\\\\';
100
+ index += 1;
101
+ active = true;
102
+ continue;
103
+ }
104
+ if (quote === "'") {
105
+ if (ch === "'") quote = null;
106
+ else token += ch;
107
+ continue;
108
+ }
109
+ if (quote === '"') {
110
+ if (ch === '"') { quote = null; continue; }
111
+ if (ch === '\\') {
112
+ const next = command[index + 1];
113
+ if (next === undefined || next === '\n' || next === '\r') return { ok: false, reason: 'shell-continuation' };
114
+ if ([ '"', '\\', '$', '`' ].includes(next)) token += next;
115
+ else token += `\\${next}`;
116
+ index += 1;
117
+ active = true;
118
+ continue;
119
+ }
120
+ if (ch === '$' || ch === '`') return { ok: false, reason: 'shell-expansion' };
121
+ token += ch;
122
+ active = true;
123
+ continue;
124
+ }
125
+
126
+ if (ch === '\n' || ch === '\r') return { ok: false, reason: 'shell-operator' };
127
+ if (/\s/.test(ch)) { push(); continue; }
128
+ if (ch === "'" || ch === '"') { quote = ch; active = true; continue; }
129
+ if (ch === '\\') {
130
+ const next = command[index + 1];
131
+ if (next === undefined || next === '\n' || next === '\r') return { ok: false, reason: 'shell-continuation' };
132
+ if (isWindowsPathToken(token)) token += '\\';
133
+ else {
134
+ token += next;
135
+ index += 1;
136
+ }
137
+ active = true;
138
+ continue;
139
+ }
140
+ if (';&|<>()'.includes(ch)) return { ok: false, reason: 'shell-operator' };
141
+ if ('*?[]{}'.includes(ch) || (ch === '~' && !active) || ch === '$' || ch === '`') return { ok: false, reason: 'shell-expansion' };
142
+ if (ch === '#' && !active) return { ok: false, reason: 'shell-comment' };
143
+ token += ch;
144
+ active = true;
145
+ }
146
+ if (quote) return { ok: false, reason: 'unclosed-quote' };
147
+ push();
148
+ return { ok: true, tokens };
149
+ }
150
+
151
+ function skipHereDocBodies(command, newlineIndex, hereDocs) {
152
+ const afterLineBreak = index => command[index] === '\r' && command[index + 1] === '\n' ? index + 2 : index + 1;
153
+ let index = afterLineBreak(newlineIndex);
154
+ for (const { delimiter, stripTabs } of hereDocs) {
155
+ let found = false;
156
+ while (index <= command.length) {
157
+ let end = index;
158
+ while (end < command.length && command[end] !== '\n' && command[end] !== '\r') end += 1;
159
+ let line = command.slice(index, end);
160
+ if (stripTabs) line = line.replace(/^\t+/, '');
161
+ if (line === delimiter) {
162
+ index = end < command.length ? afterLineBreak(end) : end;
163
+ found = true;
164
+ break;
165
+ }
166
+ if (end === command.length) return end;
167
+ index = afterLineBreak(end);
168
+ }
169
+ if (!found) return command.length;
170
+ }
171
+ return index;
172
+ }
173
+
174
+ function commandSegments(command) {
175
+ const segments = [];
176
+ let words = [];
177
+ let token = '';
178
+ let active = false;
179
+ let quote = null;
180
+ let awaitingHereDocDelimiter = null;
181
+ const hereDocs = [];
182
+ const pushWord = () => {
183
+ if (active) {
184
+ if (awaitingHereDocDelimiter) {
185
+ hereDocs.push({ delimiter: token, stripTabs: awaitingHereDocDelimiter.stripTabs });
186
+ awaitingHereDocDelimiter = null;
187
+ } else words.push(token);
188
+ }
189
+ token = '';
190
+ active = false;
191
+ };
192
+ const pushSegment = () => {
193
+ pushWord();
194
+ if (words.length) segments.push(words);
195
+ words = [];
196
+ };
197
+
198
+ for (let index = 0; index < command.length; index += 1) {
199
+ const ch = command[index];
200
+ if (ch === '\\' && isUncPathPrefix(token, command, index)) {
201
+ token += '\\\\';
202
+ index += 1;
203
+ active = true;
204
+ continue;
205
+ }
206
+ if (quote === "'") {
207
+ if (ch === "'") quote = null;
208
+ else token += ch;
209
+ continue;
210
+ }
211
+ if (quote === '"') {
212
+ if (ch === '"') { quote = null; continue; }
213
+ if (ch === '\\' && command[index + 1] !== undefined) {
214
+ const next = command[index + 1];
215
+ if (isDoubleQuotedEscapeTarget(next)) token += command[++index];
216
+ else token += '\\';
217
+ active = true;
218
+ continue;
219
+ }
220
+ token += ch;
221
+ active = true;
222
+ continue;
223
+ }
224
+ if (ch === "'" || ch === '"') { quote = ch; active = true; continue; }
225
+ if (ch === '\\' && command[index + 1] !== undefined) {
226
+ const next = command[index + 1];
227
+ if (next === '\r' && command[index + 2] === '\n') index += 2;
228
+ else if (next === '\n') index += 1;
229
+ else if (isWindowsPathToken(token)) token += '\\';
230
+ else {
231
+ token += next;
232
+ index += 1;
233
+ }
234
+ active = true;
235
+ continue;
236
+ }
237
+ // shortcut: comments are word-initial unquoted # only, no heredoc or $(...) awareness, upgrade with a real shell parser
238
+ if (ch === '#' && !active) {
239
+ while (index + 1 < command.length && command[index + 1] !== '\n' && command[index + 1] !== '\r') index += 1;
240
+ continue;
241
+ }
242
+ if (ch === '<' && command[index + 1] === '<' && command[index + 2] !== '<') {
243
+ pushWord();
244
+ awaitingHereDocDelimiter = { stripTabs: command[index + 2] === '-' };
245
+ index += awaitingHereDocDelimiter.stripTabs ? 2 : 1;
246
+ continue;
247
+ }
248
+ if (ch === '\n' || ch === '\r') {
249
+ pushWord();
250
+ awaitingHereDocDelimiter = null;
251
+ pushSegment();
252
+ if (hereDocs.length) {
253
+ index = skipHereDocBodies(command, index, hereDocs) - 1;
254
+ hereDocs.length = 0;
255
+ } else if (ch === '\r' && command[index + 1] === '\n') index += 1;
256
+ continue;
257
+ }
258
+ if (/\s/.test(ch)) { pushWord(); continue; }
259
+ if (';&|<>()'.includes(ch)) {
260
+ pushWord();
261
+ awaitingHereDocDelimiter = null;
262
+ pushSegment();
263
+ continue;
264
+ }
265
+ token += ch;
266
+ active = true;
267
+ }
268
+ pushSegment();
269
+ return segments;
270
+ }
271
+
272
+ function isDocCheckTarget(token) {
273
+ const basename = String(token || '').split(/[\\/]/).pop().toLowerCase();
274
+ return basename === 'doc-check' || basename === 'doc-check.js';
275
+ }
276
+
277
+ function commandAfterWrapper(words, wrapper, depth) {
278
+ for (let index = 1; index < words.length; index += 1) {
279
+ const word = words[index];
280
+ if (word === '--') return { words: words.slice(index + 1) };
281
+ if (wrapper === 'env') {
282
+ if (DOC_CHECK_ENV_VALUE_FLAGS.has(word)) { index += 1; continue; }
283
+ if (/^--(?:unset|chdir)=/.test(word) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) continue;
284
+ if (word.startsWith('-')) continue;
285
+ return { words: words.slice(index) };
286
+ }
287
+ if (DOC_CHECK_NPX_VALUE_FLAGS.has(word)) { index += 1; continue; }
288
+ if (word === '-c' || word === '--call') return { command: words[index + 1], depth: depth + 1 };
289
+ if (/^--(?:package|prefix|cache|userconfig|registry|workspace)=/.test(word)) continue;
290
+ if (word.startsWith('-')) continue;
291
+ return { words: words.slice(index) };
292
+ }
293
+ return null;
294
+ }
295
+
296
+ // Classify explicit command/script targets; argument data that merely mentions the checker is not a candidate.
297
+ function hasDocCheckCommandTarget(command, depth = 0) {
298
+ if (typeof command !== 'string' || depth > 4) return false;
299
+ function wordsTarget(words, level) {
300
+ if (level > 4) return false;
301
+ const executable = String(words[0] || '').split(/[\\/]/).pop().toLowerCase();
302
+ if (isDocCheckTarget(words[0])) return true;
303
+ if (DOC_CHECK_SHELLS.has(executable)) {
304
+ if (isDocCheckTarget(words[1])) return true;
305
+ const commandFlag = words.findIndex((word, index) => index > 0 && /^-[a-z]*c[a-z]*$/i.test(word));
306
+ return commandFlag >= 0 && words[commandFlag + 1] !== undefined
307
+ && hasDocCheckCommandTarget(words[commandFlag + 1], level + 1);
308
+ }
309
+ if (DOC_CHECK_WRAPPERS.has(executable)) {
310
+ const nested = commandAfterWrapper(words, executable, level);
311
+ if (!nested) return false;
312
+ return nested.command !== undefined
313
+ ? hasDocCheckCommandTarget(nested.command, nested.depth)
314
+ : wordsTarget(nested.words, level + 1);
315
+ }
316
+ if (!DOC_CHECK_LAUNCHERS.has(executable)) return false;
317
+ for (let index = 1; index < words.length; index += 1) {
318
+ const word = words[index];
319
+ if (word === '--') return isDocCheckTarget(words[index + 1]);
320
+ if (DOC_CHECK_LAUNCHER_VALUE_FLAGS.has(word)) { index += 1; continue; }
321
+ if (word.startsWith('-')) continue;
322
+ return isDocCheckTarget(word);
323
+ }
324
+ return false;
325
+ }
326
+ return commandSegments(command).some(words => wordsTarget(words, depth));
327
+ }
328
+
329
+ // Internal realpath check; the public decision and reason tuple come only from resolveDocCheckInvocation.
330
+ function matchInstalledRuntime(tokens, cwd, installed) {
331
+ let argv;
332
+ try { argv = canonicalPath(path.resolve(textOf(cwd) ? cwd : process.cwd(), tokens[1])); } catch { return null; }
333
+ return argv === installed ? { tokens, installed } : null;
334
+ }
335
+
336
+ function resolveDocCheckInvocation(command, cwd) {
337
+ let installed = null;
338
+ let digest = null;
339
+ let argv = null;
340
+ function rejected(reason) { return { kind: 'rejected', reason, installed, digest, argv }; }
341
+
342
+ if (!hasDocCheckCommandTarget(command)) {
343
+ return { kind: 'not-candidate', reason: 'not-doc-check', installed: null, digest: null, argv: null };
344
+ }
345
+ const parsed = safeTokenize(command);
346
+ if (!parsed.ok) return rejected(parsed.reason);
347
+ const tokens = parsed.tokens;
348
+ argv = tokens.slice(1);
349
+ if (tokens[0] !== 'node') return rejected('direct-launcher-required');
350
+ if (tokens.length < 4 || path.basename(tokens[1]).toLowerCase() !== 'doc-check.js' || tokens[2] !== 'check') {
351
+ return rejected('invocation-grammar');
352
+ }
353
+
354
+ const values = new Map();
355
+ const changedPaths = [];
356
+ for (let index = 3; index < tokens.length; index += 2) {
357
+ const flag = tokens[index];
358
+ const value = tokens[index + 1];
359
+ if (!DOC_CHECK_FLAGS.has(flag)) return rejected('unknown-flag');
360
+ if (value === undefined || !value || value.startsWith('--')) return rejected('flag-value-missing');
361
+ if (flag === '--changed-path') changedPaths.push(value);
362
+ else {
363
+ if (values.has(flag)) return rejected('duplicate-flag');
364
+ values.set(flag, value);
365
+ }
366
+ }
367
+ if (!values.has('--workspace') || !values.has('--config') || !changedPaths.length) {
368
+ return rejected('required-flag-missing');
369
+ }
370
+
371
+ installed = installedDocCheck();
372
+ if (!installed) return rejected('implementation-not-installed');
373
+ const registered = matchInstalledRuntime(tokens, cwd, installed);
374
+ if (!registered) return rejected('implementation-not-installed');
375
+ digest = fileDigest(registered.installed);
376
+ if (registered) {
377
+ if (registered.installed) {
378
+ if (fileDigest(registered.installed) !== SHIPPED_DOC_CHECK_DIGEST) return rejected('implementation-pin-mismatch', 'the installed doc-check is not the shipped script (canonical digest differs from SHIPPED_DOC_CHECK_DIGEST); no credit');
379
+ }
380
+ }
381
+ return { kind: 'accepted', reason: null, installed: registered.installed, digest, argv };
382
+ }
383
+
384
+ // Observation-time digests of the checked paths, read from the workspace when the check ran. Absent paths are omitted.
385
+ function observedDigestsFor(root, paths) {
386
+ const digests = {};
387
+ for (const relative of paths) {
388
+ if (typeof relative !== 'string' || path.isAbsolute(relative) || relative.split(/[\\/]/).includes('..')) continue;
389
+ try { digests[relative] = crypto.createHash('sha256').update(fs.readFileSync(path.join(root, relative))).digest('hex'); }
390
+ catch { /* absent: the event will lack this digest and be rejected by the checker */ }
391
+ }
392
+ return digests;
393
+ }
394
+
395
+ function textOf(value) { return typeof value === 'string' && value.trim().length > 0; }
396
+ function recordOf(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
397
+ function compareKeys(a, b) { return a < b ? -1 : a > b ? 1 : 0; }
398
+ // Canonical capture serialization (sorted keys recursively, array order kept,
399
+ // missing/undefined values serialized as the literal `undefined` token). The
400
+ // capture journal and this provider must agree on this exact form so recorded
401
+ // digests bind the same bytes on both sides.
402
+ function canonicalCapture(value) {
403
+ if (Array.isArray(value)) return value.map(canonicalCapture);
404
+ if (value && typeof value === 'object') {
405
+ return Object.fromEntries(Object.keys(value).sort(compareKeys).map(key => [key, canonicalCapture(value[key])]));
406
+ }
407
+ return value;
408
+ }
409
+ function captureJson(value) {
410
+ const serialized = JSON.stringify(canonicalCapture(value));
411
+ return serialized === undefined ? 'undefined' : serialized;
412
+ }
413
+ function captureDigest(value) {
414
+ return crypto.createHash('sha256').update(captureJson(value)).digest('hex');
415
+ }
416
+ function digestText(value) { return crypto.createHash('sha256').update(String(value)).digest('hex'); }
417
+ function diagnostic(code, detail) {
418
+ return { code, message: code.replace(/-/g, ' '), ...(recordOf(detail) ? detail : {}) };
419
+ }
420
+ function failDiagnostics(prefix, reason) {
421
+ return [diagnostic(prefix, { reason: `${prefix}: ${reason}` })];
422
+ }
423
+
424
+ // ---- Retained live-capture gate -------------------------------------------------
425
+
426
+ // Canonical qualification export (A3 section 4): written by the live-capture packet, copied by plugin sync.
427
+ const EXPORT_FILE = 'doc-reconciliation-qualification.json';
428
+
429
+ // Structured verdict: a labelled line `Review verdict: approve|reject`, with the summary on that line or after it.
430
+ // Free text such as `REJECT: ...` carries no verdict, so it can never approve. The reviewed artifacts are named by
431
+ // `Reviewed artifact: <relative path> sha256:<64 hex>` lines; they bind the review to exact path digests (F6).
432
+ const REVIEWED_ARTIFACT_LINE = /^\s*-?\s*Reviewed artifact:\s*(\S+)\s+sha256:([0-9a-f]{64})\s*$/i;
433
+ function parseReviewVerdict(message) {
434
+ if (typeof message !== 'string') return null;
435
+ const match = /^\s*Review verdict:\s*(approve|reject)\b[.:]?\s*(.*)$/im.exec(message);
436
+ if (!match) return null;
437
+ const reviewedArtifactDigests = {};
438
+ const summary = [match[2], message.slice(match.index + match[0].length)].join('\n').split(/\r?\n/)
439
+ .filter(line => {
440
+ const reviewed = REVIEWED_ARTIFACT_LINE.exec(line);
441
+ if (reviewed) reviewedArtifactDigests[reviewed[1]] = reviewed[2].toLowerCase();
442
+ return !reviewed;
443
+ }).join('\n').trim();
444
+ return { verdict: match[1].toLowerCase(), summary, reviewedArtifactDigests };
445
+ }
446
+
447
+ // Conservative trust gate (owner decision 3, A10 §1). The narrow candidate scanner only selects doc refs; trust asks more.
448
+ // A command is risky when strict safeTokenize refuses it (newline, heredoc, `$()`, operators, malformed quoting) or when it
449
+ // names the checker, raw or decoded. Risky commands get no generic verification and no reviewer independence. The check is
450
+ // conservative on purpose: it is not a shell parser, and non-string input is not shell text.
451
+ // shortcut: benign compounds such as `npm test && echo ok` lose generic credit, upgrade with a real shell parser.
452
+ function trustRisk(command) {
453
+ const text = typeof command === 'string' ? command : '';
454
+ if (/doc-check/i.test(text)) return true;
455
+ if (commandSegments(text).some(words => words.some(word => /doc-check/i.test(word)))) return true;
456
+ // A Windows relative path and a POSIX escape can look the same; fail closed on either command spelling.
457
+ const posixDecoded = text.replace(/\\([^\r\n])/g, '$1');
458
+ if (posixDecoded !== text && hasDocCheckCommandTarget(posixDecoded)) return true;
459
+ const parsed = safeTokenize(text);
460
+ return !parsed.ok || parsed.tokens.some(word => /doc-check/i.test(word));
461
+ }
462
+
463
+ // Executor principals of this task (owner #325, 2026-10-21: the reviewer "must differ from the author's `session_id` and
464
+ // from every executor principal"; an executor principal is the principal of execution evidence used in this assessment).
465
+ // The author session is always an executor. Other executors are the principals of a registered doc-check run, the only
466
+ // execution evidence a doc obligation uses, and of any risky shell command (trustRisk), which could have run the checker.
467
+ // Acceptance is not required, so the set over-approximates and fails closed: a reviewer whose registered run was refused
468
+ // still counts as an executor. Plain read-only tool use, such as `git status`, does not make a reviewer an executor.
469
+ // The Agent/Task launch surface is never execution evidence.
470
+ function journalExecutors(captures, author) {
471
+ const executors = new Set(textOf(author) ? [author] : []);
472
+ for (const item of Array.isArray(captures) ? captures : []) {
473
+ if (!recordOf(item) || item.event !== 'PostToolUse') continue;
474
+ if (item.tool_name === 'Agent' || item.tool_name === 'Task') continue;
475
+ if (resolveDocCheckInvocation(item.command, item.cwd).kind === 'not-candidate' && !trustRisk(item.command)) continue;
476
+ if (textOf(item.session_id)) executors.add(item.session_id);
477
+ if (textOf(item.agent_id)) executors.add(item.agent_id);
478
+ }
479
+ return [...executors].sort(compareKeys);
480
+ }
481
+
482
+ // The Agent/Task result surface that names this reviewer: a PostToolUse of the launch tool whose agent_id is the reviewer.
483
+ function agentResultSurface(captures, reviewer) {
484
+ return (Array.isArray(captures) ? captures : []).some(item => recordOf(item) && item.event === 'PostToolUse'
485
+ && (item.tool_name === 'Agent' || item.tool_name === 'Task') && item.agent_id === reviewer);
486
+ }
487
+
488
+ // A reviewed path must be a relative governed path; its digest must be a sha256 of the observed bytes.
489
+ function reviewedDigestsValid(digests) {
490
+ return recordOf(digests) && Object.keys(digests).length > 0
491
+ && Object.entries(digests).every(([relative, digest]) => textOf(relative) && !path.isAbsolute(relative)
492
+ && !relative.split(/[\\/]/).includes('..') && HEX64.test(String(digest)));
493
+ }
494
+
495
+ // What the sanitized capture proves: the payload kinds with their fields, and the correlation rules it demonstrates.
496
+ // The export must claim exactly these; a claim the capture does not support fails qualification.
497
+ const QUALIFICATION_FIELDS = ['tool_use_id', 'interrupted', 'agent_id', 'agent_type', 'session_id'];
498
+
499
+ function observedPayloadKinds(records) {
500
+ const kinds = {};
501
+ for (const record of records) {
502
+ if (!recordOf(record) || !textOf(record.event)) continue;
503
+ const fields = kinds[record.event] || (kinds[record.event] = new Set());
504
+ for (const field of QUALIFICATION_FIELDS) if (record[field] !== undefined) fields.add(field);
505
+ }
506
+ return Object.fromEntries(Object.keys(kinds).sort(compareKeys).map(kind => [kind, [...kinds[kind]].sort(compareKeys)]));
507
+ }
508
+
509
+ function observedCorrelation(records) {
510
+ const pre = new Set(records.filter(item => recordOf(item) && item.event === 'PreToolUse' && textOf(item.tool_use_id)).map(item => item.tool_use_id));
511
+ const post = records.filter(item => recordOf(item) && item.event === 'PostToolUse' && textOf(item.tool_use_id));
512
+ const starts = records.filter(item => recordOf(item) && item.event === 'SubagentStart');
513
+ const stops = records.filter(item => recordOf(item) && item.event === 'SubagentStop');
514
+ return {
515
+ preToPostByToolUseId: post.some(item => pre.has(item.tool_use_id)),
516
+ interruptedExplicit: post.some(item => typeof item.interrupted === 'boolean'),
517
+ subagentStartStopSameAgent: stops.some(stop => starts.some(start => textOf(stop.agent_id) && start.agent_id === stop.agent_id)),
518
+ agentResultAliasCorrelated: records.some(item => recordOf(item) && item.event === 'PostToolUse' && textOf(item.agent_id)
519
+ && stops.some(stop => stop.agent_id === item.agent_id)),
520
+ };
521
+ }
522
+
523
+ // Validates the qualification export against the sanitized capture it names: schema, host, capture-record version, the
524
+ // capture's digest, and every claimed kind, field and correlation. Returns null when valid, else the reason.
525
+ function validateQualificationExport() {
526
+ const exported = JSON.parse(fs.readFileSync(path.join(__dirname, EXPORT_FILE), 'utf8'));
527
+ if (!recordOf(exported) || exported.schema !== QUALIFICATION_EXPORT_SCHEMA) return 'qualification export schema is not recognized';
528
+ if (exported.host !== HOST_ID || !textOf(exported.hostVersion)) return 'qualification export host or host version is missing';
529
+ if (exported.captureRecordVersion !== CAPTURE_RECORD_VERSION) return 'qualification export capture record version is not supported';
530
+ const source = recordOf(exported.sanitizedCapture) ? exported.sanitizedCapture : {};
531
+ // The sanitized capture is a sibling of the export, so source and packaged copies resolve it the same way.
532
+ if (!textOf(source.file) || /[\\/]/.test(source.file) || source.file.includes('..')) {
533
+ return 'qualification export names no valid capture file';
534
+ }
535
+ const bytes = fs.readFileSync(path.join(__dirname, source.file));
536
+ if (crypto.createHash('sha256').update(bytes).digest('hex') !== source.sha256) {
537
+ return 'sanitized capture digest does not match the qualification export';
538
+ }
539
+ const records = bytes.toString('utf8').split(/\r?\n/).filter(line => line.trim()).map(line => JSON.parse(line));
540
+ if (!records.every(recordOf)) return 'sanitized capture contains a record that is not an object';
541
+ if (captureJson(exported.payloadKinds) !== captureJson(observedPayloadKinds(records))) {
542
+ return 'qualification export payload kinds do not match the sanitized capture';
543
+ }
544
+ const correlation = observedCorrelation(records);
545
+ if (captureJson(exported.correlation) !== captureJson(correlation)) return 'qualification export correlation does not match the sanitized capture';
546
+ if (!Object.values(correlation).every(Boolean)) return 'required correlation rules are not observed in the sanitized capture';
547
+ return null;
548
+ }
549
+
550
+ function evaluateQualification(input) {
551
+ if (recordOf(input) && input.qualification !== undefined && input.qualification !== null) {
552
+ const supplied = input.qualification;
553
+ // Test-only DI path: an explicitly fake qualification object. Anything else
554
+ // supplied is unrecognized qualification evidence and cannot open the gate.
555
+ if (recordOf(supplied) && supplied.faked === true && supplied.source === 'fake-test-fixture'
556
+ && supplied.toolUseIdPopulated === true && supplied.agentIdPopulated === true) {
557
+ return { open: true, source: 'dependency-injection' };
558
+ }
559
+ return {
560
+ open: false,
561
+ fail: diagnostic('live-gate-unmet', {
562
+ reason: 'live gate unmet: supplied qualification evidence is malformed or unrecognized; positive credit requires verified retained evidence or an explicitly fake test DI object',
563
+ }),
564
+ };
565
+ }
566
+ // Production path: no qualification is supplied, so the validated qualification export is the only source. It is checked
567
+ // against the sanitized capture it names. Nothing is read from the environment, capture flags or ancestor directories.
568
+ let reason = null;
569
+ try {
570
+ reason = validateQualificationExport();
571
+ } catch (error) {
572
+ reason = `qualification evidence could not be read (${error.code || 'error'})`;
573
+ }
574
+ if (reason) {
575
+ return {
576
+ open: false,
577
+ fail: diagnostic('live-gate-unmet', { reason: `live gate unmet: ${reason}` }),
578
+ };
579
+ }
580
+ return { open: true, source: 'validated-export' };
581
+ }
582
+
583
+ // ---- Shared provider state ------------------------------------------------------
584
+
585
+ function supportedHostCheck(input) {
586
+ if (recordOf(input) && input.hostId === HOST_ID) return null;
587
+ const host = recordOf(input) && typeof input.hostId === 'string' ? input.hostId : 'unknown';
588
+ return diagnostic('unsupported-host', {
589
+ reason: `unsupported host ${host} has no supported capture implementation; OpenCode/Codex must not borrow the Claude exit mapping`,
590
+ });
591
+ }
592
+
593
+ function contentSignature(cap) {
594
+ return captureJson({
595
+ event: cap.event === undefined ? null : cap.event,
596
+ command: cap.command === undefined ? null : cap.command,
597
+ cwd: cap.cwd === undefined ? null : cap.cwd,
598
+ tool_use_id: cap.tool_use_id === undefined ? null : cap.tool_use_id,
599
+ session_id: cap.session_id === undefined ? null : cap.session_id,
600
+ agent_id: cap.agent_id === undefined ? null : cap.agent_id,
601
+ agent_type: cap.agent_type === undefined ? null : cap.agent_type,
602
+ responseDigest: cap.responseDigest === undefined ? null : cap.responseDigest,
603
+ reviewDigest: cap.reviewDigest === undefined ? null : cap.reviewDigest,
604
+ interrupted: typeof cap.interrupted === 'boolean' ? cap.interrupted : null,
605
+ is_interrupt: typeof cap.is_interrupt === 'boolean' ? cap.is_interrupt : null,
606
+ failureDisposition: cap.failureDisposition === undefined ? null : cap.failureDisposition,
607
+ reviewVerdict: cap.reviewVerdict === undefined ? null : cap.reviewVerdict,
608
+ reviewSummary: cap.reviewSummary === undefined ? null : cap.reviewSummary,
609
+ });
610
+ }
611
+
612
+ function rejected(code, reason) { return { status: 'rejected', diagnostics: [diagnostic(code, { reason: `${code}: ${reason}` })] }; }
613
+ function unavailable(code, reason) { return { status: 'unavailable', diagnostics: [diagnostic(code, { reason: `${code}: ${reason}` })] }; }
614
+
615
+ const createClaudeOriginProvider = input => {
616
+ const safeInput = recordOf(input) ? input : {};
617
+
618
+ function resolveContext(request) {
619
+ const hostFail = supportedHostCheck(safeInput);
620
+ if (hostFail) return { status: 'unavailable', diagnostics: [hostFail] };
621
+ const qualification = evaluateQualification(safeInput);
622
+ if (!qualification.open) return { status: 'unavailable', diagnostics: [qualification.fail] };
623
+ const workspaceRoot = textOf(safeInput.workspaceRoot) ? safeInput.workspaceRoot : (recordOf(request) && textOf(request.workspaceRoot) ? request.workspaceRoot : '');
624
+ const author = recordOf(safeInput.principals) && textOf(safeInput.principals.author) ? safeInput.principals.author : safeInput.sessionId;
625
+ const executors = recordOf(safeInput.principals) && Array.isArray(safeInput.principals.executors) ? safeInput.principals.executors.filter(textOf) : [];
626
+ const originRef = 'origin://claude-code/' + digestText(captureJson({
627
+ hostId: HOST_ID,
628
+ sessionId: safeInput.sessionId === undefined ? null : safeInput.sessionId,
629
+ workspaceRoot,
630
+ taskId: safeInput.taskId === undefined ? null : safeInput.taskId,
631
+ workflowId: safeInput.workflowId === undefined ? null : safeInput.workflowId,
632
+ workflowRevision: safeInput.workflowRevision === undefined ? null : safeInput.workflowRevision,
633
+ author,
634
+ executors: executors.slice(),
635
+ qualificationSource: qualification.source,
636
+ }));
637
+ return {
638
+ status: 'accepted',
639
+ originRef,
640
+ context: {
641
+ workspaceId: 'ws-' + digestText(captureJson({ workspaceRoot })),
642
+ sessionId: safeInput.sessionId === undefined ? '' : safeInput.sessionId,
643
+ workflowId: textOf(safeInput.workflowId) ? safeInput.workflowId : '',
644
+ workflowRevision: Number.isInteger(safeInput.workflowRevision) ? safeInput.workflowRevision : 0,
645
+ taskId: textOf(safeInput.taskId) ? safeInput.taskId : '',
646
+ principals: { author, executors: executors.slice() },
647
+ activeHost: HOST_ID,
648
+ baselineReference: `retained-live-capture:claude-325-3b-s2b:${digestText(originRef)}`,
649
+ // The concrete workflow-start pin, when the caller supplies it. It is never inferred from the current state.
650
+ ...(recordOf(safeInput.baseline) && textOf(safeInput.baseline.commit) && textOf(safeInput.baseline.root)
651
+ ? { baseline: { commit: safeInput.baseline.commit, root: safeInput.baseline.root } } : {}),
652
+ workspaceRoot,
653
+ },
654
+ };
655
+ }
656
+
657
+ function resolveEvent(request) {
658
+ if (!recordOf(request) || !textOf(request.reference)) return unavailable('invalid-input', 'resolveEvent requires a nonempty event reference');
659
+ const hostFail = supportedHostCheck(safeInput);
660
+ if (hostFail) return { status: 'unavailable', diagnostics: [hostFail] };
661
+ const qualification = evaluateQualification(safeInput);
662
+ if (!qualification.open) return { status: 'unavailable', diagnostics: [qualification.fail] };
663
+ const { reference } = request;
664
+ const captures = Array.isArray(safeInput.captures) ? safeInput.captures : [];
665
+ // A reference is `<captureRef>` (lifecycle events) or `<captureRef>#<capability>` (doc-check projections).
666
+ const [captureRef, capability] = String(reference).split('#');
667
+ const index = captures.findIndex(item => recordOf(item) && item.ref === captureRef);
668
+ if (index === -1) return unavailable('origin-unavailable', `no captured host event is retained for reference ${reference}`);
669
+ const cap = captures[index];
670
+ const originRefResponse = resolveContext(request);
671
+ if (!originRefResponse || originRefResponse.status !== 'accepted' || !textOf(originRefResponse.originRef)) {
672
+ return unavailable('origin-unavailable', 'origin context did not resolve');
673
+ }
674
+ const originRef = originRefResponse.originRef;
675
+ const author = recordOf(safeInput.principals) && textOf(safeInput.principals.author) ? safeInput.principals.author : safeInput.sessionId;
676
+ const executors = recordOf(safeInput.principals) && Array.isArray(safeInput.principals.executors) ? safeInput.principals.executors.filter(textOf) : [];
677
+ // Session binding: a record copied from another session can never belong to
678
+ // this workflow's assessment.
679
+ if (cap.session_id === undefined || cap.session_id !== safeInput.sessionId) {
680
+ return rejected('record-origin-mismatch', `captured record session_id must equal the provider session to belong to this invocation (owner row: copied records from another session are rejected)`);
681
+ }
682
+ // Observation-time identity (I2c): a record captured under another workflow, revision or baseline pin is stale.
683
+ if (cap.observedIdentity !== undefined && cap.observedIdentity !== null) {
684
+ const observed = cap.observedIdentity;
685
+ const pin = recordOf(safeInput.baseline) ? safeInput.baseline : null;
686
+ const sameWorkflow = observed.workflowId === safeInput.workflowId && observed.workflowRevision === safeInput.workflowRevision;
687
+ const samePin = !pin || (recordOf(observed.baselinePin) && observed.baselinePin.commit === pin.commit && observed.baselinePin.root === pin.root);
688
+ if (!sameWorkflow || !samePin) {
689
+ return rejected('identity-mismatch', 'the observed workflow revision or baseline pin differs from the current one; stale evidence loses credit');
690
+ }
691
+ }
692
+ if (capability && !(recordOf(cap.observedIdentity))) {
693
+ return rejected('identity-missing', 'a doc capability needs the workflow identity observed at capture time');
694
+ }
695
+ // Replay: identical host-event content re-presented under a new reference
696
+ // cannot become a new accepted event. The first recorded occurrence wins.
697
+ const signature = contentSignature(cap);
698
+ const firstOccurrence = captures.findIndex(item => recordOf(item) && contentSignature(item) === signature);
699
+ if (firstOccurrence !== index) {
700
+ return rejected('replayed-record', `identical host-event content already recorded (first occurrence index ${firstOccurrence}); a copied record cannot become a new accepted event by changing its reference`);
701
+ }
702
+ const producerId = 'claude-code-origin-provider';
703
+ // Only true doc capabilities reach the doc-check branch; quality-review is the SubagentStop review branch below.
704
+ if (capability && capability !== 'quality-review') {
705
+ // Doc capability: only a registered, passed, fully covered doc-check invocation with retained stdout can cover it.
706
+ if (cap.event !== 'PostToolUse') return rejected('capability-mismatch', 'only a PostToolUse check invocation can cover a doc capability');
707
+ const registered = resolveDocCheckInvocation(cap.command, cap.cwd);
708
+ if (registered.kind === 'not-candidate') return rejected('unregistered-command', 'the command is not a registered doc-check invocation; an arbitrary successful shell command never covers a doc capability');
709
+ if (registered.kind === 'rejected' && registered.reason !== 'implementation-pin-mismatch') {
710
+ if (registered.reason === 'implementation-not-installed') return rejected('implementation-not-installed', 'the registered check does not resolve to this install\'s own doc-check runtime; a different path earns no credit');
711
+ return rejected('unregistered-command', `doc-check invocation rejected (${registered.reason}); no credit`);
712
+ }
713
+ if (cap.implementationDigest !== fileDigest(registered.installed)) return rejected('implementation-digest-mismatch', 'the installed doc-check digest differs from the one observed at capture; no credit');
714
+ if (registered.kind === 'rejected') return rejected('implementation-pin-mismatch', 'the installed doc-check is not the shipped script (canonical digest differs from SHIPPED_DOC_CHECK_DIGEST); no credit');
715
+ if (!(cap.tool_use_id && typeof cap.tool_use_id === 'string')) return rejected('tool-use-id-missing', 'absent or empty tool_use_id: the invocation cannot be correlated, no credit');
716
+ if (typeof cap.interrupted !== 'boolean') return rejected('outcome-ambiguous', 'ambiguous interruption/success correlation cannot be treated as success');
717
+ if (cap.interrupted === true) return rejected('interrupted-not-credited', 'interrupted success is not mapped to exit 0 (owner decision row 1)');
718
+ if (captures.some((item, i) => recordOf(item) && i !== index && item.event === 'PostToolUseFailure'
719
+ && item.tool_use_id === cap.tool_use_id && item.session_id === cap.session_id)) {
720
+ return rejected('failure-invalidates-mapping', 'a PostToolUseFailure arrived for the same tool_use_id; the success mapping is voided');
721
+ }
722
+ if (typeof cap.stdout !== 'string') return rejected('stdout-withheld', 'retained stdout is absent or over the cap; the event is withheld and never credited');
723
+ if (crypto.createHash('sha256').update(cap.stdout).digest('hex') !== cap.stdoutDigest) return rejected('stdout-digest-mismatch', 'retained stdout does not match its digest');
724
+ // The raw host result must still be the one observed: its canonical digest and stdout must match the record.
725
+ if (!recordOf(cap.toolResponse) || captureDigest(cap.toolResponse) !== cap.responseDigest || cap.toolResponse.stdout !== cap.stdout) {
726
+ return rejected('payload-edited', 'the retained raw tool response does not match the recorded responseDigest; an edited payload is never credited');
727
+ }
728
+ let report;
729
+ try { report = JSON.parse(cap.stdout); } catch { return rejected('stdout-not-json', 'retained stdout is not a JSON check report'); }
730
+ if (!recordOf(report) || report.schema !== DOC_CHECK_SCHEMA || !recordOf(report.lint) || report.lint.exit !== 0) {
731
+ return rejected('check-not-passed', 'the registered check did not pass lint; no capability is credited');
732
+ }
733
+ const projection = recordOf(report.capabilities) ? report.capabilities[capability] : undefined;
734
+ if (!recordOf(projection) || projection.state !== 'pass') {
735
+ return rejected('partial-coverage', `${capability} is ${projection ? projection.state : 'absent'}; partial coverage earns no credit`);
736
+ }
737
+ const paths = Array.isArray(projection.checkedPaths) ? projection.checkedPaths.slice() : [];
738
+ if (!paths.length) return rejected('scope-empty', `${capability} checked no governed paths`);
739
+ const observed = recordOf(cap.observedDigests) ? cap.observedDigests : {};
740
+ const artifactDigests = {};
741
+ for (const relative of paths) {
742
+ if (typeof observed[relative] !== 'string') return rejected('artifact-unobserved', `no observation-time digest for ${relative}`);
743
+ artifactDigests[relative] = observed[relative];
744
+ }
745
+ const argv = ['node', ...registered.argv];
746
+ const command = argv.join(' ');
747
+ const event = {
748
+ eventId: 'claude-check-' + digestText(cap.captureId + ':' + capability),
749
+ producerId,
750
+ originRef,
751
+ principalId: cap.session_id,
752
+ provenance: 'claude-code-capture:' + cap.captureId,
753
+ capability,
754
+ kind: 'execution',
755
+ command,
756
+ argv,
757
+ cwd: typeof cap.cwd === 'string' ? cap.cwd : '',
758
+ exitCode: 0,
759
+ signal: null,
760
+ toolCorrelationId: cap.tool_use_id,
761
+ resultRef: cap.responseRef,
762
+ identity: recordOf(request.identity) ? JSON.parse(JSON.stringify(request.identity)) : null,
763
+ startIdentity: recordOf(request.identity) ? JSON.parse(JSON.stringify(request.identity)) : null,
764
+ endIdentity: recordOf(request.identity) ? JSON.parse(JSON.stringify(request.identity)) : null,
765
+ paths,
766
+ artifactDigests,
767
+ captured: { responseRef: cap.responseRef, responseDigest: cap.responseDigest },
768
+ };
769
+ return { status: 'accepted', originRef, event, correlation: { toolCorrelationId: cap.tool_use_id, resultRef: cap.responseRef } };
770
+ }
771
+ // A quality-review reference must name a SubagentStop review; it never falls through to the lifecycle branches below.
772
+ if (capability === 'quality-review' && cap.event !== 'SubagentStop') {
773
+ return rejected('capability-mismatch', 'quality-review is credited only from a SubagentStop review');
774
+ }
775
+ if (cap.event === 'PostToolUseFailure') {
776
+ return rejected('failure-not-credited', 'PostToolUseFailure is never credited; its Exit code N text is diagnostics-only (owner decision row 1)');
777
+ }
778
+ if (cap.event === 'SubagentStart') {
779
+ return rejected('capture-not-completed', 'SubagentStart records only a launch; a background launch is not a completed review or execution');
780
+ }
781
+ if (cap.event === 'PreToolUse') {
782
+ return rejected('capture-not-completed', 'PreToolUse records only a launch; without a completed outcome it cannot be credited');
783
+ }
784
+ if (cap.event === 'PostToolUse') {
785
+ if (!(cap.tool_use_id && typeof cap.tool_use_id === 'string')) {
786
+ return rejected('tool-use-id-missing', 'absent or empty tool_use_id: the invocation cannot be correlated, no credit');
787
+ }
788
+ if (typeof cap.interrupted !== 'boolean') {
789
+ return rejected('outcome-ambiguous', 'ambiguous interruption/success correlation cannot be treated as success');
790
+ }
791
+ if (cap.interrupted === true) {
792
+ return rejected('interrupted-not-credited', 'interrupted success is not mapped to exit 0 (owner decision row 1)');
793
+ }
794
+ if (!textOf(cap.command) && typeof cap.command !== 'string') {
795
+ return rejected('command-missing', 'captured record without a retained command cannot be credited');
796
+ }
797
+ const failureForInvocation = captures.some((item, i) => recordOf(item) && i !== index
798
+ && item.event === 'PostToolUseFailure' && item.tool_use_id === cap.tool_use_id && item.session_id === cap.session_id);
799
+ if (failureForInvocation) {
800
+ return rejected('failure-invalidates-mapping', 'a PostToolUseFailure arrived for the same tool_use_id; the success mapping is voided (owner decision row 1)');
801
+ }
802
+ const eventId = 'claude-exit-map-' + digestText(signature + ':execution');
803
+ const event = {
804
+ eventId,
805
+ producerId,
806
+ originRef,
807
+ principalId: cap.session_id,
808
+ capability: 'shell-command',
809
+ kind: 'execution',
810
+ command: cap.command,
811
+ argv: [cap.command],
812
+ cwd: cap.cwd === undefined ? '' : cap.cwd,
813
+ exitCode: 0,
814
+ signal: null,
815
+ toolCorrelationId: cap.tool_use_id,
816
+ resultRef: cap.responseRef,
817
+ identity: recordOf(request.identity) ? JSON.parse(JSON.stringify(request.identity)) : null,
818
+ startIdentity: recordOf(request.identity) ? JSON.parse(JSON.stringify(request.identity)) : null,
819
+ endIdentity: recordOf(request.identity) ? JSON.parse(JSON.stringify(request.identity)) : null,
820
+ paths: [],
821
+ artifactDigests: {},
822
+ captured: { responseRef: cap.responseRef, responseDigest: cap.responseDigest },
823
+ };
824
+ return { status: 'accepted', originRef, event, correlation: { toolCorrelationId: cap.tool_use_id, resultRef: cap.responseRef } };
825
+ }
826
+ if (cap.event === 'SubagentStop') {
827
+ const reviewer = cap.agent_id;
828
+ if (!textOf(reviewer)) {
829
+ return rejected('reviewer-principal-missing', 'SubagentStop without a host-issued agent_id cannot establish a reviewer principal; a caller-supplied name or review prose is not an agent identity');
830
+ }
831
+ // Executors: the configured principals plus every session and agent that ran a captured tool in this task (F4).
832
+ const reviewerExecutors = [...new Set([...executors, ...journalExecutors(captures, author)])];
833
+ if (reviewer === author) {
834
+ return rejected('reviewer-not-independent', 'the reviewer agent_id equals the author session principal; not independent');
835
+ }
836
+ if (reviewerExecutors.includes(reviewer)) {
837
+ return rejected('reviewer-not-independent', 'the reviewer agent_id equals an executor principal; not independent');
838
+ }
839
+ // An approval needs its own correlated Agent/Task result surface, never a bare SubagentStop (F5).
840
+ if (!agentResultSurface(captures, reviewer)) {
841
+ return rejected('agent-result-missing', 'no correlated Agent/Task result names this reviewer agent_id; without that surface the review is not approved');
842
+ }
843
+ if (!HEX64.test(String(cap.reviewDigest === undefined ? '' : cap.reviewDigest))) {
844
+ return rejected('review-digest-missing', 'SubagentStop without a bound final review artifact digest cannot close a review');
845
+ }
846
+ // Approval only from an explicit structured verdict in the bound final artifact. SubagentStop alone never approves.
847
+ if (cap.reviewVerdict === 'reject') {
848
+ return rejected('review-not-approved', 'the final review artifact states reject; a reject never approves');
849
+ }
850
+ if (cap.reviewVerdict !== 'approve') {
851
+ return rejected('review-verdict-missing', 'the final review artifact has no explicit structured verdict; SubagentStop alone is never approval');
852
+ }
853
+ if (!textOf(cap.reviewSummary)) {
854
+ return rejected('review-summary-missing', 'an approve verdict needs a non-empty summary');
855
+ }
856
+ // The reviewed paths and their digests come from the explicit verdict, never from the caller (F6).
857
+ if (!reviewedDigestsValid(cap.reviewedArtifactDigests)) {
858
+ return rejected('reviewed-artifacts-missing', 'an approve verdict must name each reviewed path with its sha256 (Reviewed artifact lines); without them the review cannot bind the candidate');
859
+ }
860
+ const eventId = 'claude-review-' + digestText(signature + ':review');
861
+ const independentOf = [];
862
+ for (const candidate of [author, ...reviewerExecutors]) {
863
+ if (textOf(candidate) && candidate !== reviewer && !independentOf.includes(candidate)) independentOf.push(candidate);
864
+ }
865
+ const event = {
866
+ eventId,
867
+ producerId,
868
+ originRef,
869
+ principalId: reviewer,
870
+ provenance: 'claude-code-capture:' + cap.captureId,
871
+ capability: 'quality-review',
872
+ kind: 'review',
873
+ verdict: 'approve',
874
+ summary: cap.reviewSummary,
875
+ independentOf,
876
+ identity: recordOf(request.identity) ? JSON.parse(JSON.stringify(request.identity)) : null,
877
+ paths: Object.keys(cap.reviewedArtifactDigests).sort(compareKeys),
878
+ reviewedArtifactDigests: { ...cap.reviewedArtifactDigests },
879
+ captured: {
880
+ responseRef: cap.responseRef,
881
+ responseDigest: cap.responseDigest,
882
+ reviewDigest: cap.reviewDigest,
883
+ agentId: reviewer,
884
+ agentType: cap.agent_type === undefined ? null : cap.agent_type,
885
+ },
886
+ };
887
+ return { status: 'accepted', originRef, event, correlation: { toolCorrelationId: cap.agent_id, resultRef: cap.responseRef } };
888
+ }
889
+ return rejected('capture-event-unsupported', `event ${String(cap.event)} is outside the retained capture vocabulary`);
890
+ }
891
+
892
+ return { resolveContext, resolveEvent };
893
+ };
894
+
895
+ // ---- Capture record builder (consumed by state-persist.js) ----------------------
896
+
897
+ // Builds the retained capture record for a Claude hook payload. The field names
898
+ // are the doc's capture-table vocabulary (docs/doc-workflow-reconciliation.md
899
+ // § S2b). Absent input fields stay absent in the record, never invented.
900
+ // Responses:
901
+ // - value capturing the event, or null when the payload does not describe a
902
+ // supported capture event.
903
+ let captureSequence = 0;
904
+
905
+ function buildCaptureRecord(payload, eventName, workspaceRoot) {
906
+ if (!recordOf(payload)) return null;
907
+ const event = typeof eventName === 'string' ? eventName : (payload.hook_event_name || payload.hookEventName || '');
908
+ if (['PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'SubagentStart', 'SubagentStop'].indexOf(event) === -1) return null;
909
+ // PreToolUse is captured only for the launches whose outcome the pilot can later correlate.
910
+ if (event === 'PreToolUse' && PRE_CAPTURE_TOOLS.indexOf(payload.tool_name) === -1) return null;
911
+ const toolInput = recordOf(payload.tool_input) ? payload.tool_input : {};
912
+ const toolResponse = payload.tool_response;
913
+ // Demonstrated alias mapping (doc: "Host result aliases such as agentId
914
+ // require a demonstrated capture mapping to the same subagent identity"):
915
+ // the PostToolUse Task/Agent result surface reports the subagent id as
916
+ // tool_response.agentId. No other alias is guessed.
917
+ const responseAliasAgentId = recordOf(toolResponse) && typeof toolResponse.agentId === 'string' && toolResponse.agentId.trim() ? toolResponse.agentId : undefined;
918
+ const captureId = `${event}:${new Date().toISOString()}:${process.pid}:${captureSequence += 1}`;
919
+ const record = {
920
+ captureId,
921
+ // Stable origin ref: the reference the assessment looks up (the journal also keeps captureId and responseRef).
922
+ ref: captureId,
923
+ event,
924
+ command: typeof toolInput.command === 'string' ? toolInput.command : '',
925
+ cwd: typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : (typeof workspaceRoot === 'string' && workspaceRoot ? workspaceRoot : ''),
926
+ tool_use_id: typeof payload.tool_use_id === 'string' ? payload.tool_use_id : '',
927
+ session_id: payload.session_id === undefined && payload.sessionId === undefined ? '' : String(payload.session_id !== undefined ? payload.session_id : payload.sessionId),
928
+ responseDigest: captureDigest(toolResponse),
929
+ responseRef: `host-payload/${event}/${captureId}`,
930
+ };
931
+ if (payload.prompt_id !== undefined) record.prompt_id = payload.prompt_id;
932
+ // The launch tool name separates the Agent/Task result surface and the subagent launch from executed tools (F4/F5).
933
+ if (typeof payload.tool_name === 'string') record.tool_name = payload.tool_name;
934
+ const agentId = payload.agent_id !== undefined ? payload.agent_id : responseAliasAgentId;
935
+ if (agentId !== undefined) {
936
+ record.agent_id = agentId;
937
+ if (payload.agent_type !== undefined) record.agent_type = payload.agent_type;
938
+ }
939
+ if (payload.duration_ms !== undefined) record.duration_ms = payload.duration_ms;
940
+ if (event === 'PostToolUse') {
941
+ // An absent interrupted field stays absent: it is unknown, not false (A3 section 1).
942
+ if (recordOf(toolResponse) && typeof toolResponse.interrupted === 'boolean') record.interrupted = toolResponse.interrupted;
943
+ // A registered doc-check invocation keeps its full stdout (capped), a digest of it, and the checked paths' digests
944
+ // at observation time. Assessment re-parses the stdout; nothing is trusted from an agent-written field.
945
+ const installedCheck = resolveDocCheckInvocation(record.command, record.cwd);
946
+ if (installedCheck.kind === 'accepted' || installedCheck.reason === 'implementation-pin-mismatch') {
947
+ // Record the observed bytes even when unpinned; only an accepted result may retain stdout.
948
+ record.implementationDigest = installedCheck.digest;
949
+ }
950
+ // G0: an unpinned doc-check keeps its digest only; its output is never retained, so no capture credit can be recorded.
951
+ if (installedCheck && record.implementationDigest === SHIPPED_DOC_CHECK_DIGEST) {
952
+ const stdout = recordOf(toolResponse) && typeof toolResponse.stdout === 'string' ? toolResponse.stdout : '';
953
+ record.stdoutDigest = crypto.createHash('sha256').update(stdout).digest('hex');
954
+ if (Buffer.byteLength(stdout) <= STDOUT_CAP_BYTES) {
955
+ record.stdout = stdout;
956
+ // The full tool response is retained so assessment can recompute responseDigest from the same bytes.
957
+ record.toolResponse = toolResponse;
958
+ } else { record.stdout = null; record.stdoutTruncated = true; }
959
+ let checkedPaths = [];
960
+ try {
961
+ const report = JSON.parse(stdout);
962
+ checkedPaths = ['doc-lint', 'link-check'].flatMap(capability => (recordOf(report.capabilities) && recordOf(report.capabilities[capability])
963
+ && Array.isArray(report.capabilities[capability].checkedPaths) ? report.capabilities[capability].checkedPaths : []));
964
+ } catch { /* not JSON: no paths observed, the event will be withheld */ }
965
+ record.observedDigests = typeof workspaceRoot === 'string' && workspaceRoot ? observedDigestsFor(workspaceRoot, [...new Set(checkedPaths)]) : {};
966
+ }
967
+ } else if (event === 'PostToolUseFailure') {
968
+ record.is_interrupt = Boolean(payload.is_interrupt);
969
+ record.failureDisposition = /Exit code (\d+)/.exec(String(payload.error === undefined ? '' : payload.error)) ? 'nonzero-exit' : 'host-failure-error';
970
+ } else if (event === 'SubagentStop') {
971
+ record.reviewDigest = digestText(String(payload.last_assistant_message === undefined ? '' : payload.last_assistant_message));
972
+ const verdict = parseReviewVerdict(payload.last_assistant_message);
973
+ record.reviewVerdict = verdict ? verdict.verdict : null;
974
+ record.reviewSummary = verdict ? verdict.summary : null;
975
+ record.reviewedArtifactDigests = verdict ? verdict.reviewedArtifactDigests : {};
976
+ if (payload.agent_transcript_path !== undefined) record.agentTranscriptPath = payload.agent_transcript_path;
977
+ }
978
+ return record;
979
+ }
980
+
981
+ // ---- Immutable per-event capture records (I2c) ----------------------------------------------------------------------
982
+ // Each capture is one event file, created exclusively and never rewritten by this code. The compatibility journal
983
+ // `doc-reconciliation-capture.json` is rebuilt from those files, and readers compare the two. This detects accidental and
984
+ // journal-only edits. It does not stop the local agent from rewriting event files, which the owner's bound accepts (F13).
985
+ const EVENT_DIR = 'doc-reconciliation-capture';
986
+ const JOURNAL_FILE = 'doc-reconciliation-capture.json';
987
+ const EVENT_NAME = /^\d{8}\.json$/;
988
+
989
+ function immutableEventFiles(sessionDir) {
990
+ const dir = path.join(sessionDir, EVENT_DIR);
991
+ if (!fs.existsSync(dir)) return null;
992
+ return fs.readdirSync(dir).filter(name => EVENT_NAME.test(name)).sort()
993
+ .map(name => JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')));
994
+ }
995
+
996
+ function writeImmutableEvent(sessionDir, entry) {
997
+ const dir = path.join(sessionDir, EVENT_DIR);
998
+ fs.mkdirSync(dir, { recursive: true });
999
+ for (let attempt = 0; attempt < 50; attempt += 1) {
1000
+ const sequence = fs.readdirSync(dir).filter(name => EVENT_NAME.test(name)).length + 1 + attempt;
1001
+ try {
1002
+ fs.writeFileSync(path.join(dir, String(sequence).padStart(8, '0') + '.json'), JSON.stringify(entry, null, 2) + '\n', { encoding: 'utf8', flag: 'wx' });
1003
+ return;
1004
+ } catch (error) {
1005
+ if (error.code !== 'EEXIST') throw error;
1006
+ }
1007
+ }
1008
+ throw new Error('capture-sequence-exhausted');
1009
+ }
1010
+
1011
+ function rebuildJournalView(sessionDir) {
1012
+ const records = immutableEventFiles(sessionDir) || [];
1013
+ const journalFile = path.join(sessionDir, JOURNAL_FILE);
1014
+ const tempFile = `${journalFile}.${process.pid}.tmp`;
1015
+ fs.writeFileSync(tempFile, JSON.stringify({ version: 1, records }, null, 2), 'utf8');
1016
+ fs.renameSync(tempFile, journalFile);
1017
+ }
1018
+
1019
+ // The records a reader may use: the journal view, provided it still equals the immutable event files. Otherwise tampered.
1020
+ function readCaptureView(sessionDir) {
1021
+ let view = [];
1022
+ try {
1023
+ const journal = JSON.parse(fs.readFileSync(path.join(sessionDir, JOURNAL_FILE), 'utf8'));
1024
+ view = Array.isArray(journal.records) ? journal.records : [];
1025
+ } catch { view = []; }
1026
+ const events = immutableEventFiles(sessionDir);
1027
+ if (events && captureJson(events) !== captureJson(view)) return { records: [], tampered: true };
1028
+ return { records: view, tampered: false };
1029
+ }
1030
+
1031
+ // Observation-time identity: the workflow and pin the host event was captured under. Absent before a workflow starts.
1032
+ function observedIdentityFor(sessionDir) {
1033
+ try {
1034
+ const workflow = JSON.parse(fs.readFileSync(path.join(sessionDir, 'workflow-run.json'), 'utf8'));
1035
+ return { workflowId: workflow.workflowId, workflowRevision: workflow.revision, baselinePin: workflow.docReconciliationBaseline || null };
1036
+ } catch {
1037
+ return null;
1038
+ }
1039
+ }
1040
+
1041
+ // Independence rules for a completed host review (owner decision row 2), usable without the credit qualification gate:
1042
+ // the reviewer is a host agent_id that differs from the author session and every executor, the surfaces agree, the final
1043
+ // artifact digest is bound, and the verdict is an explicit approve with a non-empty summary. REJECT never approves.
1044
+ function reviewRecordIndependent(cap, captures, principals) {
1045
+ if (!recordOf(cap) || cap.event !== 'SubagentStop' || !textOf(cap.agent_id)) return false;
1046
+ const executors = [...new Set([...(recordOf(principals) && Array.isArray(principals.executors) ? principals.executors : []),
1047
+ ...journalExecutors(captures, principals.author)])];
1048
+ if (cap.agent_id === principals.author || executors.includes(cap.agent_id)) return false;
1049
+ if (!agentResultSurface(captures, cap.agent_id)) return false;
1050
+ if (!HEX64.test(String(cap.reviewDigest === undefined ? '' : cap.reviewDigest))) return false;
1051
+ if (!reviewedDigestsValid(cap.reviewedArtifactDigests)) return false;
1052
+ return cap.reviewVerdict === 'approve' && textOf(cap.reviewSummary);
1053
+ }
1054
+
1055
+ // Keep the old state-persist seam as an alias; it points at the single resolver above.
1056
+ module.exports = { createClaudeOriginProvider, buildCaptureRecord, resolveDocCheckInvocation,
1057
+ registeredInstalledCheck: resolveDocCheckInvocation, installedDocCheck,
1058
+ fileDigest, SHIPPED_DOC_CHECK_DIGEST, trustRisk, journalExecutors, reviewRecordIndependent,
1059
+ writeImmutableEvent, rebuildJournalView, readCaptureView, observedIdentityFor, _internals: { captureJson, captureDigest, digestText, canonicalCapture } };