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,34 @@
1
+ {
2
+ "schema": "harness.doc-reconciliation.qualification.v1",
3
+ "host": "claude-code",
4
+ "hostVersion": "2.1.308",
5
+ "captureRecordVersion": "claude-capture-record/1",
6
+ "run": "Run 2b",
7
+ "payloadKinds": {
8
+ "PostToolUse": [
9
+ "agent_id",
10
+ "interrupted",
11
+ "tool_use_id"
12
+ ],
13
+ "PreToolUse": [
14
+ "agent_id",
15
+ "tool_use_id"
16
+ ],
17
+ "SubagentStart": [
18
+ "agent_id"
19
+ ],
20
+ "SubagentStop": [
21
+ "agent_id"
22
+ ]
23
+ },
24
+ "correlation": {
25
+ "preToPostByToolUseId": true,
26
+ "interruptedExplicit": true,
27
+ "subagentStartStopSameAgent": true,
28
+ "agentResultAliasCorrelated": true
29
+ },
30
+ "sanitizedCapture": {
31
+ "file": "doc-reconciliation-capture.sanitized.jsonl",
32
+ "sha256": "db4d8c85d2ba0fd06f53f455f31b125dd194e9a22cb337687bc0e295a16f02e5"
33
+ }
34
+ }
@@ -673,6 +673,13 @@ function updateStep(context, stepId, disposition, options = {}) {
673
673
  withholdPilotVerification(context, loaded.set);
674
674
  throw new Error('document verification pending: S1 grants no document credit');
675
675
  }
676
+ // Review-mode refusal (pilot ON only): a self-authored or non-independent review PASS stays pending.
677
+ if (disposition === 'pass' && step.stepType === 'review') {
678
+ const pilot = require('./doc-reconciliation-pilot');
679
+ if (pilot.reviewRequired(loaded.set) && !pilot.independentReviewAccepted(context)) {
680
+ throw new Error('review pending: a self-authored or non-independent review cannot pass this step; an independent host quality review is required');
681
+ }
682
+ }
676
683
  step.status = disposition;
677
684
  step.evidence = evidence;
678
685
  step.reasonCode = reasonCode;
@@ -4,6 +4,16 @@ const path = require('path');
4
4
  const { getWorkspaceRoot, getSessionDir } = require('./lib/harness-state');
5
5
  const { cwdOf, classifyShell, isVerificationShell, directMutationInWorkspace } = require('./lib/workflow-isolation');
6
6
  const { observeTool } = require('./lib/telemetry');
