harness-everything 0.44.0 → 0.46.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 (46) hide show
  1. package/.agents/harness-everything/project-docs.json +3 -1
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/CHANGELOG.md +27 -0
  5. package/ci/fixtures/doc-obligations/read-boundary/boundary-policy.json +2 -2
  6. package/ci/fixtures/doc-obligations/reconciliation-s2a/harness.js +19 -1
  7. package/ci/mechanism-2ax-doc-lifecycle.test.js +2 -1
  8. package/ci/mechanism-325-3b-s2b-capture.test.js +6 -1
  9. package/ci/mechanism-325-f2-doc-craft.test.js +2 -2
  10. package/ci/mechanism-325-f3-derived-views.test.js +5 -2
  11. package/ci/mechanism-325-f4-projections.test.js +11 -1
  12. package/ci/mechanism-pr-a-conflict-check.test.js +507 -0
  13. package/ci/mechanism-prb-outline-review.test.js +1265 -0
  14. package/docs/adr/DOCS-001-project-docs-config.md +32 -0
  15. package/docs/adr/DOCS-002-adr-identity-supersession-index.md +34 -0
  16. package/docs/adr/DOCS-003-prd-revision-contract.md +29 -0
  17. package/docs/adr/DOCS-004-doc-change-step-type.md +28 -0
  18. package/docs/adr/DOCS-005-doc-obligations-evidence-producer.md +29 -0
  19. package/docs/adr/DOCS-006-reconciliation-pilot.md +50 -0
  20. package/docs/adr/DOCS-007-shared-doc-craft-derived-views.md +31 -0
  21. package/docs/adr/DOCS-008-obligation-abstraction-bounded-consumer.md +30 -0
  22. package/docs/doc-workflow-reconciliation.md +32 -1
  23. package/docs/governed-index.md +8 -0
  24. package/docs/platform-capabilities.md +6 -0
  25. package/harness-everything/SKILL.md +1 -1
  26. package/harness-everything/scripts/doc-check.js +84 -6
  27. package/harness-everything/scripts/doc-evidence.js +55 -0
  28. package/harness-everything/scripts/doc-lifecycle.js +51 -6
  29. package/hooks/hooks.json +1 -1
  30. package/hooks/scripts/lib/doc-reconciliation-events.js +138 -9
  31. package/hooks/scripts/lib/doc-reconciliation-pilot.js +131 -15
  32. package/hooks/scripts/lib/doc-reconciliation-provider.js +941 -13
  33. package/hooks/scripts/state-persist.js +10 -0
  34. package/opencode-plugin/plugin.json +1 -1
  35. package/package.json +1 -1
  36. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  37. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-events.js +138 -9
  38. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-pilot.js +131 -15
  39. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-provider.js +941 -13
  40. package/plugins/harness-everything/hooks/scripts/state-persist.js +10 -0
  41. package/plugins/harness-everything/plugin.json +1 -1
  42. package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
  43. package/plugins/harness-everything/skills/harness-everything/scripts/doc-check.js +84 -6
  44. package/plugins/harness-everything/skills/harness-everything/scripts/doc-evidence.js +55 -0
  45. package/plugins/harness-everything/skills/harness-everything/scripts/doc-lifecycle.js +51 -6
  46. package/sbom.cdx.json +7 -7
@@ -265,7 +265,16 @@ function directoryCanContainMatch(relativeDirectory, globs, exactPaths = []) {
265
265
  || exactPathCanMatchDescendant(relativeDirectory, exactPaths);
266
266
  }
267
267
 
