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.
- package/.agents/harness-everything/project-docs.json +3 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +27 -0
- package/ci/fixtures/doc-obligations/read-boundary/boundary-policy.json +2 -2
- package/ci/fixtures/doc-obligations/reconciliation-s2a/harness.js +19 -1
- package/ci/mechanism-2ax-doc-lifecycle.test.js +2 -1
- package/ci/mechanism-325-3b-s2b-capture.test.js +6 -1
- package/ci/mechanism-325-f2-doc-craft.test.js +2 -2
- package/ci/mechanism-325-f3-derived-views.test.js +5 -2
- package/ci/mechanism-325-f4-projections.test.js +11 -1
- package/ci/mechanism-pr-a-conflict-check.test.js +507 -0
- package/ci/mechanism-prb-outline-review.test.js +1265 -0
- package/docs/adr/DOCS-001-project-docs-config.md +32 -0
- package/docs/adr/DOCS-002-adr-identity-supersession-index.md +34 -0
- package/docs/adr/DOCS-003-prd-revision-contract.md +29 -0
- package/docs/adr/DOCS-004-doc-change-step-type.md +28 -0
- package/docs/adr/DOCS-005-doc-obligations-evidence-producer.md +29 -0
- package/docs/adr/DOCS-006-reconciliation-pilot.md +50 -0
- package/docs/adr/DOCS-007-shared-doc-craft-derived-views.md +31 -0
- package/docs/adr/DOCS-008-obligation-abstraction-bounded-consumer.md +30 -0
- package/docs/doc-workflow-reconciliation.md +32 -1
- package/docs/governed-index.md +8 -0
- package/docs/platform-capabilities.md +6 -0
- package/harness-everything/SKILL.md +1 -1
- package/harness-everything/scripts/doc-check.js +84 -6
- package/harness-everything/scripts/doc-evidence.js +55 -0
- package/harness-everything/scripts/doc-lifecycle.js +51 -6
- package/hooks/hooks.json +1 -1
- package/hooks/scripts/lib/doc-reconciliation-events.js +138 -9
- package/hooks/scripts/lib/doc-reconciliation-pilot.js +131 -15
- package/hooks/scripts/lib/doc-reconciliation-provider.js +941 -13
- package/hooks/scripts/state-persist.js +10 -0
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-events.js +138 -9
- package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-pilot.js +131 -15
- package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-provider.js +941 -13
- package/plugins/harness-everything/hooks/scripts/state-persist.js +10 -0
- package/plugins/harness-everything/plugin.json +1 -1
- package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
- package/plugins/harness-everything/skills/harness-everything/scripts/doc-check.js +84 -6
- package/plugins/harness-everything/skills/harness-everything/scripts/doc-evidence.js +55 -0
- package/plugins/harness-everything/skills/harness-everything/scripts/doc-lifecycle.js +51 -6
- 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
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
|
-
const
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
return
|
|
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 (
|
|
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,
|
|
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(
|
|
124
|
-
|
|
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),
|
|
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()
|
|
35
|
+
if (!enabled()) return;
|
|
33
36
|
try {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 (
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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 } };
|