7
+
8
+ // S2b doc-reconciliation pilot capture (docs/doc-workflow-reconciliation.md
9
+ // § S2b "Activation and pilot-only scope", "Claude capture and origin-provider
10
+ // contract"): pilot-only, fail-open to the host, and gated on the exact
11
+ // switch value `1` before any collector logic runs. OFF must return to the
12
+ // existing legacy path before pilot collection; capture never executes a
13
+ // command, verifier or generator and never blocks a tool or Stop.
14
+ const PILOT_CAPTURE_EVENT_NAMES = ['PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'SubagentStart', 'SubagentStop'];
15
+ // Task/Agent results were never routed here before S2b, so they stay capture-only: the legacy handoff path does not run for them.
16
+ const CAPTURE_ONLY_TOOLS = ['Task', 'Agent'];
7
17
  let inputData = '';
8
18
  const timeout = setTimeout(() => processState(null), 200);
9
19
  process.stdin.on('data', chunk => { inputData += chunk; });
@@ -11,7 +21,30 @@ process.stdin.on('end', () => { clearTimeout(timeout); try { processState(JSON.p
11
21
  function processState(payload) {
12
22
  try {
13
23
  const sessionId = payload && (payload.session_id || payload.sessionId);
24
+ const captureEventName = payload ? (payload.hook_event_name || payload.hookEventName) || '' : '';
25
+ const captureToolName = payload ? (payload.tool_name || payload.tool || '') : '';
26
+ const captureOnly = captureEventName === 'SubagentStart' || captureEventName === 'SubagentStop'
27
+ || captureEventName === 'PreToolUse'
28
+ || (captureEventName === 'PostToolUse' && CAPTURE_ONLY_TOOLS.indexOf(captureToolName) !== -1);
29
+ if (captureOnly && process.env.HARNESS_DOC_RECONCILIATION_PILOT !== '1') {
30
+ process.exit(0);
31
+ return;
32
+ }
14
33
  const root = getWorkspaceRoot(payload);
34
+ // SubagentStart/SubagentStop were never routed to this script before S2b.
35
+ // They are capture-only events: the legacy handoff state path stays
36
+ // untouched for them, so wiring them while OFF adds no writes at all.
37
+ if (captureEventName === 'SubagentStart' || captureEventName === 'SubagentStop') {
38
+ persistPilotCapture(payload, captureEventName, sessionId, root);
39
+ process.exit(0);
40
+ return;
41
+ }
42
+ // Pre-tool launch capture and Task/Agent result capture: capture-only, no legacy handoff state is written.
43
+ if (captureEventName === 'PreToolUse' || (captureEventName === 'PostToolUse' && CAPTURE_ONLY_TOOLS.indexOf(captureToolName) !== -1)) {
44
+ persistPilotCapture(payload, captureEventName, sessionId, root);
45
+ process.exit(0);
46
+ return;
47
+ }
15
48
  const stateFile = path.join(getSessionDir(root, sessionId), 'handoff-state.json');
16
49
  let state = { status: 'idle', timestamp: new Date().toISOString() };
17
50
  if (fs.existsSync(stateFile)) { try { state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); } catch (_) {} }
@@ -25,7 +58,7 @@ function processState(payload) {
25
58
  const rawExitCode = toolResponse.exitCode ?? toolResponse.exit_code ?? payload.exitCode;
26
59
  const exitCode = typeof rawExitCode === 'number' ? rawExitCode : undefined;
27
60
  const stderrSignal = typeof stderr === 'string' && stderr.trim().length > 0;
28
- const hookEvent = payload.hook_event_name || payload.hookEventName || '';
61
+ const hookEvent = captureEventName;
29
62
  const isSuccessEvent = hookEvent === 'PostToolUse';
30
63
  const isFailed = hookEvent === 'PostToolUseFailure' || (exitCode !== undefined && exitCode !== 0) || (!isSuccessEvent && exitCode === undefined && stderrSignal);
31
64
  const toolName = payload.tool_name || payload.tool || 'command';
@@ -44,10 +77,53 @@ function processState(payload) {
44
77
  const commandClass = classifyShell(command);
45
78
  if (commandClass !== 'read-only' && commandClass !== 'worktree-setup') state.lastEditAt = Date.now();
46
79
  // Verification evidence needs a confirmed success: a PostToolUse event or an explicit zero exit.
47
- if (!isFailed && (isSuccessEvent || exitCode === 0) && isVerificationShell(command, { cwd: cwdOf(payload, root), root })) { state.lastVerifyAt = Date.now(); state.lastVerifyExitCode = exitCode ?? null; }
80
+ // Pilot ON only: a registered, argv-validated doc-check that resolves to this install's own runtime is the document
81
+ // verification evidence, and only at the shipped pin (R3-02). Any other path earns no verification evidence. OFF stays legacy.
82
+ const pilotEnabled = process.env.HARNESS_DOC_RECONCILIATION_PILOT === '1';
83
+ const docCheck = pilotEnabled ? require('./lib/doc-reconciliation-provider') : null;
84
+ const installedCheck = docCheck && docCheck.registeredInstalledCheck(command, cwdOf(payload, root));
85
+ const docCheckCandidate = installedCheck && installedCheck.kind !== 'not-candidate';
86
+ const pinCheckable = installedCheck && installedCheck.installed
87
+ && (installedCheck.kind === 'accepted' || installedCheck.reason === 'implementation-pin-mismatch');
88
+ // A non-candidate that is compound or names the checker is not generic verification (trustRisk); the scanner miss does not clear it.
89
+ const verifies = docCheckCandidate
90
+ ? Boolean(pinCheckable) && docCheck.fileDigest(installedCheck.installed) === docCheck.SHIPPED_DOC_CHECK_DIGEST
91
+ : !(pilotEnabled && docCheck.trustRisk(command)) && isVerificationShell(command, { cwd: cwdOf(payload, root), root });
92
+ if (!isFailed && (isSuccessEvent || exitCode === 0) && verifies) {
93
+ const verifiedAt = Date.now();
94
+ const verifiedExitCode = exitCode ?? null;
95
+ state.lastVerifyAt = verifiedAt;
96
+ state.lastVerifyExitCode = verifiedExitCode;
97
+ if (pilotEnabled) {
98
+ const source = docCheckCandidate ? 'Doc' : 'Generic';
99
+ state[`last${source}VerifyAt`] = verifiedAt;
100
+ state[`last${source}VerifyExitCode`] = verifiedExitCode;
101
+ }
102
+ }
48
103
  }
49
104
  fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
50
105
  observeTool(payload);
106
+ persistPilotCapture(payload, captureEventName, sessionId, root);
51
107
  } catch (_) {}
52
108
  process.exit(0);
53
109
  }
110
+
111
+ // Pilot-only capture journaling. Every guard is placed before any collector
112
+ // work; any failure is swallowed (fail-open), and the hook output/exit is
113
+ // never changed by capture.
114
+ function persistPilotCapture(payload, eventName, sessionId, root) {
115
+ try {
116
+ if (!payload || process.env.HARNESS_DOC_RECONCILIATION_PILOT !== '1') return;
117
+ if (PILOT_CAPTURE_EVENT_NAMES.indexOf(eventName) === -1) return;
118
+ const provider = require('./lib/doc-reconciliation-provider');
119
+ const entry = provider.buildCaptureRecord(payload, eventName, root);
120
+ if (!entry) return;
121
+ const sessionDir = getSessionDir(root, sessionId);
122
+ // Observation-time identity: the workflow and pin in force when the host event was captured.
123
+ const observed = provider.observedIdentityFor(sessionDir);
124
+ if (observed) entry.observedIdentity = observed;
125
+ // Each capture is one exclusively created event file; the compatibility journal is rebuilt from them (not tamper-proof).
126
+ provider.writeImmutableEvent(sessionDir, entry);
127
+ provider.rebuildJournalView(sessionDir);
128
+ } catch (_) { /* fail-open: capture failures never reach the host */ }
129
+ }
@@ -16,7 +16,8 @@ function decide(payload) {
16
16
  const context = loadWorkflow(payload);
17
17
  const { workflow, sessionDir } = context;
18
18
  if (!workflow || workflow.state === 'deferred') return;
19
- const handoff = readJson(path.join(sessionDir, 'handoff-state.json'));
19
+ const handoffFile = path.join(sessionDir, 'handoff-state.json');
20
+ let handoff = readJson(handoffFile);
20
21
  const unresolved = [];
21
22
  const diagnostics = [];
22
23
  let requiredFault = false;
@@ -36,11 +37,15 @@ function decide(payload) {
36
37
 
37
38
  required(() => planningUnresolved(context));
38
39
  if (String(workflow.strategy || '').startsWith('fable-')) {
39
- if (!unresolved.length) require('./lib/doc-reconciliation-pilot').assess(context, null);
40
+ if (!unresolved.length) {
41
+ require('./lib/doc-reconciliation-pilot').assess(context, null);
42
+ }
40
43
  const match = matchingRun(context);
41
44
  unresolved.push(...(match ? unresolvedStages(match, workflow, handoff?.lastEditAt || 0) : ['correlated-run-missing']));
42
45
  } else {
43
46
  required(() => unresolvedObligations(context));
47
+ // Assessment can withdraw stale document verification; read the current aggregate afterward.
48
+ handoff = readJson(handoffFile);
44
49
  const lastEdit = Math.max(workflow.lastMutationAt || 0, handoff?.lastEditAt || 0);
45
50
  if (lastEdit && (handoff?.lastVerifyAt || 0) < lastEdit) unresolved.push('verification-after-edit-missing');
46
51
  }
@@ -104,6 +104,21 @@ new breaker failure. A reflection artifact itself does not count as a code edit.
104
104
 
105
105
  ## Installation
106
106
 
107
+ **OpenCode 2.x (and newer 1.18.29+):** the module default-exports
108
+ `{ id: "harness.enforcement", setup(ctx) }` and registers `ctx.tool.hook("execute.before|after")`
109
+ plus `ctx.event.subscribe()` through the OpenCode 2 plugin context. OpenCode 2
110
+ never loads a named-export-only plugin: it fails with `Plugin must export a
111
+ default definition with an id and an effect or setup function`
112
+ (SchemaError at `["default"]`). The named `HarnessEnforcement` export stays
113
+ available for OpenCode 1.x hosts: it receives the V1 context
114
+ (`{client, directory}`) and returns a V1 hooks object
115
+ (`tool.execute.before`, `tool.execute.after`, `event`); the V2 `setup`
116
+ adapter wraps that factory and maps the V2 hook events onto it, so the
117
+ enforcement logic is shared, not duplicated.
118
+
119
+ Direction:
120
+ named factory (V1 hosts/tests) → default-export setup adapter → V2 domains.
121
+
107
122
  Copy `index.mjs` into opencode's plugin directory **under a `.js` filename** -
108
123
  it is self-contained, no sibling files required:
109
124
 
@@ -932,3 +932,80 @@ export const HarnessEnforcement = async ({ client, directory }) => {
932
932
  },
933
933
  }
934
934
  }
935
+
936
+ // ---------------------------------------------------------------------------
937
+ // OpenCode 2.x entrypoint.
938
+ //
939
+ // OpenCode 2 changed the plugin contract: a plugin default-exports
940
+ // { id, setup(ctx) } and registers hooks through domains (ctx.tool.hook,
941
+ // ctx.event.subscribe, ctx.session.*) instead of returning a hooks object from
942
+ // a named factory. A named-export-only module fails to load with
943
+ // "Plugin must export a default definition with an id and an effect or setup
944
+ // function" (SchemaError at ["default"]) - this is what tripped the global
945
+ // install on this machine's OpenCode 2.1.3.
946
+ //
947
+ // The named `HarnessEnforcement` factory above keeps the hooks-object shape
948
+ // used by the repository mechanism tests (and by OpenCode 1.x hosts); this
949
+ // default export adapts it to the V2 hooks. Tool hook events are verified
950
+ // OpenCode 2.1.3 shapes: execute.before is {tool, sessionID, agent, messageID,
951
+ // id, input} with `input` carrying the tool args; execute.after adds
952
+ // {status, result | error}.
953
+ export default {
954
+ id: "harness.enforcement",
955
+ async setup(ctx) {
956
+ const hooks = await HarnessEnforcement({
957
+ client: {
958
+ session: {
959
+ // V1 shutdown path calls client.session.prompt({path, body}) twice;
960
+ // map it onto the V2 session API instead of duplicating the code.
961
+ prompt: ({ path, body }) =>
962
+ ctx.session.prompt({
963
+ sessionID: path && path.id,
964
+ text: (body && body.parts || []).map((part) => part && part.text || "").join(""),
965
+ }),
966
+ },
967
+ },
968
+ directory: (ctx.location && ctx.location.directory) || ctx.directory,
969
+ })
970
+
971
+ await ctx.tool.hook("execute.before", (event) =>
972
+ hooks["tool.execute.before"](toolEventInput(event), { args: event.input }))
973
+ await ctx.tool.hook("execute.after", (event) =>
974
+ hooks["tool.execute.after"](toolEventInput(event), { args: event.input }))
975
+
976
+ const events = new AbortController()
977
+ void (async () => {
978
+ for await (const raw of ctx.event.subscribe({ signal: events.signal })) {
979
+ try {
980
+ await hooks.event({
981
+ event: {
982
+ type: raw.type,
983
+ // V1 event handlers read {type, properties}; V2 events are flat
984
+ // objects, so pass both shapes.
985
+ properties: raw.properties ?? raw,
986
+ },
987
+ })
988
+ } catch { // fail open, same as the hooks themselves
989
+ }
990
+ }
991
+ })()
992
+ return () => events.abort()
993
+ },
994
+ }
995
+
996
+ // Build the V1-shaped (input, output) pair the legacy hooks expect from the
997
+ // V2 tool-hook event: legacy telemetry reads callID/agentID/sessionID and
998
+ // `.args`, and edit detection reads output.args, which is the same input args
999
+ // object in the V2 API.
1000
+ function toolEventInput(event) {
1001
+ const input = event.input && typeof event.input === "object" ? event.input : {}
1002
+ return {
1003
+ ...input,
1004
+ args: input,
1005
+ tool: event.tool,
1006
+ sessionID: event.sessionID,
1007
+ agentID: event.agent,
1008
+ callID: event.id,
1009
+ callId: event.id,
1010
+ }
1011
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "harness-enforcement",
3
3
  "description": "Repository inventory for the opencode enforcement plugin and its standalone helper",
4
- "version": "0.40.0",
4
+ "version": "0.41.0",
5
5
  "runtime": "index.mjs",
6
6
  "hooks": {
7
7
  "verify": "hooks/verify.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Harness OS: An Orchestrated Agent Operating System for AI-driven Development",
5
5
  "main": "harness-everything/scripts/bootstrap.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
5
5
  "author": {
6
6
  "name": "Miya Daniel",
@@ -0,0 +1,26 @@
1
+ {"run":"1","source":"wrapper-stdin","wrapperLabel":"PreToolUse","event":"PreToolUse","tool_use_id":"<tool-use-1>","payload":{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_use_id":"<tool-use-1>","session_id":"<session-1>","prompt_id":"<prompt-1>","tool_input_command":"node <scratch>/plugin/harness-everything/scripts/doc-check.js check --workspace <scratch>/project --config .agents/harness-everything/project-docs.json --changed-path docs/a.md","cwd":"<scratch>/project","transcript_path":"<home-path>"}}
2
+ {"run":"2a","source":"wrapper-stdin","wrapperLabel":"PreToolUse","event":"PreToolUse","tool_use_id":"<tool-use-2>","payload":{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_use_id":"<tool-use-2>","session_id":"<session-2>","prompt_id":"<prompt-2>","tool_input_command":"node <scratch>/plugin/harness-everything/scripts/doc-check.js check --workspace <scratch>/project --config .agents/harness-everything/project-docs.json --changed-path docs/a.md","cwd":"<scratch>/project","transcript_path":"<home-path>"}}
3
+ {"run":"2b","source":"wrapper-stdin","wrapperLabel":"PreToolUse","event":"PreToolUse","tool_use_id":"<tool-use-3>","payload":{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_use_id":"<tool-use-3>","session_id":"<session-3>","prompt_id":"<prompt-3>","tool_input_command":"node \"<scratch>/plugin/harness-everything/scripts/doc-check.js\" check --workspace <scratch>/project --config .agents/harness-everything/project-docs.json --changed-path docs/a.md","cwd":"<scratch>/project","transcript_path":"<home-path>"}}
4
+ {"run":"2b","source":"wrapper-stdin","wrapperLabel":"PostToolUse","event":"PostToolUse","tool_use_id":"<tool-use-3>","interrupted":false,"payload":{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_use_id":"<tool-use-3>","session_id":"<session-3>","prompt_id":"<prompt-3>","tool_input_command":"node \"<scratch>/plugin/harness-everything/scripts/doc-check.js\" check --workspace <scratch>/project --config .agents/harness-everything/project-docs.json --changed-path docs/a.md","cwd":"<scratch>/project","transcript_path":"<home-path>","tool_response":{"keys":["interrupted","isImage","noOutputExpected","stderr","stdout"],"interrupted":false,"digest":"sha256:22cd89eb079e65f996432f65cfd9bed1a29366def8f3f8af9af94c26c60bd1f5"}}}
5
+ {"run":"2b","source":"wrapper-stdin","wrapperLabel":"PreToolUse","event":"PreToolUse","tool_use_id":"<tool-use-4>","payload":{"hook_event_name":"PreToolUse","tool_name":"Agent","tool_use_id":"<tool-use-4>","session_id":"<session-3>","prompt_id":"<prompt-3>","cwd":"<scratch>/project","transcript_path":"<home-path>"}}
6
+ {"run":"2b","source":"wrapper-stdin","wrapperLabel":"SubagentStart","event":"SubagentStart","agent_id":"<agent-1>","payload":{"hook_event_name":"SubagentStart","session_id":"<session-3>","prompt_id":"<prompt-3>","agent_id":"<agent-1>","agent_type":"doc-reviewer","cwd":"<scratch>/project","transcript_path":"<home-path>"}}
7
+ {"run":"2b","source":"wrapper-stdin","wrapperLabel":"PreToolUse","event":"PreToolUse","tool_use_id":"<tool-use-5>","agent_id":"<agent-1>","payload":{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_use_id":"<tool-use-5>","session_id":"<session-3>","prompt_id":"<prompt-3>","agent_id":"<agent-1>","agent_type":"doc-reviewer","tool_input_command":"cat docs/a.md && git diff docs/a.md && shasum -a 256 docs/a.md","cwd":"<scratch>/project","transcript_path":"<home-path>"}}
8
+ {"run":"2b","source":"wrapper-stdin","wrapperLabel":"PreToolUse","event":"PreToolUse","tool_use_id":"<tool-use-6>","agent_id":"<agent-1>","payload":{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_use_id":"<tool-use-6>","session_id":"<session-3>","prompt_id":"<prompt-3>","agent_id":"<agent-1>","agent_type":"doc-reviewer","tool_input_command":"git diff docs/a.md","cwd":"<scratch>/project","transcript_path":"<home-path>"}}
9
+ {"run":"2b","source":"wrapper-stdin","wrapperLabel":"PostToolUse","event":"PostToolUse","tool_use_id":"<tool-use-6>","agent_id":"<agent-1>","interrupted":false,"payload":{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_use_id":"<tool-use-6>","session_id":"<session-3>","prompt_id":"<prompt-3>","agent_id":"<agent-1>","agent_type":"doc-reviewer","tool_input_command":"git diff docs/a.md","cwd":"<scratch>/project","transcript_path":"<home-path>","tool_response":{"keys":["interrupted","isImage","noOutputExpected","stderr","stdout"],"interrupted":false,"digest":"sha256:5ff449b9366059bd45c22c411728c93b8c4b5d5a083f1243c7882279b8b2e656"}}}
10
+ {"run":"2b","source":"wrapper-stdin","wrapperLabel":"PreToolUse","event":"PreToolUse","tool_use_id":"<tool-use-7>","agent_id":"<agent-1>","payload":{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_use_id":"<tool-use-7>","session_id":"<session-3>","prompt_id":"<prompt-3>","agent_id":"<agent-1>","agent_type":"doc-reviewer","tool_input_command":"ls docs; grep -rni retr . --include=*.md","cwd":"<scratch>/project","transcript_path":"<home-path>"}}
11
+ {"run":"2b","source":"wrapper-stdin","wrapperLabel":"PostToolUse","event":"PostToolUse","tool_use_id":"<tool-use-7>","agent_id":"<agent-1>","interrupted":false,"payload":{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_use_id":"<tool-use-7>","session_id":"<session-3>","prompt_id":"<prompt-3>","agent_id":"<agent-1>","agent_type":"doc-reviewer","tool_input_command":"ls docs; grep -rni retr . --include=*.md","cwd":"<scratch>/project","transcript_path":"<home-path>","tool_response":{"keys":["interrupted","isImage","noOutputExpected","returnCodeInterpretation","stderr","stdout"],"interrupted":false,"digest":"sha256:f4f1baf50b0770a29763b15493f7c7f315321f5c2c92b5288b18d20c3480f97a"}}}
12
+ {"run":"2b","source":"wrapper-stdin","wrapperLabel":"SubagentStop","event":"SubagentStop","agent_id":"<agent-1>","payload":{"hook_event_name":"SubagentStop","session_id":"<session-3>","prompt_id":"<prompt-3>","agent_id":"<agent-1>","agent_type":"doc-reviewer","cwd":"<scratch>/project","transcript_path":"<home-path>","agent_transcript_path":"<home-path>","last_assistant_message":"Review verdict: approve\n\nSummary: The only change to `docs/a.md` is \"retries once\" becoming \"retries twice\". The front matter is intact, and the link to `b.md` resolves because that file exists. Nothing else in the repo mentions retries, so I found no contradiction. I could not check the \"twice\" claim against the code, since this repo has no code or other source for it. I also couldn't compute a SHA-256, because the shell blocked `shasum`.\n\nReviewed artifact digest: `<scratch>/project/docs/a.md`, the working-tree version, uncommitted on top of baseline 8648a71. The identifying content is the diff hunk `@@ -3,4 +3,4 @@`, with the blob change `0936062..3bea475`. The body line is \"The checkout retries twice. See [b](b.md).\""}}
13
+ {"run":"2b","source":"wrapper-stdin","wrapperLabel":"PostToolUse","event":"PostToolUse","tool_use_id":"<tool-use-4>","payload":{"hook_event_name":"PostToolUse","tool_name":"Agent","tool_use_id":"<tool-use-4>","session_id":"<session-3>","prompt_id":"<prompt-3>","cwd":"<scratch>/project","transcript_path":"<home-path>","tool_response":{"keys":["agentId","agentType","canContinueAgent","content","harnessNoteCount","harnessSectionHash","harnessTailCount","prompt","resolvedModel","status","toolStats","totalDurationMs","totalTokens","totalToolUseCount","usage"],"status":"completed","agentId":"<agent-1>","digest":"sha256:f035f2ed002f3dc6827f3109a8fdbc99ae439472bc504fc4d460b5bf0ac4204e"}}}
14
+ {"run":"1","source":"journal-record","event":"PreToolUse","tool_use_id":"<tool-use-1>","record":{"captureId":"<capture-1>","event":"PreToolUse","tool_use_id":"<tool-use-1>","session_id":"<session-1>","command":"node <scratch>/plugin/harness-everything/scripts/doc-check.js check --workspace <scratch>/project --config .agents/harness-everything/project-docs.json --changed-path docs/a.md","cwd":"<scratch>/project","responseDigest":"eb045d78d273107348b0300c01d29b7552d622abbc6faf81b3ec55359aa9950c"}}
15
+ {"run":"1","source":"journal-record","event":"PreToolUse","tool_use_id":"<tool-use-1>","record":{"captureId":"<capture-2>","event":"PreToolUse","tool_use_id":"<tool-use-1>","session_id":"<session-1>","command":"node <scratch>/plugin/harness-everything/scripts/doc-check.js check --workspace <scratch>/project --config .agents/harness-everything/project-docs.json --changed-path docs/a.md","cwd":"<scratch>/project","responseDigest":"eb045d78d273107348b0300c01d29b7552d622abbc6faf81b3ec55359aa9950c"}}
16
+ {"run":"2b","source":"journal-record","event":"PreToolUse","tool_use_id":"<tool-use-3>","record":{"captureId":"<capture-3>","event":"PreToolUse","tool_use_id":"<tool-use-3>","session_id":"<session-3>","command":"node \"<scratch>/plugin/harness-everything/scripts/doc-check.js\" check --workspace <scratch>/project --config .agents/harness-everything/project-docs.json --changed-path docs/a.md","cwd":"<scratch>/project","responseDigest":"eb045d78d273107348b0300c01d29b7552d622abbc6faf81b3ec55359aa9950c"}}
17
+ {"run":"2b","source":"journal-record","event":"PostToolUse","tool_use_id":"<tool-use-3>","interrupted":false,"record":{"captureId":"<capture-4>","event":"PostToolUse","tool_use_id":"<tool-use-3>","session_id":"<session-3>","command":"node \"<scratch>/plugin/harness-everything/scripts/doc-check.js\" check --workspace <scratch>/project --config .agents/harness-everything/project-docs.json --changed-path docs/a.md","cwd":"<scratch>/project","interrupted":false,"responseDigest":"80ccfae628fb7127a06592ddb8439b864093a34d1b11007d5b137fbc6f95fcef"}}
18
+ {"run":"2b","source":"journal-record","event":"PreToolUse","tool_use_id":"<tool-use-4>","record":{"captureId":"<capture-5>","event":"PreToolUse","tool_use_id":"<tool-use-4>","session_id":"<session-3>","cwd":"<scratch>/project","responseDigest":"eb045d78d273107348b0300c01d29b7552d622abbc6faf81b3ec55359aa9950c"}}
19
+ {"run":"2b","source":"journal-record","event":"SubagentStart","agent_id":"<agent-1>","record":{"captureId":"<capture-6>","event":"SubagentStart","session_id":"<session-3>","agent_id":"<agent-1>","agent_type":"doc-reviewer","cwd":"<scratch>/project","responseDigest":"eb045d78d273107348b0300c01d29b7552d622abbc6faf81b3ec55359aa9950c"}}
20
+ {"run":"2b","source":"journal-record","event":"PreToolUse","tool_use_id":"<tool-use-5>","agent_id":"<agent-1>","record":{"captureId":"<capture-7>","event":"PreToolUse","tool_use_id":"<tool-use-5>","session_id":"<session-3>","agent_id":"<agent-1>","agent_type":"doc-reviewer","command":"cat docs/a.md && git diff docs/a.md && shasum -a 256 docs/a.md","cwd":"<scratch>/project","responseDigest":"eb045d78d273107348b0300c01d29b7552d622abbc6faf81b3ec55359aa9950c"}}
21
+ {"run":"2b","source":"journal-record","event":"PreToolUse","tool_use_id":"<tool-use-6>","agent_id":"<agent-1>","record":{"captureId":"<capture-8>","event":"PreToolUse","tool_use_id":"<tool-use-6>","session_id":"<session-3>","agent_id":"<agent-1>","agent_type":"doc-reviewer","command":"git diff docs/a.md","cwd":"<scratch>/project","responseDigest":"eb045d78d273107348b0300c01d29b7552d622abbc6faf81b3ec55359aa9950c"}}
22
+ {"run":"2b","source":"journal-record","event":"PostToolUse","tool_use_id":"<tool-use-6>","agent_id":"<agent-1>","interrupted":false,"record":{"captureId":"<capture-9>","event":"PostToolUse","tool_use_id":"<tool-use-6>","session_id":"<session-3>","agent_id":"<agent-1>","agent_type":"doc-reviewer","command":"git diff docs/a.md","cwd":"<scratch>/project","interrupted":false,"responseDigest":"f8770735e00cf9d10b07d43d91379c17ab2ca5444610dd3d5944b52912d13369"}}
23
+ {"run":"2b","source":"journal-record","event":"PreToolUse","tool_use_id":"<tool-use-7>","agent_id":"<agent-1>","record":{"captureId":"<capture-10>","event":"PreToolUse","tool_use_id":"<tool-use-7>","session_id":"<session-3>","agent_id":"<agent-1>","agent_type":"doc-reviewer","command":"ls docs; grep -rni retr . --include=*.md","cwd":"<scratch>/project","responseDigest":"eb045d78d273107348b0300c01d29b7552d622abbc6faf81b3ec55359aa9950c"}}
24
+ {"run":"2b","source":"journal-record","event":"PostToolUse","tool_use_id":"<tool-use-7>","agent_id":"<agent-1>","interrupted":false,"record":{"captureId":"<capture-11>","event":"PostToolUse","tool_use_id":"<tool-use-7>","session_id":"<session-3>","agent_id":"<agent-1>","agent_type":"doc-reviewer","command":"ls docs; grep -rni retr . --include=*.md","cwd":"<scratch>/project","interrupted":false,"responseDigest":"4a1d1e23f6b3c88bbc86f5960e2492acc61d65201a9cea4863610c068beb907c"}}
25
+ {"run":"2b","source":"journal-record","event":"SubagentStop","agent_id":"<agent-1>","record":{"captureId":"<capture-12>","event":"SubagentStop","session_id":"<session-3>","agent_id":"<agent-1>","agent_type":"doc-reviewer","cwd":"<scratch>/project","reviewVerdict":"approve","reviewSummary":"Summary: The only change to `docs/a.md` is \"retries once\" becoming \"retries twice\". The front matter is intact, and the link to `b.md` resolves because that file exists. Nothing else in the repo mentions retries, so I found no contradiction. I could not check the \"twice\" claim against the code, since this repo has no code or other source for it. I also couldn't compute a SHA-256, because the shell blocked `shasum`.\n\n\nReviewed artifact digest: `<scratch>/project/docs/a.md`, the working-tree version, uncommitted on top of baseline 8648a71. The identifying content is the diff hunk `@@ -3,4 +3,4 @@`, with the blob change `0936062..3bea475`. The body line is \"The checkout retries twice. See [b](b.md).\"","responseDigest":"eb045d78d273107348b0300c01d29b7552d622abbc6faf81b3ec55359aa9950c"}}
26
+ {"run":"2b","source":"journal-record","event":"PostToolUse","tool_use_id":"<tool-use-4>","agent_id":"<agent-1>","record":{"captureId":"<capture-13>","event":"PostToolUse","tool_use_id":"<tool-use-4>","session_id":"<session-3>","agent_id":"<agent-1>","cwd":"<scratch>/project","responseDigest":"51be05fc9c852f14829a9e01ccd0b2d9d743e0d16c237de69db752d100fad0dc"}}
@@ -111,10 +111,157 @@ function collect(context) {
111
111
  return deriveDocObligations(facts).filter(item => item.strength === 'MUST' && item.applicability !== 'N/A');
112
112
  }
113
113
 
114
+ // S2b credit chain (F4): produce the snapshot, accept the origin-provider events, and check them against the expected
115
+ // MUSTs. Credit only when the core marks the evidence eligible. Production has no qualification, so it stays withheld.
116
+ function creditChain(context, set, dependencies = {}) {
117
+ if (require('./doc-reconciliation-provider').readCaptureView(context.sessionDir).tampered) {
118
+ return { eligible: false, diagnostics: [{ code: 'journal-tampered', message: 'the capture journal no longer matches its immutable event records' }] };
119
+ }
120
+ const { produceDocSnapshot } = require('./doc-reconciliation-producer');
121
+ const { acceptDocEvents } = require('./doc-reconciliation-events');
122
+ const { checkAcceptedDocEvidence } = require('./doc-reconciliation-core');
123
+ const baseline = context.workflow.docReconciliationBaseline;
124
+ const requirements = Array.isArray(set?.requirements) ? set.requirements : [];
125
+ const taskId = `task-${requirements.map(item => item.id).join('+') || 'none'}`;
126
+ const sessionId = context.sessionId;
127
+ const { createClaudeOriginProvider, journalExecutors } = require('./doc-reconciliation-provider');
128
+ const captures = readCaptures(context);
129
+ const originProvider = dependencies.originProvider || createClaudeOriginProvider({
130
+ hostId: 'claude-code',
131
+ sessionId,
132
+ workspaceRoot: context.root,
133
+ workflowId: context.workflow.workflowId,
134
+ workflowRevision: context.workflow.revision,
135
+ taskId,
136
+ principals: { author: sessionId, executors: journalExecutors(captures, sessionId) },
137
+ baseline,
138
+ captures,
139
+ });
140
+ const produced = produceDocSnapshot({
141
+ workspaceRoot: context.root,
142
+ baseline,
143
+ workflow: { workflowId: context.workflow.workflowId, sessionId, workflowRevision: context.workflow.revision },
144
+ task: { taskId, requirements },
145
+ scope: { prdRoot: 'docs/prd/' },
146
+ activeHost: 'claude-code',
147
+ prdRoot: 'docs/prd/',
148
+ }, { originProvider });
149
+ if (produced.state !== 'ready' || !produced.snapshot) return { eligible: false, diagnostics: produced.diagnostics, unsatisfied: [] };
150
+ const eventRefs = captureReferences(context);
151
+ const accepted = acceptDocEvents({ snapshot: produced.snapshot, eventRefs }, { originProvider });
152
+ const checked = checkAcceptedDocEvidence({ snapshot: produced.snapshot, accepted, mode: 'verification' });
153
+ // Credit covers only the expected MUSTs that were both accepted and satisfied (passing, observed). N/A never counts.
154
+ const passing = new Set((checked.results || []).filter(r => r.observation === 'observed' && r.outcome === 'pass').map(r => r.obligationId));
155
+ const expected = new Set(checked.checkedMustIds || []);
156
+ const satisfied = produced.snapshot.obligations.filter(o => o.strength === 'MUST' && o.applicability !== 'N/A' && expected.has(o.id) && passing.has(o.id));
157
+ return {
158
+ eligible: checked.eligible === true,
159
+ capabilities: checked.eligible === true ? [...new Set(satisfied.map(o => o.capability))] : [],
160
+ satisfiedObligationIds: satisfied.map(o => o.id),
161
+ unsatisfied: checked.unsatisfied || [],
162
+ acceptedRefs: Object.keys(accepted.acceptedIndex || {}),
163
+ diagnostics: [...(accepted.diagnostics || []), ...(checked.diagnostics || [])],
164
+ rejected: accepted.rejected || [],
165
+ };
166
+ }
167
+
168
+ // Report diagnostics are objects with the obligation they concern (F12); a global fault has obligationId null.
169
+ function diagnosticOf(item) {
170
+ if (item && typeof item === 'object') return { ...item, code: item.code || 'unknown', obligationId: item.obligationId ?? null };
171
+ return { code: String(item), obligationId: null };
172
+ }
173
+
174
+ // Review-mode refusal (F4 I2a): with the pilot ON in a supported (non-Fable) workflow, a review step PASS needs an
175
+ // independent quality-review record. The provider's own independence rules decide it: a host agent_id that differs from
176
+ // the author session, an explicit approve verdict, a non-empty summary and the bound final-artifact digest.
177
+ function reviewRequired(set) {
178
+ return enabled() && set?.stepBindingsEnabled === true && !String(set?.strategy || '').startsWith('fable-');
179
+ }
180
+
181
+ // The review step is independent of credit qualification: the step records host-observed independence, while document
182
+ // credit itself still needs the validated qualification (assess).
183
+ function independentReviewAccepted(context) {
184
+ const { reviewRecordIndependent, journalExecutors } = require('./doc-reconciliation-provider');
185
+ const view = require('./doc-reconciliation-provider').readCaptureView(context.sessionDir);
186
+ if (view.tampered) return false;
187
+ const captures = view.records;
188
+ const principals = { author: context.sessionId, executors: journalExecutors(captures, context.sessionId) };
189
+ const pin = context.workflow.docReconciliationBaseline;
190
+ return captures.some(item => recordOf(item) && item.session_id === context.sessionId
191
+ && recordOf(item.observedIdentity) && item.observedIdentity.workflowId === context.workflow.workflowId
192
+ && item.observedIdentity.workflowRevision === context.workflow.revision
193
+ && (!pin || (recordOf(item.observedIdentity.baselinePin) && item.observedIdentity.baselinePin.commit === pin.commit))
194
+ && reviewRecordIndependent(item, captures, principals));
195
+ }
196
+
197
+ function recordOf(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
198
+
199
+ // The session capture records are the only host-event source. A missing journal means no events; a journal that no longer
200
+ // equals its immutable event files is tampered and yields no events, so the chain withholds.
201
+ function readCaptures(context) {
202
+ return require('./doc-reconciliation-provider').readCaptureView(context.sessionDir).records;
203
+ }
204
+
205
+ // Only explicit PostToolUse doc-check candidates are offered; ordinary Edit or Agent results are not (R3-01).
206
+ const docCheckCandidate = item => item.event === 'PostToolUse'
207
+ && require('./doc-reconciliation-provider').resolveDocCheckInvocation(item.command, item.cwd).kind !== 'not-candidate';
208
+
209
+ function captureReferences(context) {
210
+ const captures = readCaptures(context);
211
+ const candidates = captures
212
+ .filter(item => item && typeof item.ref === 'string' && (item.event === 'SubagentStop' || docCheckCandidate(item)))
213
+ .filter(item => item.event !== 'SubagentStop');
214
+ const reviews = captures.filter(item => item && typeof item.ref === 'string' && item.event === 'SubagentStop');
215
+ const references = [];
216
+ for (const [index, capability] of ['doc-lint', 'link-check'].entries()) {
217
+ const ordered = index ? candidates.slice(1).concat(candidates.slice(0, 1)) : candidates;
218
+ references.push(...ordered.map(item => `${item.ref}#${capability}`));
219
+ }
220
+ // The adapter accepts one event per capability; rotating candidate order lets separate valid runs fill each capability.
221
+ return [...references, ...reviews.map(item => `${item.ref}#quality-review`)];
222
+ }
223
+
224
+ // R3-02: verification recorded before an installed-runtime change is withdrawn once the live doc-check leaves the pin.
225
+ // Capture time sees the pristine runtime, so only assessment can catch a later change. Scoped to sessions that ran a doc-check.
226
+ function withdrawChangedRuntimeVerification(context) {
227
+ try {
228
+ const provider = require('./doc-reconciliation-provider');
229
+ const installed = provider.installedDocCheck();
230
+ if (!installed || provider.fileDigest(installed) === provider.SHIPPED_DOC_CHECK_DIGEST) return;
231
+ if (!readCaptures(context).some(item => {
232
+ if (!item || item.event !== 'PostToolUse') return false;
233
+ const invocation = provider.resolveDocCheckInvocation(item.command, item.cwd);
234
+ return invocation.kind === 'accepted' || invocation.reason === 'implementation-pin-mismatch';
235
+ })) return;
236
+ const file = path.join(context.sessionDir, 'handoff-state.json');
237
+ const handoff = JSON.parse(fs.readFileSync(file, 'utf8'));
238
+ const genericAt = handoff.lastGenericVerifyAt;
239
+ const hasGeneric = typeof genericAt === 'number' && Number.isFinite(genericAt);
240
+ const hasDoc = typeof handoff.lastDocVerifyAt === 'number' && Number.isFinite(handoff.lastDocVerifyAt);
241
+ if (!hasGeneric && !hasDoc && handoff.lastVerifyAt === undefined) return;
242
+ delete handoff.lastDocVerifyAt;
243
+ delete handoff.lastDocVerifyExitCode;
244
+ if (hasGeneric) {
245
+ handoff.lastVerifyAt = genericAt;
246
+ if (Object.prototype.hasOwnProperty.call(handoff, 'lastGenericVerifyExitCode')) {
247
+ handoff.lastVerifyExitCode = handoff.lastGenericVerifyExitCode;
248
+ } else {
249
+ delete handoff.lastVerifyExitCode;
250
+ }
251
+ } else {
252
+ // Older aggregate-only state has unknown ownership; do not preserve it as unaffected evidence.
253
+ delete handoff.lastVerifyAt;
254
+ delete handoff.lastVerifyExitCode;
255
+ }
256
+ atomicWriteJson(file, handoff);
257
+ } catch { /* fail-open: no handoff state yet, or nothing to withdraw */ }
258
+ }
259
+
114
260
  // Assessment is deliberately negative-credit only in S1. Unsupported topologies never enter
115
261
  // generic step reconciliation, and failures (including report persistence) never escape to Stop.
116
- function assess(context, set) {
262
+ function assess(context, set, dependencies = {}) {
117
263
  if (!enabled()) return { withhold: false };
264
+ withdrawChangedRuntimeVerification(context);
118
265
  const report = {
119
266
  schemaVersion: 1, reportOrigin: 'runtime', pilotStatus: 'unavailable', pilotCredit: false,
120
267
  identity: null, expectedMustIds: [], results: [], unsatisfied: [], diagnostics: [],
@@ -133,6 +280,27 @@ function assess(context, set) {
133
280
  reasonCode: item.applicability === 'unknown' ? 'unknown-applicability' : 'producer-unavailable',
134
281
  }));
135
282
  if (!withhold) report.diagnostics.push('trusted-non-document-scope');
283
+ if (withhold) {
284
+ let chain;
285
+ try { chain = creditChain(context, set, dependencies); }
286
+ catch (error) { chain = { eligible: false, diagnostics: [{ code: 'collector-unavailable', message: String(error.message) }] }; }
287
+ // The unsatisfied list is rewritten from the chain: a MUST that the chain did not satisfy stays listed (F3).
288
+ const satisfied = new Set(chain.satisfiedObligationIds || []);
289
+ report.unsatisfied = [
290
+ ...musts.filter(item => !satisfied.has(item.id)).map(item => ({
291
+ obligationId: item.id, state: item.applicability === 'unknown' ? 'unknown' : 'pending',
292
+ reasonCode: item.applicability === 'unknown' ? 'unknown-applicability' : 'producer-unavailable',
293
+ })),
294
+ ...(chain.unsatisfied || []),
295
+ ];
296
+ report.creditedCapabilities = chain.eligible === true ? chain.capabilities : [];
297
+ report.acceptedRefs = chain.acceptedRefs || [];
298
+ report.diagnostics.push(...(chain.diagnostics || []));
299
+ // Credit needs an empty unsatisfied list across both sources and at least one satisfied capability.
300
+ withhold = !(chain.eligible === true && report.unsatisfied.length === 0 && report.creditedCapabilities.length > 0);
301
+ report.pilotCredit = !withhold;
302
+ report.pilotStatus = report.pilotCredit ? 'credited' : 'unresolved';
303
+ }
136
304
  } catch (error) {
137
305
  withhold = true;
138
306
  const reason = ['baseline-unavailable', 'config-unavailable', 'scope-unavailable', 'config-scope-changed'].includes(error.message)
@@ -141,6 +309,7 @@ function assess(context, set) {
141
309
  report.unsatisfied.push({ obligationId: 'doc-change:scope', state: 'unavailable', reasonCode: reason });
142
310
  }
143
311
  }
312
+ report.diagnostics = report.diagnostics.map(diagnosticOf);
144
313
  try { atomicWriteJson(path.join(context.sessionDir, 'doc-reconciliation-pilot.json'), report); }
145
314
  catch {
146
315
  if (supported) withhold = true;
@@ -149,4 +318,4 @@ function assess(context, set) {
149
318
  return { withhold, report };
150
319
  }
151
320
 
152
- module.exports = { enabled, captureBaseline, assess };
321
+ module.exports = { enabled, captureBaseline, assess, reviewRequired, independentReviewAccepted };