268
- function collectDocs(root, config) {
268
+ function documentCorpusPaths(records, config) {
269
+ const selectors = [
270
+ ...(Array.isArray(config && config.governed) ? config.governed.map(entry => entry && entry.path) : []),
271
+ ...(Array.isArray(config && config.legacy) ? config.legacy : [])
272
+ ].filter(value => typeof value === 'string');
273
+ return [...new Set((records || []).filter(item => item && item.presence === 'present' && item.type === 'file'
274
+ && typeof item.path === 'string' && matchesAny(item.path, selectors)).map(item => item.path))].sort(compareText);
275
+ }
276
+
277
+ function collectDocs(root, config, coverage = null) {
269
278
  const workspace = path.resolve(root);
270
279
  const governed = Array.isArray(config && config.governed) ? config.governed : [];
271
280
  const legacyGlobs = Array.isArray(config && config.legacy) ? config.legacy : [];
@@ -274,9 +283,14 @@ function collectDocs(root, config) {
274
283
  ...legacyGlobs
275
284
  ].filter(value => typeof value === 'string');
276
285
  const files = [];
286
+ const skipped = [];
287
+ const skip = (relative, reason) => skipped.push({ path: relative || '.', reason });
277
288
  function walk(directory, relativeDirectory) {
278
289
  let entries;
279
- try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { return; }
290
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch {
291
+ skip(relativeDirectory, 'unreadable-directory');
292
+ return;
293
+ }
280
294
  entries.sort((a, b) => compareText(a.name, b.name));
281
295
  for (const entry of entries) {
282
296
  if (entry.name === '.git' || entry.name === 'node_modules') continue;
@@ -285,6 +299,11 @@ function collectDocs(root, config) {
285
299
  if (entry.isDirectory()) {
286
300
  if (directoryCanContainMatch(relativePath, globs)) walk(absolute, relativePath);
287
301
  } else if (entry.isFile() && matchesAny(relativePath, globs)) files.push(absolute);
302
+ else if (entry.isSymbolicLink() && (matchesAny(relativePath, globs) || directoryCanContainMatch(relativePath, globs))) {
303
+ skip(relativePath, 'unsupported-symlink');
304
+ } else if (!entry.isDirectory() && !entry.isFile() && matchesAny(relativePath, globs)) {
305
+ skip(relativePath, 'unsupported-entry');
306
+ }
288
307
  }
289
308
  }
290
309
  walk(workspace, '');
@@ -296,9 +315,23 @@ function collectDocs(root, config) {
296
315
  const legacy = matchesAny(relativePath, legacyGlobs);
297
316
  if (!roleEntry && !legacy) continue;
298
317
  let bytes;
299
- try { bytes = fs.readFileSync(absolute); } catch { continue; }
318
+ try { bytes = fs.readFileSync(absolute); } catch {
319
+ skip(relativePath, 'unreadable-file');
320
+ continue;
321
+ }
300
322
  selected.push({ relativePath, role: roleEntry ? roleEntry.role : null, legacy, bytes });
301
323
  }
324
+ for (const selector of globs.filter(value => !/[*?\[\]{}]/.test(value))) {
325
+ if (!files.includes(path.resolve(workspace, selector))) skip(selector, 'missing-configured-path');
326
+ }
327
+ if (coverage) {
328
+ coverage.complete = globs.length > 0 && skipped.length === 0;
329
+ coverage.skipped = skipped;
330
+ coverage.paths = selected.map(item => item.relativePath).sort(compareText);
331
+ coverage.digests = Object.fromEntries(selected
332
+ .sort((a, b) => compareText(a.relativePath, b.relativePath))
333
+ .map(item => [item.relativePath, crypto.createHash('sha256').update(item.bytes).digest('hex')]));
334
+ }
302
335
  const rules = readWorkingTreeRules(workspace, selected.filter(item => item.bytes.includes(0x0d)).map(item => item.relativePath));
303
336
  const docs = [];
304
337
  for (const item of selected) {
@@ -2664,7 +2697,11 @@ function main(argv = process.argv.slice(2)) {
2664
2697
  ? path.resolve(options.configFile)
2665
2698
  : path.resolve(workspacePath, options.configFile);
2666
2699
  let config;
2667
- try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch (error) {
2700
+ let configBytes;
2701
+ try {
2702
+ configBytes = fs.readFileSync(configPath);
2703
+ config = JSON.parse(configBytes.toString('utf8'));
2704
+ } catch (error) {
2668
2705
  process.stderr.write(`cannot read config: ${error.message}\n`);
2669
2706
  return 2;
2670
2707
  }
@@ -2672,7 +2709,14 @@ function main(argv = process.argv.slice(2)) {
2672
2709
  process.stderr.write(`${error.message}\n`);
2673
2710
  return 2;
2674
2711
  }
2675
- const docs = collectDocs(workspacePath, config);
2712
+ const corpus = options.all ? { complete: false, skipped: [], paths: [], digests: {} } : null;
2713
+ const docs = collectDocs(workspacePath, config, corpus);
2714
+ if (corpus) {
2715
+ const relativeConfig = path.relative(workspacePath, configPath);
2716
+ corpus.config = relativeConfig && relativeConfig !== '..' && !relativeConfig.startsWith(`..${path.sep}`)
2717
+ ? { path: relativeConfig.split(path.sep).join('/'), digest: crypto.createHash('sha256').update(configBytes).digest('hex') }
2718
+ : null;
2719
+ }
2676
2720
  const mode = {
2677
2721
  ...(options.all ? { all: true } : { changedPaths: options.changedPaths }),
2678
2722
  workspace: workspacePath, base: options.base, configPath,
@@ -2688,10 +2732,11 @@ function main(argv = process.argv.slice(2)) {
2688
2732
  }));
2689
2733
  return 0;
2690
2734
  }
2735
+ if (corpus) analysis.corpus = corpus;
2691
2736
  process.stdout.write(`${JSON.stringify(analysis, null, 2)}\n`);
2692
2737
  return analysis.diagnostics.some(item => item.severity === 'error') ? 1 : 0;
2693
2738
  }
2694
2739
 
2695
- module.exports = { readGitBaseInventory, scanWorkspaceFiles, parseFrontmatter, parseRequirementBlocks, collectDocs, analyzeDocs, resolveRef, lintDocs, buildIndex, diffRevisions, buildGitDocumentInventory };
2740
+ module.exports = { readGitBaseInventory, scanWorkspaceFiles, parseFrontmatter, parseRequirementBlocks, collectDocs, documentCorpusPaths, analyzeDocs, resolveRef, lintDocs, buildIndex, diffRevisions, buildGitDocumentInventory };
2696
2741
 
2697
2742
  if (require.main === module) process.exitCode = main();
package/hooks/hooks.json CHANGED
@@ -37,7 +37,7 @@
37
37
  ],
38
38
  "PreToolUse": [
39
39
  {
40
- "matcher": "Bash|PowerShell|Task|Agent",
40
+ "matcher": "Bash|PowerShell|Task|Agent|Write|Edit|apply_patch",
41
41
  "hooks": [
42
42
  {
43
43
  "type": "command",
@@ -1,15 +1,16 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
- const path = require('path');
4
+ const { execFileSync } = require('child_process');
5
5
  const { _internals: U } = require('./doc-reconciliation-core');
6
+ const { key } = require('./workflow-isolation');
6
7
 
7
8
  // Directory identity as in the producer: native realpath (Windows 8.3 names), case-insensitive on Windows.
8
9
  function samePath(a, b) {
9
10
  try {
10
- const left = fs.realpathSync.native(path.resolve(a));
11
- const right = fs.realpathSync.native(path.resolve(b));
12
- return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
11
+ fs.statSync(a);
12
+ fs.statSync(b);
13
+ return key(a) === key(b);
13
14
  } catch { return false; }
14
15
  }
15
16
 
@@ -19,6 +20,109 @@ function artifactsFor(records) {
19
20
  function prdDigest(artifacts) {
20
21
  return U.sha(JSON.stringify(Object.keys(artifacts).filter(p => p.startsWith('docs/prd/')).sort().map(p => [p, artifacts[p].digest])));
21
22
  }
23
+
24
+ function git(root, args, binary = false) {
25
+ const env = { ...process.env };
26
+ for (const key of Object.keys(env)) if (key.startsWith('GIT_')) delete env[key];
27
+ return execFileSync('git', ['-C', root, ...args], { env, encoding: binary ? null : 'utf8',
28
+ timeout: 10000, maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
29
+ }
30
+
31
+ function shaBytes(value) { return U.sha(Buffer.from(value)); }
32
+
33
+ function outlineEventValid({ event, snapshot, paths, artifacts, baselineArtifacts } = {}) {
34
+ let stage = 'envelope';
35
+ try {
36
+ const evidence = event?.outlineEvidence;
37
+ const root = snapshot.originContext.workspaceRoot;
38
+ const baseline = snapshot.originContext.baseline;
39
+ if (snapshot.originContext.activeHost !== 'claude-code' || !U.record(evidence)
40
+ || evidence.schema !== 'harness.outline-confirmation.v1' || evidence.state !== 'validated'
41
+ || evidence.baselineCommit !== baseline?.commit || evidence.taskId !== snapshot.identity.taskId
42
+ || !/^[0-9a-f]{40,64}$/.test(String(evidence.outlineCommit || ''))
43
+ || !/^[0-9a-f]{40,64}$/.test(String(evidence.draftCommit || ''))
44
+ || !/^[0-9a-f]{40,64}$/.test(String(evidence.mergedCommit || ''))
45
+ || !/^[0-9a-f]{64}$/.test(String(evidence.handoffDigest || ''))
46
+ || !/^[0-9a-f]{64}$/.test(String(evidence.reviewCaptureDigest || ''))
47
+ || !/^[0-9a-f]{64}$/.test(String(evidence.reviewDigest || ''))
48
+ || !/^[0-9a-f]{64}$/.test(String(evidence.seamDigest || ''))
49
+ || !/^[0-9a-f]{64}$/.test(String(evidence.dispatchInputDigest || ''))
50
+ || !/^[0-9a-f]{64}$/.test(String(evidence.writerLeaseDigest || ''))
51
+ || !/^[0-9a-f]{64}$/.test(String(evidence.inventoryPinDigest || ''))
52
+ || !/^[0-9a-f]{64}$/.test(String(evidence.inventoryArtifactDigest || ''))
53
+ || !Array.isArray(evidence.targetPaths) || U.stable(evidence.targetPaths) !== U.stable(paths)
54
+ || !U.record(evidence.outlineArtifactDigests) || !U.record(evidence.draftArtifactDigests)
55
+ || !U.record(evidence.outlineBlobOids) || !U.record(evidence.draftBlobOids)
56
+ || U.stable(Object.keys(evidence.outlineArtifactDigests).sort()) !== U.stable(paths)
57
+ || U.stable(Object.keys(evidence.draftArtifactDigests).sort()) !== U.stable(paths)
58
+ || U.stable(Object.keys(evidence.outlineBlobOids).sort()) !== U.stable(paths)
59
+ || U.stable(Object.keys(evidence.draftBlobOids).sort()) !== U.stable(paths)
60
+ || !U.record(event.draftArtifactDigests) || U.stable(Object.keys(event.draftArtifactDigests).sort()) !== U.stable(paths)
61
+ || U.stable(event.draftArtifactDigests) !== U.stable(evidence.draftArtifactDigests)
62
+ || !U.record(event.reviewedArtifactDigests)
63
+ || !paths.every(p => /^[0-9a-f]{64}$/.test(String(evidence.outlineArtifactDigests[p] || ''))
64
+ && /^[0-9a-f]{64}$/.test(String(evidence.draftArtifactDigests[p] || ''))
65
+ && /^[0-9a-f]{40,64}$/.test(String(evidence.outlineBlobOids[p] || ''))
66
+ && /^[0-9a-f]{40,64}$/.test(String(evidence.draftBlobOids[p] || ''))
67
+ && artifacts[p]?.digest === evidence.draftArtifactDigests[p])
68
+ || !/^[0-9a-f]{40,64}$/.test(String(evidence.baselineCommit || ''))
69
+ || !/^[0-9a-f]{40,64}$/.test(String(evidence.outlineTree || ''))
70
+ || !/^[0-9a-f]{40,64}$/.test(String(evidence.draftTree || ''))
71
+ || !U.text(evidence.coordinatorPrincipal) || !U.text(evidence.reviewerPrincipal) || !U.text(evidence.workerPrincipal)
72
+ || evidence.reviewerPrincipal === evidence.coordinatorPrincipal || evidence.reviewerPrincipal === evidence.workerPrincipal
73
+ || evidence.ancestry !== 'strict-descendant' || evidence.outlineCommit === evidence.draftCommit) return stage;
74
+
75
+ stage = 'reviewed-object-binding';
76
+ const pinPath = Object.keys(event.reviewedArtifactDigests).filter(p => p.startsWith('doc-reconciliation/outline-confirmation/inventory/'));
77
+ if (pinPath.length !== 1 || event.reviewedArtifactDigests[pinPath[0]] !== evidence.inventoryArtifactDigest
78
+ || Object.keys(event.reviewedArtifactDigests).length !== paths.length + 1
79
+ || !paths.every(p => event.reviewedArtifactDigests[p] === evidence.outlineArtifactDigests[p])) return stage;
80
+
81
+ stage = 'current-commit-binding';
82
+ const head = git(root, ['rev-parse', '--verify', 'HEAD^{commit}']).trim();
83
+ const tree = git(root, ['rev-parse', '--verify', 'HEAD^{tree}']).trim();
84
+ if (head !== evidence.mergedCommit || tree !== evidence.draftTree) return stage;
85
+ const verified = commit => git(root, ['rev-parse', '--verify', `${commit}^{commit}`]).trim() === commit;
86
+ if (![evidence.baselineCommit, evidence.outlineCommit, evidence.draftCommit, evidence.mergedCommit].every(verified)) return 'git-object-missing';
87
+ if (evidence.baselineCommit === evidence.outlineCommit) return 'B-to-O-ancestry';
88
+ stage = 'B-to-O-ancestry';
89
+ try { git(root, ['merge-base', '--is-ancestor', evidence.baselineCommit, evidence.outlineCommit]); }
90
+ catch { return stage; }
91
+ stage = 'O-to-D-ancestry';
92
+ try { git(root, ['merge-base', '--is-ancestor', evidence.outlineCommit, evidence.draftCommit]); }
93
+ catch { return stage; }
94
+ stage = 'merge-correspondence';
95
+ if (evidence.mergeCorrespondence === null) {
96
+ if (evidence.draftCommit !== evidence.mergedCommit) return stage;
97
+ } else {
98
+ const merge = evidence.mergeCorrespondence;
99
+ if (!U.record(merge) || !['accepted', 'changed'].includes(merge.state)
100
+ || merge.draftCommit !== evidence.draftCommit || merge.mergedCommit !== evidence.mergedCommit
101
+ || U.stable(merge.targetPaths) !== U.stable(paths) || !U.record(merge.targetBlobs)) return stage;
102
+ for (const p of paths) {
103
+ const draftOid = git(root, ['rev-parse', `${evidence.draftCommit}:${p}`]).trim();
104
+ const mergedOid = git(root, ['rev-parse', `${evidence.mergedCommit}:${p}`]).trim();
105
+ if (merge.targetBlobs[p]?.draft !== draftOid || merge.targetBlobs[p]?.merged !== mergedOid
106
+ || (merge.state === 'accepted' && draftOid !== mergedOid)
107
+ || (merge.state === 'changed' && draftOid === mergedOid)) return stage;
108
+ }
109
+ }
110
+ stage = 'target-object-binding';
111
+ for (const p of paths) {
112
+ const atBase = git(root, ['ls-tree', '-r', '--name-only', evidence.baselineCommit, '--', p]).trim().split('\n').filter(Boolean);
113
+ if (atBase.includes(p)) return 'target-existed-at-B';
114
+ if (git(root, ['rev-parse', `${evidence.outlineCommit}:${p}`]).trim() !== evidence.outlineBlobOids[p]
115
+ || git(root, ['rev-parse', `${evidence.mergedCommit}:${p}`]).trim() !== evidence.draftBlobOids[p]) return stage;
116
+ const outlineBytes = git(root, ['show', `${evidence.outlineCommit}:${p}`], true);
117
+ const draftBytes = git(root, ['show', `${evidence.mergedCommit}:${p}`], true);
118
+ if (shaBytes(outlineBytes) !== evidence.outlineArtifactDigests[p]
119
+ || shaBytes(draftBytes) !== evidence.draftArtifactDigests[p]
120
+ || shaBytes(draftBytes) !== artifacts[p]?.digest) return 'candidate-artifact-binding';
121
+ }
122
+ return true;
123
+ } catch { return 'git-verification-failed'; }
124
+ }
125
+
22
126
  function acceptDocEvents(input = {}, dependencies = {}) {
23
127
  const { snapshot, eventRefs } = U.record(input) ? input : {};
24
128
  const originProvider = dependencies?.originProvider;
@@ -91,8 +195,12 @@ function acceptDocEvents(input = {}, dependencies = {}) {
91
195
  seenCapabilities.add(event?.capability);
92
196
  const paths = obligation?.scope?.paths || [];
93
197
  if (!Array.isArray(event?.paths) || U.stable([...event.paths].sort()) !== U.stable([...paths].sort())) reject('scope-mismatch');
198
+ const outlineCapability = event?.capability === 'outline-confirmation';
94
199
  const digests = event?.kind === 'review' ? event.reviewedArtifactDigests : event?.artifactDigests;
95
- if (!U.record(digests)) reject('artifact-mismatch');
200
+ if (outlineCapability) {
201
+ const outlineValidation = outlineEventValid({ event, snapshot, paths, artifacts, baselineArtifacts });
202
+ if (outlineValidation !== true) reject(`outline-${outlineValidation || 'evidence-invalid'}`);
203
+ } else if (!U.record(digests)) reject('artifact-mismatch');
96
204
  else {
97
205
  for (const p of paths) {
98
206
  if (artifacts[p] && digests[p] !== artifacts[p].digest) reject('artifact-mismatch');
@@ -101,6 +209,23 @@ function acceptDocEvents(input = {}, dependencies = {}) {
101
209
  // Supplied extra digests must also describe this capture, never another tree.
102
210
  for (const p of Object.keys(digests)) if (!artifacts[p] || digests[p] !== artifacts[p].digest) reject('artifact-mismatch');
103
211
  }
212
+ if (event?.capability === 'conflict-check') {
213
+ const binding = event.corpusBinding;
214
+ const identity = snapshot.identity;
215
+ const expectedPaths = U.runtimeModule('doc-lifecycle.js').documentCorpusPaths(
216
+ snapshot.candidate.records, snapshot.config.resolution.config
217
+ );
218
+ if (!U.record(binding) || binding.complete !== true || !Array.isArray(binding.paths)
219
+ || U.stable(binding.paths) !== U.stable(expectedPaths) || !U.record(binding.digests)
220
+ || U.stable(Object.keys(binding.digests).sort()) !== U.stable(expectedPaths)
221
+ || !expectedPaths.every(p => artifacts[p] && binding.digests[p] === artifacts[p].digest)) reject('corpus-mismatch');
222
+ if (binding?.baselineDigest !== identity.baselineDigest || binding?.candidateDigest !== identity.candidateDigest
223
+ || binding?.configIdentityDigest !== identity.configDigest
224
+ || binding?.baseCommit !== snapshot.originContext.baseline.commit) reject('snapshot-changed');
225
+ const configSource = snapshot.config.capture.sources.find(source => source.path === binding?.config?.path);
226
+ if (!configSource || configSource.presence !== 'present'
227
+ || U.sha(U.bytes(configSource.bytes)) !== binding?.config?.digest) reject('config-capture-mismatch');
228
+ }
104
229
  if (event?.kind === 'review') {
105
230
  if (event.verdict !== 'approve' || !U.text(event.summary)) reject('event-invalid');
106
231
  const principals = context?.principals;
@@ -118,10 +243,13 @@ function acceptDocEvents(input = {}, dependencies = {}) {
118
243
  if (!context?.principals?.executors?.includes(event?.principalId)) reject('origin-untrusted');
119
244
  }
120
245
  if (problems.length) { rejected.push(...problems); continue; }
121
- const adapted = { id: reference, obligationId, kind: event.kind === 'review' ? 'review' : 'execution',
246
+ const adapted = { id: reference, obligationId,
247
+ kind: outlineCapability ? 'outline-confirmation' : event.kind === 'review' ? 'review' : 'execution',
122
248
  paths: [...paths], provenance: event.provenance, baseDigest: snapshot.identity.baselineDigest,
123
- artifactDigests: U.clone(digests), ...(event.baselineArtifacts ? { baselineArtifacts: U.clone(event.baselineArtifacts) } : {}) };
124
- if (event.kind === 'review') Object.assign(adapted, { verdict: event.verdict, summary: event.summary });
249
+ artifactDigests: U.clone(outlineCapability ? event.draftArtifactDigests : digests),
250
+ ...(event.baselineArtifacts ? { baselineArtifacts: U.clone(event.baselineArtifacts) } : {}) };
251
+ if (outlineCapability) Object.assign(adapted, { outlineEvidence: U.clone(event.outlineEvidence) });
252
+ else if (event.kind === 'review') Object.assign(adapted, { verdict: event.verdict, summary: event.summary });
125
253
  else Object.assign(adapted, { command: event.command, exitCode: event.exitCode });
126
254
  if (event.capability === 'conflict-check') {
127
255
  adapted.candidatePrdSet = { digest: prdDigest(artifacts), complete: true, paths: Object.keys(artifacts).filter(p => p.startsWith('docs/prd/')).sort() };
@@ -129,7 +257,8 @@ function acceptDocEvents(input = {}, dependencies = {}) {
129
257
  (event.kind === 'review' ? checkerInputs.reviewRecords : checkerInputs.executionRecords).push(adapted);
130
258
  acceptedIndex[reference] = { reference, eventId: event.eventId, obligationId, capability: event.capability,
131
259
  paths: [...paths], identity: U.clone(event.identity), originRef: response.originRef, artifactBinding: snapshot.identity.candidateDigest,
132
- artifactDigests: U.clone(digests), event };
260
+ artifactDigests: U.clone(outlineCapability ? event.draftArtifactDigests : digests),
261
+ ...(outlineCapability ? { reviewedArtifactDigests: U.clone(event.reviewedArtifactDigests) } : {}), event };
133
262
  }
134
263
  if (diagnostics.length || rejected.length) out.state = 'unresolved';
135
264
  return out;
@@ -2,10 +2,13 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const crypto = require('crypto');
5
6
  const { execFileSync } = require('child_process');
6
7
  const { atomicWriteJson } = require('./fable-contracts');
8
+ const { canonical, key } = require('./workflow-isolation');
7
9
 
8
10
  const enabled = () => process.env.HARNESS_DOC_RECONCILIATION_PILOT === '1';
11
+ const text = value => typeof value === 'string' && value.trim().length > 0;
9
12
 
10
13
  function git(root, args) {
11
14
  const env = { ...process.env };
@@ -29,11 +32,40 @@ function runtimeModule(name) {
29
32
 
30
33
  // Only capture at plan/start. A later credit assessment must never advance the baseline to HEAD.
31
34
  function captureBaseline(context) {
32
- if (!enabled() || context.workflow.docReconciliationBaseline) return;
35
+ if (!enabled()) return;
33
36
  try {
34
- const root = git(context.root, ['rev-parse', '--show-toplevel']).trim();
35
- const commit = git(root, ['rev-parse', '--verify', 'HEAD^{commit}']).trim();
36
- context.workflow.docReconciliationBaseline = { root, commit };
37
+ if (!context.workflow.docReconciliationBaseline) {
38
+ const root = canonical(git(context.root, ['rev-parse', '--show-toplevel']).trim());
39
+ const commit = git(root, ['rev-parse', '--verify', 'HEAD^{commit}']).trim();
40
+ const tree = git(root, ['rev-parse', '--verify', `${commit}^{tree}`]).trim();
41
+ context.workflow.docReconciliationBaseline = { root, commit, tree };
42
+ } else if (!context.workflow.docReconciliationBaseline.tree) {
43
+ const pin = context.workflow.docReconciliationBaseline;
44
+ pin.tree = git(pin.root, ['rev-parse', '--verify', `${pin.commit}^{tree}`]).trim();
45
+ }
46
+ if (!context.workflow.docReconciliationTask) {
47
+ const plan = context.workflow.pendingPlan || context.workflow.workflowPlan;
48
+ const loaded = plan && runtimeModule('workflow-obligations').loadObligationSet(context, plan);
49
+ const requirementIds = loaded?.set?.requirements?.map(item => item.id);
50
+ if (Array.isArray(requirementIds) && requirementIds.length && requirementIds.every(id => typeof id === 'string' && id)) {
51
+ const baseline = context.workflow.docReconciliationBaseline;
52
+ const resolver = runtimeModule('project-docs-resolver');
53
+ const configSources = configSourcePaths(resolver).map(relative => {
54
+ const bytes = bytesAtBaseline(baseline.root, baseline.commit, relative);
55
+ return [relative, bytes === null ? null : crypto.createHash('sha256').update(bytes).digest('hex')];
56
+ });
57
+ const hash = value => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
58
+ const workspaceId = 'ws-' + hash({ workspaceRoot: canonical(baseline.root) });
59
+ context.workflow.docReconciliationTask = {
60
+ schema: 'harness.doc-reconciliation-task-pin.v1',
61
+ taskId: `task-${requirementIds.join('+')}`, requirementIds,
62
+ requirementDigest: hash(requirementIds), sessionId: context.sessionId,
63
+ workflowId: context.workflow.workflowId, workspaceId,
64
+ baselineCommit: baseline.commit, baselineRoot: baseline.root,
65
+ configDigest: hash(configSources),
66
+ };
67
+ }
68
+ }
37
69
  } catch {
38
70
  // Absence is intentionally retained; credit assessment will fail closed.
39
71
  }
@@ -80,8 +112,9 @@ function assertConfigScopeUnchanged(root, baseline, resolver) {
80
112
  function collect(context) {
81
113
  const baseline = context.workflow.docReconciliationBaseline;
82
114
  if (!baseline) throw new Error('baseline-unavailable');
83
- const root = git(context.root, ['rev-parse', '--show-toplevel']).trim();
84
- if (root !== baseline.root || !/^[0-9a-f]{40,64}$/.test(baseline.commit)) throw new Error('baseline-unavailable');
115
+ const root = canonical(git(context.root, ['rev-parse', '--show-toplevel']).trim());
116
+ if (!text(baseline.root) || key(root) !== key(baseline.root)
117
+ || !/^[0-9a-f]{40,64}$/.test(baseline.commit)) throw new Error('baseline-unavailable');
85
118
  const resolver = runtimeModule('project-docs-resolver');
86
119
  const resolved = resolver.resolveDocsConfig(root);
87
120
  if (!['project', 'host'].includes(resolved.status)) throw new Error('config-unavailable');
@@ -120,17 +153,30 @@ function creditChain(context, set, dependencies = {}) {
120
153
  : hostId === 'claude-code' ? providerModule.readCaptureView(context.sessionDir)
121
154
  : { records: [], tampered: false };
122
155
  if (captureView.tampered) {
123
- return { eligible: false, diagnostics: [{ code: 'journal-tampered', message: 'the capture journal no longer matches its immutable event records' }] };
156
+ return { eligible: false, outlineEvidence: { outcome: 'none', observation: 'unresolved', reason: 'journal-tampered' },
157
+ diagnostics: [{ code: 'journal-tampered', message: 'the capture journal no longer matches its immutable event records' }] };
124
158
  }
125
159
  if (!['claude-code', 'opencode'].includes(hostId)) {
126
- return { eligible: false, diagnostics: [{ code: 'unsupported-host', message: `no document origin provider is registered for ${String(hostId)}` }] };
160
+ return { eligible: false, outlineEvidence: { outcome: 'none', observation: 'unresolved', reason: 'unsupported-host' },
161
+ diagnostics: [{ code: 'unsupported-host', message: `no document origin provider is registered for ${String(hostId)}` }] };
127
162
  }
128
163
  const { produceDocSnapshot } = require('./doc-reconciliation-producer');
129
164
  const { acceptDocEvents } = require('./doc-reconciliation-events');
130
165
  const { checkAcceptedDocEvidence } = require('./doc-reconciliation-core');
131
166
  const baseline = context.workflow.docReconciliationBaseline;
132
167
  const requirements = Array.isArray(set?.requirements) ? set.requirements : [];
133
- const taskId = `task-${requirements.map(item => item.id).join('+') || 'none'}`;
168
+ const requirementIds = requirements.map(item => item.id);
169
+ const derivedTaskId = `task-${requirementIds.join('+') || 'none'}`;
170
+ const taskBinding = context.workflow.docReconciliationTask || null;
171
+ const taskId = taskBinding?.taskId || derivedTaskId;
172
+ if (taskBinding && (taskBinding.taskId !== derivedTaskId || taskBinding.workflowId !== context.workflow.workflowId
173
+ || taskBinding.sessionId !== context.sessionId || taskBinding.baselineCommit !== baseline?.commit
174
+ || !text(taskBinding.baselineRoot) || !text(baseline?.root)
175
+ || key(taskBinding.baselineRoot) !== key(baseline.root)
176
+ || taskBinding.requirementDigest !== crypto.createHash('sha256').update(JSON.stringify(requirementIds)).digest('hex')) ) {
177
+ return { eligible: false, outlineEvidence: { outcome: 'none', observation: 'unresolved', reason: 'task-binding-mismatch' },
178
+ diagnostics: [{ code: 'task-binding-mismatch', message: 'the requirement set or original B differs from the task pin' }] };
179
+ }
134
180
  const sessionId = context.sessionId;
135
181
  const { createClaudeOriginProvider, createOpencodeOriginProvider, journalExecutors } = providerModule;
136
182
  const captures = captureView.records;
@@ -145,6 +191,8 @@ function creditChain(context, set, dependencies = {}) {
145
191
  workflowId: context.workflow.workflowId,
146
192
  workflowRevision: context.workflow.revision,
147
193
  taskId,
194
+ sessionDir: context.sessionDir,
195
+ taskBinding,
148
196
  principals,
149
197
  baseline,
150
198
  captures,
@@ -161,6 +209,7 @@ function creditChain(context, set, dependencies = {}) {
161
209
  }, { originProvider });
162
210
  if (produced.state !== 'ready' || !produced.snapshot) return {
163
211
  eligible: false,
212
+ outlineEvidence: { outcome: 'none', observation: 'unresolved', reason: produced.diagnostics?.[0]?.code || 'snapshot-unavailable' },
164
213
  diagnostics: produced.diagnostics || [],
165
214
  unsatisfied: [],
166
215
  };
@@ -171,12 +220,25 @@ function creditChain(context, set, dependencies = {}) {
171
220
  const passing = new Set((checked.results || []).filter(r => r.observation === 'observed' && r.outcome === 'pass').map(r => r.obligationId));
172
221
  const expected = new Set(checked.checkedMustIds || []);
173
222
  const satisfied = produced.snapshot.obligations.filter(o => o.strength === 'MUST' && o.applicability !== 'N/A' && expected.has(o.id) && passing.has(o.id));
223
+ const outlineObligation = produced.snapshot.obligations.find(item => item.capability === 'outline-confirmation');
224
+ const outlineResult = outlineObligation && (checked.results || []).find(item => item.obligationId === outlineObligation.id);
225
+ const outlineRefs = Object.entries(accepted.acceptedIndex || {}).filter(([, item]) => item.capability === 'outline-confirmation').map(([ref]) => ref);
226
+ const outlineEvidence = {
227
+ status: outlineResult?.observation === 'observed' && outlineResult?.outcome === 'pass' ? 'pass' : 'unresolved',
228
+ outcome: outlineResult?.outcome || 'none',
229
+ observation: outlineResult?.observation || 'unresolved',
230
+ recordRefs: outlineResult?.recordRefs || [],
231
+ acceptedRefs: outlineRefs,
232
+ diagnostics: [...(accepted.diagnostics || []), ...(accepted.rejected || []), ...(checked.diagnostics || [])]
233
+ .filter(item => !item?.obligationId || item.obligationId === outlineObligation?.id),
234
+ };
174
235
  return {
175
236
  eligible: checked.eligible === true,
176
237
  capabilities: checked.eligible === true ? [...new Set(satisfied.map(o => o.capability))] : [],
177
238
  satisfiedObligationIds: satisfied.map(o => o.id),
178
239
  unsatisfied: checked.unsatisfied || [],
179
240
  acceptedRefs: Object.keys(accepted.acceptedIndex || {}),
241
+ outlineEvidence,
180
242
  diagnostics: [...(accepted.diagnostics || []), ...(checked.diagnostics || [])],
181
243
  rejected: accepted.rejected || [],
182
244
  };
@@ -232,6 +294,45 @@ function readCaptures(context) {
232
294
  const docCheckCandidate = item => item.event === 'PostToolUse'
233
295
  && require('./doc-reconciliation-provider').resolveDocCheckInvocation(item.command, item.cwd).kind !== 'not-candidate';
234
296
 
297
+ function outlineReviewCaptureRefs(sessionDir, taskId) {
298
+ const refs = new Set();
299
+ if (!text(taskId)) return refs;
300
+ const attempts = path.join(sessionDir, 'doc-reconciliation', 'outline-confirmation', taskId, 'attempts');
301
+ try {
302
+ for (const entry of fs.readdirSync(attempts, { withFileTypes: true })) {
303
+ if (!entry.isDirectory()) continue;
304
+ try {
305
+ const handoff = JSON.parse(fs.readFileSync(path.join(attempts, entry.name, 'handoff.json'), 'utf8'));
306
+ if (handoff.taskId === taskId && typeof handoff.reviewCaptureRef === 'string') refs.add(handoff.reviewCaptureRef);
307
+ } catch { /* malformed handoff cannot add review credit */ }
308
+ }
309
+ } catch { /* no attempts yet */ }
310
+ return refs;
311
+ }
312
+
313
+ function outlineDispatchReferences(captures, taskId, sessionDir) {
314
+ if (!text(taskId)) return [];
315
+ const candidates = (Array.isArray(captures) ? captures : []).filter(item => recordOf(item)
316
+ && item.event === 'PreToolUse' && ['Task', 'Agent'].includes(item.tool_name)
317
+ && text(item.ref) && item.outlineDispatch?.taskId === taskId && text(item.outlineDispatch.attemptId));
318
+ if (!candidates.length) return [];
319
+ if (text(sessionDir)) {
320
+ const provider = require('./doc-reconciliation-provider');
321
+ const attemptIds = [...new Set(candidates.map(item => item.outlineDispatch.attemptId))];
322
+ const resolved = attemptIds.map(attemptId => ({ attemptId,
323
+ attempt: provider._internals.resolveOutlineAttempt({ sessionDir, taskId, attemptId }) }));
324
+ const current = resolved.filter(item => item.attempt.state === 'accepted');
325
+ if (current.length === 1) {
326
+ const dispatches = candidates.filter(item => item.outlineDispatch.attemptId === current[0].attemptId);
327
+ return dispatches.length ? [`${dispatches[0].ref}#outline-confirmation`] : [];
328
+ }
329
+ if (current.length > 1 || attemptIds.length > 1) return [];
330
+ }
331
+ const tuples = [...new Set(candidates.map(item => `${item.outlineDispatch.attemptId}\0${item.tool_use_id || ''}\0${item.outlineDispatch.dispatchInputDigest || ''}`))];
332
+ if (tuples.length > 1 && !candidates.some(item => item.outlineDispatch.state === 'unresolved')) return [];
333
+ return [`${candidates[0].ref}#outline-confirmation`];
334
+ }
335
+
235
336
  function captureReferences(context) {
236
337
  const captures = readCaptures(context);
237
338
  if (activeHostFor(context) === 'opencode') {
@@ -241,12 +342,19 @@ function captureReferences(context) {
241
342
  const candidates = captures
242
343
  .filter(item => item && typeof item.ref === 'string' && (item.event === 'SubagentStop' || docCheckCandidate(item)))
243
344
  .filter(item => item.event !== 'SubagentStop');
244
- const reviews = captures.filter(item => item && typeof item.ref === 'string' && item.event === 'SubagentStop');
345
+ const taskId = context.workflow.docReconciliationTask?.taskId;
346
+ const outlineReviews = outlineReviewCaptureRefs(context.sessionDir, taskId);
347
+ const reviews = captures.filter(item => item && typeof item.ref === 'string' && item.event === 'SubagentStop'
348
+ && !outlineReviews.has(item.ref));
245
349
  const references = [];
246
350
  for (const [index, capability] of ['doc-lint', 'link-check'].entries()) {
247
351
  const ordered = index ? candidates.slice(1).concat(candidates.slice(0, 1)) : candidates;
248
352
  references.push(...ordered.map(item => `${item.ref}#${capability}`));
249
353
  }
354
+ const provider = require('./doc-reconciliation-provider');
355
+ references.push(...candidates.filter(item => provider.resolveDocCheckInvocation(item.command, item.cwd).conflictAssessment === true)
356
+ .map(item => `${item.ref}#conflict-check`));
357
+ references.push(...outlineDispatchReferences(captures, taskId, context.sessionDir));
250
358
  // The adapter accepts one event per capability; rotating candidate order lets separate valid runs fill each capability.
251
359
  return [...references, ...reviews.map(item => `${item.ref}#quality-review`)];
252
360
  }
@@ -295,6 +403,7 @@ function assess(context, set, dependencies = {}) {
295
403
  const report = {
296
404
  schemaVersion: 1, reportOrigin: 'runtime', pilotStatus: 'unavailable', pilotCredit: false,
297
405
  identity: null, expectedMustIds: [], results: [], unsatisfied: [], diagnostics: [],
406
+ outlineEvidence: { status: 'unresolved', outcome: 'none', observation: 'unresolved', acceptedRefs: [], recordRefs: [], diagnostics: [] },
298
407
  };
299
408
  const supported = set?.stepBindingsEnabled === true && !String(context.workflow.strategy || '').startsWith('fable-');
300
409
  let withhold = false;
@@ -310,10 +419,17 @@ function assess(context, set, dependencies = {}) {
310
419
  reasonCode: item.applicability === 'unknown' ? 'unknown-applicability' : 'producer-unavailable',
311
420
  }));
312
421
  if (!withhold) report.diagnostics.push('trusted-non-document-scope');
313
- if (withhold) {
422
+ const hasOutlineCapture = captureReferences(context).some(reference => reference.endsWith('#outline-confirmation'));
423
+ if (withhold || hasOutlineCapture) {
314
424
  let chain;
315
425
  try { chain = creditChain(context, set, dependencies); }
316
- catch (error) { chain = { eligible: false, diagnostics: [{ code: 'collector-unavailable', message: String(error.message) }] }; }
426
+ catch (error) { chain = { eligible: false, outlineEvidence: { status: 'unresolved', outcome: 'none',
427
+ observation: 'unresolved', reason: 'collector-unavailable', acceptedRefs: [], recordRefs: [], diagnostics: [] },
428
+ diagnostics: [{ code: 'collector-unavailable', message: String(error.message) }] }; }
429
+ report.outlineEvidence = chain.outlineEvidence || report.outlineEvidence;
430
+ report.acceptedRefs = chain.acceptedRefs || [];
431
+ report.diagnostics.push(...(chain.diagnostics || []));
432
+ if (withhold) {
317
433
  // The unsatisfied list is rewritten from the chain: a MUST that the chain did not satisfy stays listed (F3).
318
434
  const satisfied = new Set(chain.satisfiedObligationIds || []);
319
435
  report.unsatisfied = [
@@ -324,12 +440,11 @@ function assess(context, set, dependencies = {}) {
324
440
  ...(chain.unsatisfied || []),
325
441
  ];
326
442
  report.creditedCapabilities = chain.eligible === true ? chain.capabilities : [];
327
- report.acceptedRefs = chain.acceptedRefs || [];
328
- report.diagnostics.push(...(chain.diagnostics || []));
329
443
  // Credit needs an empty unsatisfied list across both sources and at least one satisfied capability.
330
444
  withhold = !(chain.eligible === true && report.unsatisfied.length === 0 && report.creditedCapabilities.length > 0);
331
445
  report.pilotCredit = !withhold;
332
446
  report.pilotStatus = report.pilotCredit ? 'credited' : 'unresolved';
447
+ }
333
448
  }
334
449
  } catch (error) {
335
450
  withhold = true;
@@ -348,4 +463,5 @@ function assess(context, set, dependencies = {}) {
348
463
  return { withhold, report };
349
464
  }
350
465
 
351
- module.exports = { enabled, captureBaseline, assess, reviewRequired, independentReviewAccepted };
466
+ module.exports = { enabled, captureBaseline, assess, reviewRequired, independentReviewAccepted,
467
+ _internals: { captureReferences, outlineDispatchReferences, outlineReviewCaptureRefs } };