harness-everything 0.38.0 → 0.40.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 (79) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +79 -63
  4. package/CHANGELOG.md +32 -0
  5. package/benchmarks/results/reviews/pr353-r6-2026-10-22/README.md +76 -0
  6. package/benchmarks/results/reviews/pr353-r6-2026-10-22/a-reviews-b.md +191 -0
  7. package/benchmarks/results/reviews/pr353-r6-2026-10-22/b-reviews-a.md +100 -0
  8. package/benchmarks/results/reviews/pr353-r6-2026-10-22/baseline-probes.log +47 -0
  9. package/benchmarks/results/reviews/pr353-r6-2026-10-22/bom-red.log +85 -0
  10. package/benchmarks/results/reviews/pr353-r6-2026-10-22/bom-transition.txt +25 -0
  11. package/benchmarks/results/reviews/pr353-r6-2026-10-22/coordinator-probes.txt +84 -0
  12. package/benchmarks/results/reviews/pr353-r6-2026-10-22/cross-suite.txt +18 -0
  13. package/benchmarks/results/reviews/pr353-r6-2026-10-22/final-bom.log +3 -0
  14. package/benchmarks/results/reviews/pr353-r6-2026-10-22/final-consistency.log.gz +0 -0
  15. package/benchmarks/results/reviews/pr353-r6-2026-10-22/final-core.log +198 -0
  16. package/benchmarks/results/reviews/pr353-r6-2026-10-22/final-probes.log +27 -0
  17. package/benchmarks/results/reviews/pr353-r6-2026-10-22/final-producer.log +83 -0
  18. package/benchmarks/results/reviews/pr353-r6-2026-10-22/final-review.md +89 -0
  19. package/benchmarks/results/reviews/pr353-r6-2026-10-22/final-transitions.log +5 -0
  20. package/benchmarks/results/reviews/pr353-r6-2026-10-22/implementation-a.md +166 -0
  21. package/benchmarks/results/reviews/pr353-r6-2026-10-22/implementation-b.md +264 -0
  22. package/benchmarks/results/reviews/pr353-r6-2026-10-22/logs.sha256 +15 -0
  23. package/benchmarks/results/reviews/pr353-r6-2026-10-22/peer-a-probe.log +62 -0
  24. package/benchmarks/results/reviews/pr353-r6-2026-10-22/peer-a-probe.txt +313 -0
  25. package/benchmarks/results/reviews/pr353-r6-2026-10-22/peer-b-probe.log +51 -0
  26. package/benchmarks/results/reviews/pr353-r6-2026-10-22/peer-b-probe.txt +94 -0
  27. package/benchmarks/results/reviews/pr353-r6-2026-10-22/semantic-transitions.txt +25 -0
  28. package/benchmarks/results/reviews/pr353-r6-2026-10-22/source.sha256 +9 -0
  29. package/benchmarks/results/reviews/pr353-r6-2026-10-22/tests-a-on-b-core.log +201 -0
  30. package/benchmarks/results/reviews/pr353-r6-2026-10-22/tests-a-on-b-producer.log +82 -0
  31. package/benchmarks/results/reviews/pr353-r6-2026-10-22/tests-b-on-a-core.log +197 -0
  32. package/benchmarks/results/reviews/pr353-r6-2026-10-22/tests-b-on-a-producer.log +82 -0
  33. package/benchmarks/results/reviews/pr353-r6-2026-10-22/verify-all.log.gz +0 -0
  34. package/benchmarks/results/reviews/pr353-r6-2026-10-22/verify-gate-summary.json +22 -0
  35. package/ci/fixtures/doc-obligations/reconciliation-s2a/harness.js +489 -0
  36. package/ci/fixtures/doc-obligations/reconciliation-s2a/vectors.json +617 -0
  37. package/ci/mechanism-325-3b-s2a-core.test.js +840 -0
  38. package/ci/mechanism-325-3b-s2a-producer.test.js +564 -0
  39. package/code-review/SKILL.md +3 -3
  40. package/code-review/references/review-playbook.md +4 -0
  41. package/code-review/templates/finding.template.md +6 -0
  42. package/docs/doc-workflow-reconciliation.md +357 -0
  43. package/evals/zoom-out/tasks/negative-first-edge-case-review.yaml +16 -0
  44. package/evals/zoom-out/tasks/positive-repair-loop.yaml +15 -0
  45. package/harness-everything/SKILL.md +1 -1
  46. package/harness-everything/scripts/project-docs-resolver.js +42 -8
  47. package/harness-everything/scripts/routing-keywords.json +4 -0
  48. package/hooks/scripts/lib/doc-reconciliation-core.js +280 -0
  49. package/hooks/scripts/lib/doc-reconciliation-events.js +141 -0
  50. package/hooks/scripts/lib/doc-reconciliation-producer.js +206 -0
  51. package/multi-agent-workspace/SKILL.md +1 -1
  52. package/multi-agent-workspace/scripts/project-docs-resolver.js +42 -8
  53. package/opencode-plugin/plugin.json +1 -1
  54. package/package.json +2 -1
  55. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  56. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-core.js +280 -0
  57. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-events.js +141 -0
  58. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-producer.js +206 -0
  59. package/plugins/harness-everything/plugin.json +1 -1
  60. package/plugins/harness-everything/skills/code-review/SKILL.md +3 -3
  61. package/plugins/harness-everything/skills/code-review/references/review-playbook.md +4 -0
  62. package/plugins/harness-everything/skills/code-review/templates/finding.template.md +6 -0
  63. package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
  64. package/plugins/harness-everything/skills/harness-everything/scripts/project-docs-resolver.js +42 -8
  65. package/plugins/harness-everything/skills/harness-everything/scripts/routing-keywords.json +4 -0
  66. package/plugins/harness-everything/skills/multi-agent-workspace/SKILL.md +1 -1
  67. package/plugins/harness-everything/skills/multi-agent-workspace/scripts/project-docs-resolver.js +42 -8
  68. package/plugins/harness-everything/skills/to-spec/SKILL.md +1 -1
  69. package/plugins/harness-everything/skills/to-spec/scripts/project-docs-resolver.js +42 -8
  70. package/plugins/harness-everything/skills/zoom-out/SKILL.md +4 -3
  71. package/plugins/harness-everything/skills/zoom-out/references/circuit-breaker.md +7 -0
  72. package/plugins/harness-everything/skills/zoom-out/templates/zoom-out-report.template.md +4 -2
  73. package/sbom.cdx.json +7 -7
  74. package/scripts/errors-sync.js +90 -0
  75. package/to-spec/SKILL.md +1 -1
  76. package/to-spec/scripts/project-docs-resolver.js +42 -8
  77. package/zoom-out/SKILL.md +4 -3
  78. package/zoom-out/references/circuit-breaker.md +7 -0
  79. package/zoom-out/templates/zoom-out-report.template.md +4 -2
@@ -0,0 +1,280 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ // Both layouts are shipped by plugin:sync. Resolve modules at import, never during a decision.
6
+ function runtimeModule(name) {
7
+ try { return require('../../../harness-everything/scripts/' + name); }
8
+ catch (error) {
9
+ if (error.code !== 'MODULE_NOT_FOUND' || !error.message.startsWith("Cannot find module '../../../harness-everything/scripts/" + name + "'")) throw error;
10
+ return require('../../../skills/harness-everything/scripts/' + name);
11
+ }
12
+ }
13
+ const { checkDocObligations } = runtimeModule('doc-evidence.js');
14
+ const { deriveDocFacts, deriveDocObligations } = runtimeModule('doc-obligations.js');
15
+ const text = value => typeof value === 'string' && value.trim().length > 0;
16
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
17
+ const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
18
+ const bytes = value => Buffer.isBuffer(value) ? Buffer.from(value)
19
+ : value && value.type === 'Buffer' && Array.isArray(value.data) ? Buffer.from(value.data)
20
+ : (() => { throw new Error('inventory-unsupported'); })();
21
+ function canonical(value) {
22
+ if (Buffer.isBuffer(value)) return { type: 'Buffer', data: [...value] };
23
+ if (Array.isArray(value)) return value.map(canonical);
24
+ if (!record(value)) return value;
25
+ return Object.fromEntries(Object.keys(value).sort(compare).map(key => [key, canonical(value[key])]));
26
+ }
27
+ const stable = value => JSON.stringify(canonical(value));
28
+ const clone = value => JSON.parse(stable(value));
29
+ const sha = value => crypto.createHash('sha256').update(value).digest('hex');
30
+ const frame = value => { const b = Buffer.isBuffer(value) ? value : Buffer.from(String(value)); return Buffer.concat([Buffer.from(b.length + ':'), b]); };
31
+ const hashFrames = values => sha(Buffer.concat(values.map(frame)));
32
+ const structuredDigest = (domain, value) => hashFrames([domain, stable(value)]);
33
+ const identityFields = ['workspaceId', 'sessionId', 'workflowId', 'workflowRevision', 'taskId', 'taskScopeDigest', 'baselineDigest', 'candidateDigest', 'configDigest', 'prdRoot'];
34
+ const validIdentity = id => record(id) && ['workspaceId', 'sessionId', 'workflowId', 'taskId'].every(key => text(id[key]))
35
+ && Number.isInteger(id.workflowRevision) && id.workflowRevision >= 0 && id.prdRoot === 'docs/prd/'
36
+ && ['taskScopeDigest', 'baselineDigest', 'candidateDigest', 'configDigest'].every(key => /^[a-f0-9]{64}$/.test(id[key] || ''));
37
+ const sameIdentity = (a, b) => validIdentity(a) && validIdentity(b) && identityFields.every(key => a[key] === b[key]);
38
+ const diagnostic = (code, context = {}) => ({ code, message: code.replace(/-/g, ' '), ...context });
39
+ const pathCompare = (a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path));
40
+ const codedError = code => Object.assign(new Error(code), { code });
41
+ // The derive contract (doc-obligations.js) decides applicability from governed/legacy documents, derived
42
+ // outputs and code/REQ-test paths. Records outside them cannot affect document obligations.
43
+ // shortcut: mirrors derive path rules, update them together until a shared classifier is exposed.
44
+ const CODE_EXTENSION = /\.(?:c|cc|cpp|h|hpp|m|mm|go|rs|java|kt|swift|js|jsx|mjs|cjs|ts|tsx|py|rb|php|sh|ps1|sql)$/i;
45
+ const TEST_PATH = /(?:^|\/)(?:test|tests|__tests__|spec)(?:\/|$)|(?:^|\/)[^/]*(?:\.test|\.spec)\.[^/]+$/i;
46
+ // Glob semantics of the derive contract: '**' crosses directories, '*' stays inside one segment.
47
+ function globToRegExp(glob) {
48
+ const source = String(glob).replace(/\\/g, '/').replace(/^\.\//, '');
49
+ let pattern = '^';
50
+ for (let index = 0; index < source.length; index++) {
51
+ const char = source[index];
52
+ if (char === '*' && source[index + 1] === '*') {
53
+ if (source[index + 2] === '/') { pattern += '(?:.*/)?'; index += 2; }
54
+ else { pattern += '.*'; index += 1; }
55
+ } else if (char === '*') pattern += '[^/]*';
56
+ else pattern += char.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
57
+ }
58
+ return new RegExp(`${pattern}$`);
59
+ }
60
+ // Semantic relevance of a path under the derive contract; null means the path cannot change applicability.
61
+ function semanticRelevance(path, config) {
62
+ const governed = Array.isArray(config && config.governed) ? config.governed : [];
63
+ const legacy = Array.isArray(config && config.legacy) ? config.legacy : [];
64
+ const derived = Array.isArray(config && config.derived) ? config.derived : [];
65
+ const matches = globs => globs.some(glob => typeof glob === 'string' && globToRegExp(glob).test(path));
66
+ if (governed.some(entry => entry && typeof entry.path === 'string' && matches([entry.path]))) return 'document';
67
+ if (matches(legacy)) return 'document';
68
+ if (derived.some(entry => entry && typeof entry.path === 'string'
69
+ && (matches([entry.path]) || matches([entry.source, ...(Array.isArray(entry.sources) ? entry.sources : [])])))) return 'derived';
70
+ if (CODE_EXTENSION.test(path)) return 'code';
71
+ if (TEST_PATH.test(path)) return 'test';
72
+ return null;
73
+ }
74
+ // The derive contract reads the frontmatter of every captured file, so a record that opens a frontmatter
75
+ // block is observed by it even when the record's bytes cannot be decoded. Match TextDecoder's BOM handling.
76
+ function opensFrontmatter(stored) {
77
+ return stored.subarray(0, 11).toString('utf8').replace(/^\uFEFF/, '').split('\n')[0].replace(/\r$/, '') === '---';
78
+ }
79
+ // A whole-project inventory keeps every stored byte in the identity framing. Semantic derivation converts only
80
+ // records it can decode; a record the derive contract can observe (governed/derived/code/test path or a
81
+ // frontmatter document) fails closed instead of being silently dropped.
82
+ function deriveFiles(records, config, options = {}) {
83
+ const files = {};
84
+ for (const item of records || []) {
85
+ if (!record(item) || item.presence !== 'present') continue;
86
+ // The unchanged derive API uses ordinary object maps, whose __proto__ setter drops this path.
87
+ if (item.path === '__proto__') throw codedError('inventory-unsupported');
88
+ const stored = bytes(item.storedBytes);
89
+ if (options.strict && (item.digest !== sha(stored) || item.type !== 'file' || !['100644', '100755'].includes(item.mode))) {
90
+ throw codedError('invalid-input');
91
+ }
92
+ let content;
93
+ try { content = new TextDecoder('utf-8', { fatal: true }).decode(stored); }
94
+ catch {
95
+ if (semanticRelevance(item.path, config) || opensFrontmatter(stored)) throw codedError('inventory-unsupported');
96
+ continue;
97
+ }
98
+ files[item.path] = content;
99
+ }
100
+ return files;
101
+ }
102
+ const identityTupleFields = ['workspaceId', 'sessionId', 'workflowId', 'workflowRevision', 'taskId'];
103
+ // The captured origin context is the snapshot's only origin-correlated identity: every field it carries must be
104
+ // the identity the snapshot claims, the captured plan must agree with both, and the captured host must be the
105
+ // host the captured config was selected for (PR #353 review P1).
106
+ function originBound(snapshot) {
107
+ const context = snapshot.originContext;
108
+ const id = snapshot.identity;
109
+ if (!record(context) || !validIdentity(id)) return false;
110
+ for (const key of identityTupleFields) if (context[key] !== id[key]) return false;
111
+ const plan = snapshot.taskSemantics;
112
+ if (!record(plan) || !record(plan.workflow) || !record(plan.task)) return false;
113
+ if (plan.workflow.workflowId !== id.workflowId || plan.workflow.workflowRevision !== id.workflowRevision
114
+ || plan.workflow.sessionId !== id.sessionId || plan.task.taskId !== id.taskId) return false;
115
+ if (plan.prdRoot !== id.prdRoot || plan.configDigest !== id.configDigest) return false;
116
+ const captured = context.activeHost === undefined || context.activeHost === null ? null : context.activeHost;
117
+ const selected = snapshot.config && snapshot.config.capture ? snapshot.config.capture.activeHost : null;
118
+ const chosen = selected === undefined || selected === null ? null : selected;
119
+ if (captured === null && chosen === null) return true;
120
+ return stable(captured) === stable(chosen);
121
+ }
122
+ // The captured config bytes must be the bytes the captured inventory holds for the same path; otherwise the
123
+ // config digest binds a parsed value that the whole-project capture contradicts (the producer's own rule).
124
+ function configBytesBound(snapshot) {
125
+ const sources = snapshot.config && snapshot.config.capture ? snapshot.config.capture.sources : null;
126
+ if (!Array.isArray(sources)) return false;
127
+ const records = Array.isArray(snapshot.candidate.records) ? snapshot.candidate.records : [];
128
+ for (const source of sources) {
129
+ const file = records.find(item => item && item.path === source.path && item.presence === 'present');
130
+ if (source.presence === 'present' ? !file || !bytes(source.bytes).equals(bytes(file.storedBytes)) : Boolean(file)) return false;
131
+ }
132
+ return true;
133
+ }
134
+ function snapshotDigest(kind, baseline, coverage, records) {
135
+ const fields = ['doc-snapshot-v1', kind, baseline.commit, baseline.treeRef, structuredDigest('doc-coverage-v1', coverage), String(records.length)];
136
+ for (const item of [...records].sort(pathCompare)) fields.push(item.path, item.presence, item.type, item.mode, bytes(item.storedBytes));
137
+ return hashFrames(fields);
138
+ }
139
+ function configDigest(config) {
140
+ const sources = [...config.capture.sources].sort(pathCompare);
141
+ const fields = ['doc-config-v1', stable({ activeHost: config.capture.activeHost, selectionInputs: config.capture.selectionInputs, resolution: config.resolution }), String(sources.length)];
142
+ for (const source of sources) fields.push(source.path, source.presence, bytes(source.bytes));
143
+ return hashFrames(fields);
144
+ }
145
+ function validateSnapshot(snapshot) {
146
+ const errors = [];
147
+ try {
148
+ if (!validIdentity(snapshot.identity) || !record(snapshot.facts) || !Array.isArray(snapshot.obligations)) throw new Error('invalid-input');
149
+ // Roots are canonicalized at capture; keep reconciliation detached and free of filesystem reads.
150
+ const context = snapshot.originContext;
151
+ if (!record(context) || !record(context.baseline) || !text(context.workspaceRoot)
152
+ || context.baseline.commit !== snapshot.baseline.commit || context.baseline.root !== context.workspaceRoot) {
153
+ errors.push(diagnostic('baseline-unavailable'));
154
+ }
155
+ // The captured origin identity, the captured plan and the selected host must be the identity the snapshot
156
+ // claims; otherwise the provider half and the event half of the trust contract stay disconnected.
157
+ if (!originBound(snapshot)) errors.push(diagnostic('identity-mismatch'));
158
+ if (snapshotDigest('baseline', snapshot.baseline, snapshot.candidate.coverage, snapshot.baseline.records) !== snapshot.identity.baselineDigest
159
+ || snapshotDigest('candidate', snapshot.baseline, snapshot.candidate.coverage, snapshot.candidate.records) !== snapshot.identity.candidateDigest
160
+ || snapshot.baseline.digest !== snapshot.identity.baselineDigest || snapshot.candidate.digest !== snapshot.identity.candidateDigest) errors.push(diagnostic('identity-mismatch'));
161
+ if (configDigest(snapshot.config) !== snapshot.identity.configDigest || snapshot.config.digest !== snapshot.identity.configDigest) errors.push(diagnostic('config-capture-mismatch'));
162
+ if (!configBytesBound(snapshot)) errors.push(diagnostic('config-capture-mismatch'));
163
+ if (structuredDigest('doc-task-scope-v1', snapshot.taskSemantics) !== snapshot.identity.taskScopeDigest) errors.push(diagnostic('identity-mismatch'));
164
+ const config = snapshot.config.resolution.config;
165
+ const derived = deriveDocFacts({ base: { files: deriveFiles(snapshot.baseline.records, config, { strict: true }) },
166
+ candidate: { files: deriveFiles(snapshot.candidate.records, config, { strict: true }) }, config });
167
+ if (stable(derived.facts) !== stable(snapshot.facts)
168
+ || stable(deriveDocObligations(derived.facts)) !== stable(snapshot.obligations)) errors.push(diagnostic('identity-mismatch'));
169
+ for (const obligation of snapshot.obligations) {
170
+ const reason = snapshot.derivationReasons && snapshot.derivationReasons[obligation.id];
171
+ if (obligation.strength === 'MUST' && obligation.applicability === 'N/A'
172
+ && (!reason || reason.applicability !== 'N/A' || !text(reason.reason) || !Array.isArray(reason.factRefs) || !reason.factRefs.length)) {
173
+ errors.push(diagnostic('na-reason-missing', { obligationId: obligation.id }));
174
+ }
175
+ }
176
+ } catch (error) {
177
+ const code = error && ['invalid-input', 'inventory-incomplete', 'inventory-unsupported'].includes(error.message)
178
+ ? error.message : 'invalid-input';
179
+ errors.push(diagnostic(code));
180
+ }
181
+ return errors;
182
+ }
183
+
184
+ function reconcileDocResults(input = {}) {
185
+ const { snapshot, accepted, checkerOutput, mode = 'verification' } = record(input) ? input : {};
186
+ const diagnostics = [];
187
+ const unsatisfied = [];
188
+ const results = Array.isArray(checkerOutput && checkerOutput.results) ? checkerOutput.results : [];
189
+ const base = { decision: 'unresolved', eligible: false, expectedMustIds: [], checkedMustIds: [], results, unsatisfied, diagnostics };
190
+ if (!record(snapshot) || !record(accepted) || !record(checkerOutput) || !Array.isArray(checkerOutput.results)
191
+ || !results.every(record) || !Array.isArray(checkerOutput.diagnostics) || !checkerOutput.diagnostics.every(record)
192
+ || !['verification', 'review'].includes(mode)
193
+ || !['ready', 'unresolved', 'unavailable'].includes(accepted.state)
194
+ || !Array.isArray(accepted.diagnostics) || !accepted.diagnostics.every(record)
195
+ || !Array.isArray(accepted.rejected) || !accepted.rejected.every(record) || !record(accepted.acceptedIndex)) {
196
+ diagnostics.push(diagnostic('invalid-input'));
197
+ return base;
198
+ }
199
+ diagnostics.push(...validateSnapshot(snapshot));
200
+ if (!Array.isArray(snapshot.obligations) || !snapshot.obligations.every(o => record(o) && record(o.scope) && Array.isArray(o.scope.paths))) return base;
201
+ const expected = snapshot.obligations.filter(o => o.strength === 'MUST' && o.applicability !== 'N/A');
202
+ base.expectedMustIds = expected.map(o => o.id).sort(compare);
203
+ const selected = mode === 'review' ? expected.filter(o => o.capability === 'quality-review') : expected;
204
+ base.checkedMustIds = selected.map(o => o.id).sort(compare);
205
+ const fail = (code, obligationId, state = 'pending') => {
206
+ diagnostics.push(diagnostic(code, obligationId ? { obligationId } : {}));
207
+ if (obligationId) unsatisfied.push({ obligationId, state, reasonCode: code });
208
+ };
209
+ if (diagnostics.length) return base;
210
+ const capturedArtifacts = Object.fromEntries(snapshot.candidate.records.filter(r => r.presence === 'present').map(r => [r.path, { digest: r.digest }]));
211
+ const capturedBaseline = { digest: snapshot.identity.baselineDigest,
212
+ artifacts: Object.fromEntries(snapshot.baseline.records.filter(r => r.presence === 'present').map(r => [r.path, { digest: r.digest }])) };
213
+ if (stable(accepted.checkerInputs?.artifacts) !== stable(capturedArtifacts)
214
+ || stable(accepted.checkerInputs?.baseline) !== stable(capturedBaseline)
215
+ || stable(accepted.checkerInputs?.obligations) !== stable(snapshot.obligations)) fail('artifact-mismatch');
216
+ if (!sameIdentity(snapshot.identity, accepted.identity) || stable(snapshot.originContext) !== stable(accepted.originContext)) fail('identity-mismatch');
217
+ if (snapshot.identity.configDigest !== accepted.identity?.configDigest) fail('config-capture-mismatch');
218
+ for (const item of [...(accepted.diagnostics || []), ...(accepted.rejected || [])]) {
219
+ diagnostics.push(item);
220
+ // Only rejected evidence for a checked expected MUST, or a global origin/identity fault, gates credit;
221
+ // a rejected SHOULD or N/A event stays a visible diagnostic (PR #353 review P2).
222
+ if (!item.obligationId || selected.some(o => o.id === item.obligationId)) {
223
+ unsatisfied.push({ obligationId: item.obligationId || 'origin', state: item.code === 'origin-unavailable' ? 'unavailable' : 'pending', reasonCode: item.code });
224
+ }
225
+ }
226
+ if (accepted.state === 'unavailable') fail('origin-unavailable');
227
+ if (checkerOutput?.unavailable) fail('checker-unavailable');
228
+ const known = new Set(snapshot.obligations.map(o => o.id));
229
+ for (const result of results) if (!known.has(result.obligationId)) fail('result-misattributed', result.obligationId);
230
+ for (const obligation of selected) {
231
+ const id = obligation.id;
232
+ if (obligation.applicability === 'unknown') { fail('unknown-applicability', id, 'unknown'); continue; }
233
+ const matches = results.filter(r => r.obligationId === id);
234
+ if (matches.length !== 1) { fail(matches.length ? 'result-duplicate' : 'result-missing', id); continue; }
235
+ const result = matches[0];
236
+ if (result.observation !== 'observed' || result.outcome !== 'pass') {
237
+ const hasAccepted = Object.values(accepted.acceptedIndex || {}).some(item => item.obligationId === id);
238
+ fail(hasAccepted ? 'result-not-pass' : 'result-missing', id);
239
+ continue;
240
+ }
241
+ const artifactDigest = obligation.scope.paths.length
242
+ ? obligation.scope.paths.map(p => `${p}=${capturedArtifacts[p]?.digest || 'missing'}`).join('|')
243
+ : snapshot.identity.baselineDigest;
244
+ if (result.baseDigest !== snapshot.identity.baselineDigest || result.artifactDigest !== artifactDigest) fail('result-misattributed', id);
245
+ if (!Array.isArray(result.recordRefs) || !result.recordRefs.length) { fail('result-untrusted', id); continue; }
246
+ for (const ref of result.recordRefs) {
247
+ const item = accepted.acceptedIndex?.[ref];
248
+ if (!item || item.obligationId !== id || !sameIdentity(item.identity, snapshot.identity)
249
+ || !text(item.originRef) || stable(item.paths) !== stable(obligation.scope.paths)
250
+ || item.artifactBinding !== snapshot.identity.candidateDigest) fail('result-untrusted', id);
251
+ }
252
+ }
253
+ // Validation failures must never be hidden by a cached report or a passing result subset.
254
+ // Global faults and diagnostics for a checked expected MUST block; SHOULD/N/A diagnostics stay visible only.
255
+ const blocking = diagnostics.filter(item => !item.obligationId || selected.some(o => o.id === item.obligationId));
256
+ // Checker diagnostics stay visible; eligibility is decided from the expected MUST set above.
257
+ // In particular, later verifier diagnostics cannot stall an independent review.
258
+ if (Array.isArray(checkerOutput.diagnostics)) diagnostics.push(...checkerOutput.diagnostics);
259
+ if (blocking.length || unsatisfied.length) {
260
+ if (blocking.some(d => ['origin-unavailable', 'checker-unavailable'].includes(d.code))) base.decision = 'unavailable';
261
+ return base;
262
+ }
263
+ if (!selected.length) { base.decision = 'legacy'; diagnostics.push(diagnostic('no-document-musts')); return base; }
264
+ base.decision = 'eligible';
265
+ base.eligible = true;
266
+ return base;
267
+ }
268
+
269
+ function checkAcceptedDocEvidence(input = {}) {
270
+ const { snapshot, accepted, mode = 'verification' } = record(input) ? input : {};
271
+ let checkerOutput;
272
+ try {
273
+ if (!accepted?.checkerInputs || !snapshot) throw new Error('invalid-input');
274
+ checkerOutput = checkDocObligations({ ...accepted.checkerInputs, obligations: snapshot.obligations });
275
+ } catch { checkerOutput = { results: [], diagnostics: [], unavailable: true }; }
276
+ return reconcileDocResults({ snapshot, accepted, checkerOutput, mode });
277
+ }
278
+
279
+ module.exports = { checkAcceptedDocEvidence, reconcileDocResults,
280
+ _internals: { runtimeModule, text, record, bytes, stable, clone, sha, structuredDigest, snapshotDigest, configDigest, sameIdentity, diagnostic, validateSnapshot, compare, deriveFiles, semanticRelevance, originBound, configBytesBound } };
@@ -0,0 +1,141 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { _internals: U } = require('./doc-reconciliation-core');
6
+
7
+ // Directory identity as in the producer: native realpath (Windows 8.3 names), case-insensitive on Windows.
8
+ function samePath(a, b) {
9
+ 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;
13
+ } catch { return false; }
14
+ }
15
+
16
+ function artifactsFor(records) {
17
+ return Object.fromEntries(records.filter(item => item.presence === 'present').map(item => [item.path, { digest: item.digest }]));
18
+ }
19
+ function prdDigest(artifacts) {
20
+ return U.sha(JSON.stringify(Object.keys(artifacts).filter(p => p.startsWith('docs/prd/')).sort().map(p => [p, artifacts[p].digest])));
21
+ }
22
+ function acceptDocEvents(input = {}, dependencies = {}) {
23
+ const { snapshot, eventRefs } = U.record(input) ? input : {};
24
+ const originProvider = dependencies?.originProvider;
25
+ const diagnostics = [];
26
+ const rejected = [];
27
+ const acceptedIndex = Object.create(null);
28
+ const empty = { state: 'unavailable', checkerInputs: null, acceptedIndex, rejected, diagnostics };
29
+ try {
30
+ if (!snapshot || !Array.isArray(eventRefs) || !eventRefs.every(U.text)) {
31
+ diagnostics.push(U.diagnostic('invalid-input'));
32
+ return empty;
33
+ }
34
+ diagnostics.push(...U.validateSnapshot(snapshot));
35
+ if (diagnostics.length) return { ...empty, state: 'unresolved' };
36
+ const artifacts = artifactsFor(snapshot.candidate.records);
37
+ const baselineArtifacts = artifactsFor(snapshot.baseline.records);
38
+ const checkerInputs = { obligations: U.clone(snapshot.obligations), artifacts,
39
+ baseline: { digest: snapshot.identity.baselineDigest, artifacts: baselineArtifacts }, executionRecords: [], reviewRecords: [] };
40
+ const out = { state: 'ready', identity: U.clone(snapshot.identity), originContext: U.clone(snapshot.originContext), checkerInputs, acceptedIndex, rejected, diagnostics };
41
+ if (!originProvider || typeof originProvider.resolveEvent !== 'function' || typeof originProvider.resolveContext !== 'function') {
42
+ diagnostics.push(U.diagnostic('origin-unavailable'));
43
+ out.state = 'unavailable';
44
+ return out;
45
+ }
46
+ const contextResponse = originProvider.resolveContext({ workspaceRoot: snapshot.originContext.workspaceRoot, workflowId: snapshot.identity.workflowId, sessionId: snapshot.identity.sessionId });
47
+ if (contextResponse?.status !== 'accepted' || !U.text(contextResponse.originRef)) {
48
+ diagnostics.push(U.diagnostic(contextResponse?.status === 'rejected' ? 'origin-untrusted' : 'origin-unavailable'));
49
+ out.state = 'unavailable';
50
+ return out;
51
+ }
52
+ if (contextResponse.originRef !== snapshot.originContext.originRef) diagnostics.push(U.diagnostic('identity-mismatch'));
53
+ const context = contextResponse.context;
54
+ for (const key of ['workspaceId', 'sessionId', 'workflowId', 'workflowRevision', 'taskId', 'principals', 'activeHost', 'baselineReference']) {
55
+ if (U.stable(context?.[key]) !== U.stable(snapshot.originContext[key])) diagnostics.push(U.diagnostic('identity-mismatch'));
56
+ }
57
+ // The concrete workflow-start pin and worktree bound at capture must still be what the provider issues;
58
+ // a different pin under the same baselineReference is not the captured baseline (PR #353 review P1).
59
+ const pin = snapshot.originContext.baseline;
60
+ if (!U.record(context?.baseline) || !U.record(pin) || context.baseline.commit !== pin.commit
61
+ || !U.text(context.baseline.root) || !samePath(context.baseline.root, pin.root)) diagnostics.push(U.diagnostic('baseline-unavailable'));
62
+ if (context?.workspaceRoot !== undefined && !(U.text(context.workspaceRoot) && samePath(context.workspaceRoot, snapshot.originContext.workspaceRoot))) {
63
+ diagnostics.push(U.diagnostic('identity-mismatch'));
64
+ }
65
+ const seenIds = new Set();
66
+ const seenCapabilities = new Set();
67
+ for (const reference of eventRefs) {
68
+ let response;
69
+ try { response = originProvider.resolveEvent({ reference, identity: U.clone(snapshot.identity) }); }
70
+ catch { response = { status: 'unavailable' }; }
71
+ if (response?.status !== 'accepted') {
72
+ const supplied = Array.isArray(response?.diagnostics) ? response.diagnostics : [];
73
+ rejected.push(...(supplied.length ? U.clone(supplied) : [U.diagnostic(response?.status === 'rejected' ? 'origin-untrusted' : 'origin-unavailable', { reference })]));
74
+ continue;
75
+ }
76
+ const event = U.clone(response.event);
77
+ const obligation = snapshot.obligations.find(o => o.capability === event?.capability);
78
+ const obligationId = obligation?.id;
79
+ const problems = [];
80
+ const reject = code => problems.push(U.diagnostic(code, { reference, ...(obligationId ? { obligationId } : {}) }));
81
+ if (!U.record(event) || !['eventId', 'producerId', 'originRef', 'principalId', 'provenance', 'capability'].every(k => U.text(event[k]))
82
+ || !U.text(response.originRef) || event.originRef !== response.originRef || !['execution', 'verification', 'review'].includes(event.kind)) reject('event-invalid');
83
+ if (!U.sameIdentity(event?.identity, snapshot.identity)) {
84
+ reject('identity-mismatch');
85
+ if (event?.identity?.configDigest !== snapshot.identity.configDigest) reject('config-capture-mismatch');
86
+ }
87
+ if (!obligation) reject('capability-mismatch');
88
+ if (seenIds.has(event?.eventId)) reject('event-duplicate');
89
+ if (seenCapabilities.has(event?.capability)) reject('event-conflict');
90
+ seenIds.add(event?.eventId);
91
+ seenCapabilities.add(event?.capability);
92
+ const paths = obligation?.scope?.paths || [];
93
+ if (!Array.isArray(event?.paths) || U.stable([...event.paths].sort()) !== U.stable([...paths].sort())) reject('scope-mismatch');
94
+ const digests = event?.kind === 'review' ? event.reviewedArtifactDigests : event?.artifactDigests;
95
+ if (!U.record(digests)) reject('artifact-mismatch');
96
+ else {
97
+ for (const p of paths) {
98
+ if (artifacts[p] && digests[p] !== artifacts[p].digest) reject('artifact-mismatch');
99
+ if (!artifacts[p] && (!baselineArtifacts[p] || event.baselineArtifacts?.[p] !== baselineArtifacts[p].digest || Object.hasOwn(digests, p))) reject('artifact-mismatch');
100
+ }
101
+ // Supplied extra digests must also describe this capture, never another tree.
102
+ for (const p of Object.keys(digests)) if (!artifacts[p] || digests[p] !== artifacts[p].digest) reject('artifact-mismatch');
103
+ }
104
+ if (event?.kind === 'review') {
105
+ if (event.verdict !== 'approve' || !U.text(event.summary)) reject('event-invalid');
106
+ const principals = context?.principals;
107
+ const independent = principals && [principals.author, ...(principals.executors || [])];
108
+ if (!independent || independent.includes(event.principalId) || !Array.isArray(event.independentOf)
109
+ || !independent.every(p => event.independentOf.includes(p))) reject('review-not-independent');
110
+ } else {
111
+ if (!U.text(event?.command) || !Array.isArray(event?.argv) || !event.argv.length || !event.argv.every(U.text)
112
+ || !U.text(event.cwd) || !Number.isInteger(event.exitCode) || event.exitCode !== 0 || event.signal !== null
113
+ || !U.text(event.toolCorrelationId) || response.correlation?.toolCorrelationId !== event.toolCorrelationId
114
+ || !U.text(event.resultRef)) reject('event-invalid');
115
+ if (!U.sameIdentity(event?.startIdentity, snapshot.identity) || !U.sameIdentity(event?.endIdentity, snapshot.identity)) reject('snapshot-changed');
116
+ // Provider acceptance covers command semantics; the adapter additionally rejects command/argv substitution.
117
+ if (Array.isArray(event?.argv) && event.command !== event.argv.join(' ')) reject('capability-mismatch');
118
+ if (!context?.principals?.executors?.includes(event?.principalId)) reject('origin-untrusted');
119
+ }
120
+ if (problems.length) { rejected.push(...problems); continue; }
121
+ const adapted = { id: reference, obligationId, kind: event.kind === 'review' ? 'review' : 'execution',
122
+ 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 });
125
+ else Object.assign(adapted, { command: event.command, exitCode: event.exitCode });
126
+ if (event.capability === 'conflict-check') {
127
+ adapted.candidatePrdSet = { digest: prdDigest(artifacts), complete: true, paths: Object.keys(artifacts).filter(p => p.startsWith('docs/prd/')).sort() };
128
+ }
129
+ (event.kind === 'review' ? checkerInputs.reviewRecords : checkerInputs.executionRecords).push(adapted);
130
+ acceptedIndex[reference] = { reference, eventId: event.eventId, obligationId, capability: event.capability,
131
+ paths: [...paths], identity: U.clone(event.identity), originRef: response.originRef, artifactBinding: snapshot.identity.candidateDigest,
132
+ artifactDigests: U.clone(digests), event };
133
+ }
134
+ if (diagnostics.length || rejected.length) out.state = 'unresolved';
135
+ return out;
136
+ } catch {
137
+ diagnostics.push(U.diagnostic('invalid-input'));
138
+ return empty;
139
+ }
140
+ }
141
+ module.exports = { acceptDocEvents };
@@ -0,0 +1,206 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execFileSync } = require('child_process');
6
+ const { _internals: U } = require('./doc-reconciliation-core');
7
+ const { scanWorkspaceFiles } = U.runtimeModule('doc-lifecycle.js');
8
+ const { resolveDocsConfigCaptured } = U.runtimeModule('project-docs-resolver.js');
9
+ const { deriveDocFacts, deriveDocObligations } = U.runtimeModule('doc-obligations.js');
10
+
11
+ const MAX_FILES = 10000;
12
+ const MAX_BYTES = 64 * 1024 * 1024;
13
+ const POSIX_MODES = process.platform !== 'win32';
14
+ function fail(code) { throw new Error(code); }
15
+ // The JS realpath keeps Windows 8.3 spellings (RUNNER~1) while Git reports the long name, so
16
+ // directory identity uses native resolution and is case-insensitive on Windows (as workflow-isolation).
17
+ function canonicalPath(value) { return fs.realpathSync.native(path.resolve(value)); }
18
+ function samePath(a, b) {
19
+ const left = canonicalPath(a);
20
+ const right = canonicalPath(b);
21
+ return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
22
+ }
23
+ function git(root, args, options = {}) {
24
+ const env = { ...process.env };
25
+ for (const key of Object.keys(env)) if (key.startsWith('GIT_')) delete env[key];
26
+ try {
27
+ return execFileSync('git', ['-C', root, ...args], { env, timeout: 10000, maxBuffer: MAX_BYTES, stdio: ['pipe', 'pipe', 'pipe'], ...options });
28
+ } catch { fail('baseline-unavailable'); }
29
+ }
30
+ function fileRecord(relative, mode, storedBytes, presence = 'present') {
31
+ return { path: relative, presence, type: presence === 'present' ? 'file' : '', mode,
32
+ storedBytes: Buffer.from(storedBytes), digest: U.sha(storedBytes) };
33
+ }
34
+ function validatePaths(records) {
35
+ const names = new Set();
36
+ for (const item of records) {
37
+ const name = item.path;
38
+ if (!name || name.includes('\\') || name.includes('\ufffd') || name.startsWith('/')
39
+ || name.split('/').some(segment => !segment || segment === '..' || segment === '.')) fail('inventory-unsupported');
40
+ const folded = name.normalize('NFC').toLowerCase();
41
+ if (names.has(folded)) fail('inventory-unsupported');
42
+ names.add(folded);
43
+ }
44
+ }
45
+ function captureBaseline(root, pin) {
46
+ if (!U.record(pin) || !/^[a-f0-9]{40,64}$/.test(pin.commit || '') || !U.text(pin.root)
47
+ || !samePath(pin.root, root)) fail('baseline-unavailable');
48
+ const actualRoot = git(root, ['rev-parse', '--show-toplevel']).toString('utf8').trim();
49
+ if (!samePath(actualRoot, root)) fail('baseline-unavailable');
50
+ const commit = git(root, ['rev-parse', '--verify', '--end-of-options', `${pin.commit}^{commit}`]).toString('utf8').trim();
51
+ const treeRef = git(root, ['rev-parse', '--verify', '--end-of-options', `${commit}^{tree}`]).toString('utf8').trim();
52
+ const entries = git(root, ['ls-tree', '-r', '-z', '--full-tree', treeRef]).toString('utf8').split('\0').filter(Boolean);
53
+ if (entries.length > MAX_FILES) fail('inventory-unsupported');
54
+ const records = [];
55
+ let total = 0;
56
+ for (const entry of entries) {
57
+ const match = entry.match(/^(100644|100755) blob ([a-f0-9]{40,64})\t(.+)$/s);
58
+ if (!match) fail('inventory-unsupported');
59
+ const storedBytes = git(root, ['cat-file', 'blob', match[2]]);
60
+ total += storedBytes.length;
61
+ if (total > MAX_BYTES) fail('inventory-unsupported');
62
+ records.push(fileRecord(match[3], match[1], storedBytes));
63
+ }
64
+ validatePaths(records);
65
+ return { commit, treeRef, records };
66
+ }
67
+ function captureCandidate(root, baseline) {
68
+ const raw = [];
69
+ let total = 0;
70
+ let visited = 0;
71
+ function walk(dir, prefix) {
72
+ const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => U.compare(a.name, b.name));
73
+ for (const entry of entries) {
74
+ if (!prefix && entry.name === '.git') continue;
75
+ if (++visited > MAX_FILES) fail('inventory-unsupported');
76
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
77
+ const absolute = path.join(dir, entry.name);
78
+ const stat = fs.lstatSync(absolute);
79
+ if (stat.isDirectory()) {
80
+ // Windows stats carry no POSIX permission bits (directories report 0o666); an unreadable
81
+ // path there makes readdir/readFile throw instead.
82
+ if (POSIX_MODES && (!(stat.mode & 0o444) || !(stat.mode & 0o111))) fail('inventory-incomplete');
83
+ walk(absolute, relative);
84
+ continue;
85
+ }
86
+ if (!stat.isFile()) fail('inventory-unsupported');
87
+ // Unreadable files must remain unavailable even when the process has elevated read access.
88
+ if (POSIX_MODES && !(stat.mode & 0o444)) fail('inventory-incomplete');
89
+ const bytes = fs.readFileSync(absolute);
90
+ total += bytes.length;
91
+ if (raw.length >= MAX_FILES || total > MAX_BYTES) fail('inventory-unsupported');
92
+ raw.push({ path: relative, raw: bytes, mode: stat.mode & 0o111 ? '100755' : '100644' });
93
+ }
94
+ }
95
+ walk(root, '');
96
+ validatePaths(raw);
97
+ // Refuse filters/encodings that the existing stored-byte reader does not model.
98
+ if (raw.length) {
99
+ const attrs = git(root, ['check-attr', '-z', '--stdin', 'filter', 'working-tree-encoding', 'ident'], { input: Buffer.from(raw.map(r => r.path).join('\0') + '\0') }).toString('utf8').split('\0');
100
+ for (let i = 0; i + 2 < attrs.length; i += 3) {
101
+ if (!['unspecified', 'unset'].includes(attrs[i + 2])) fail('inventory-unsupported');
102
+ }
103
+ }
104
+ // Lifecycle owns text/eol/autocrlf conversion. A completeness walk above detects its skipped paths.
105
+ const stored = new Map(scanWorkspaceFiles(root).map(item => [item.path, item.bytes]));
106
+ const records = raw.map(item => {
107
+ const converted = stored.get(item.path);
108
+ if (!converted) fail('inventory-unsupported');
109
+ // An unexplained non-CR difference means the two readers observed different bytes.
110
+ if (!item.raw.includes(13) && !item.raw.equals(converted)) fail('snapshot-changed');
111
+ return fileRecord(item.path, item.mode, converted);
112
+ });
113
+ for (const item of baseline.records) if (!stored.has(item.path)) records.push(fileRecord(item.path, '', Buffer.alloc(0), 'deleted'));
114
+ return records;
115
+ }
116
+ const progressKeys = new Set(['status', 'state', 'evidence', 'loaded', 'loadedAt', 'updatedAt', 'createdAt', 'completedAt', 'timestamp', 'report', 'reports']);
117
+ // Progress fields are excluded only at their defined record locations (top-level task/workflow, each
118
+ // requirement, each requirement binding). Nested objects keep every key: a nested `status`/`state` there
119
+ // is semantic and must change taskScopeDigest (PR #353 review P2).
120
+ function withoutProgress(value, keys = progressKeys) {
121
+ if (!U.record(value)) return U.clone(value);
122
+ return Object.fromEntries(Object.keys(value).filter(key => !keys.has(key)).map(key => [key, U.clone(value[key])]));
123
+ }
124
+ // A binding's `report`/`reports` object configures the binding; only its load/status records are progress.
125
+ const bindingProgressKeys = new Set([...progressKeys].filter(key => key !== 'report' && key !== 'reports'));
126
+ function taskSemantics(task) {
127
+ const semantic = withoutProgress(task);
128
+ if (Array.isArray(task?.requirements)) {
129
+ semantic.requirements = task.requirements.map(requirement => {
130
+ const item = withoutProgress(requirement);
131
+ if (U.record(requirement) && Array.isArray(requirement.bindings)) item.bindings = requirement.bindings.map(binding => withoutProgress(binding, bindingProgressKeys));
132
+ return item;
133
+ });
134
+ }
135
+ return semantic;
136
+ }
137
+ function contextFor(input, provider) {
138
+ if (!provider || typeof provider.resolveContext !== 'function') fail('origin-unavailable');
139
+ const response = provider.resolveContext({ workspaceRoot: input.workspaceRoot, workflowId: input.workflow.workflowId, sessionId: input.workflow.sessionId });
140
+ if (response?.status !== 'accepted') fail(response?.status === 'rejected' ? 'origin-untrusted' : 'origin-unavailable');
141
+ const c = response.context;
142
+ if (!U.record(c) || !U.text(response.originRef) || !U.text(c.workspaceId) || !U.text(c.baselineReference)
143
+ || !U.text(c.principals?.author) || !Array.isArray(c.principals?.executors) || !c.principals.executors.every(U.text)) fail('origin-untrusted');
144
+ for (const key of ['sessionId', 'workflowId', 'workflowRevision']) if (c[key] !== input.workflow[key]) fail('identity-mismatch');
145
+ if (c.taskId !== input.task.taskId || U.stable(c.activeHost) !== U.stable(input.activeHost)) fail('identity-mismatch');
146
+ if (c.workspaceRoot && !samePath(c.workspaceRoot, input.workspaceRoot)) fail('identity-mismatch');
147
+ // The provider must bind the concrete workflow-start pin; a bare reference cannot authorize a
148
+ // re-anchored baseline (PR #353 review P1).
149
+ if (!U.record(c.baseline) || !U.text(c.baseline.commit) || !U.text(c.baseline.root)
150
+ || !U.record(input.baseline) || c.baseline.commit !== input.baseline.commit || !samePath(c.baseline.root, input.baseline.root)) fail('baseline-unavailable');
151
+ const root = canonicalPath(input.workspaceRoot);
152
+ return U.clone({ ...c, baseline: { ...c.baseline, root }, workspaceRoot: root, originRef: response.originRef });
153
+ }
154
+ function filesOf(records, config) {
155
+ // The inventory is whole-project: binary records keep their stored bytes in the identity framing and are
156
+ // omitted from semantic derivation unless the derive contract can observe them (PR #353 review P2).
157
+ return U.deriveFiles(records, config);
158
+ }
159
+ function produceDocSnapshot(input, dependencies = {}) {
160
+ const originProvider = dependencies?.originProvider;
161
+ try {
162
+ if (!U.record(input) || !U.text(input.workspaceRoot) || !U.record(input.workflow) || !U.record(input.task)
163
+ || !U.text(input.task.taskId) || !Array.isArray(input.task.requirements) || !U.record(input.scope)
164
+ || !Number.isInteger(input.workflow.workflowRevision) || input.workflow.workflowRevision < 0) fail('invalid-input');
165
+ if ((input.prdRoot === undefined ? 'docs/prd/' : input.prdRoot) !== 'docs/prd/' || (input.scope.prdRoot && input.scope.prdRoot !== 'docs/prd/')) fail('unsupported-prd-root');
166
+ const root = canonicalPath(input.workspaceRoot);
167
+ const detached = U.clone(input);
168
+ const originContext = contextFor(detached, originProvider);
169
+ const baseline = captureBaseline(root, detached.baseline);
170
+ const config = resolveDocsConfigCaptured(root, { activeHost: detached.activeHost });
171
+ if (!['project', 'host'].includes(config.resolution.status) || config.capture.sources.some(s => s.presence === 'unreadable')) fail('config-unavailable');
172
+ const coverage = { kind: 'whole-project', excluded: ['.git'], scope: U.clone(detached.scope) };
173
+ const candidate = { records: captureCandidate(root, baseline), coverage };
174
+ // Same owned config bytes must be the bytes covered by the working-tree capture.
175
+ for (const source of config.capture.sources) {
176
+ const file = candidate.records.find(r => r.path === source.path && r.presence === 'present');
177
+ if (source.presence === 'present' ? !file || !U.bytes(source.bytes).equals(file.storedBytes) : Boolean(file)) fail('snapshot-changed');
178
+ }
179
+ config.digest = U.configDigest(config);
180
+ baseline.digest = U.snapshotDigest('baseline', baseline, coverage, baseline.records);
181
+ candidate.digest = U.snapshotDigest('candidate', baseline, coverage, candidate.records);
182
+ const planIdentity = { task: taskSemantics(detached.task), scope: U.clone(detached.scope), workflow: withoutProgress(detached.workflow), coverage, configDigest: config.digest, prdRoot: 'docs/prd/' };
183
+ const identity = { workspaceId: originContext.workspaceId, sessionId: originContext.sessionId, workflowId: originContext.workflowId,
184
+ workflowRevision: originContext.workflowRevision, taskId: originContext.taskId, taskScopeDigest: U.structuredDigest('doc-task-scope-v1', planIdentity),
185
+ baselineDigest: baseline.digest, candidateDigest: candidate.digest, configDigest: config.digest, prdRoot: 'docs/prd/' };
186
+ const derived = deriveDocFacts({ base: { files: filesOf(baseline.records, config.resolution.config) },
187
+ candidate: { files: filesOf(candidate.records, config.resolution.config) }, config: config.resolution.config });
188
+ if (!derived.facts?.inputKnown) fail('inventory-incomplete');
189
+ const facts = derived.facts;
190
+ const obligations = deriveDocObligations(facts);
191
+ const derivationReasons = Object.fromEntries(obligations.map(o => [o.id, { applicability: o.applicability, reason: `captured facts derive ${o.capability}: ${o.applicability}`, factRefs: ['facts', ...(o.scope?.paths || []).map(p => `inventory:${p}`)] }]));
192
+ const again = captureCandidate(root, baseline);
193
+ const configAgain = resolveDocsConfigCaptured(root, { activeHost: detached.activeHost });
194
+ let finalContext;
195
+ try { finalContext = contextFor(detached, originProvider); }
196
+ catch { fail('snapshot-changed'); }
197
+ if (U.snapshotDigest('candidate', baseline, coverage, again) !== candidate.digest || U.configDigest(configAgain) !== config.digest
198
+ || U.stable(originContext) !== U.stable(finalContext)) fail('snapshot-changed');
199
+ return { state: 'ready', snapshot: { identity, baseline, candidate, config, originContext, taskSemantics: planIdentity, facts, obligations, derivationReasons }, diagnostics: [] };
200
+ } catch (error) {
201
+ const codes = ['invalid-input', 'baseline-unavailable', 'inventory-incomplete', 'inventory-unsupported', 'snapshot-changed', 'config-unavailable', 'unsupported-prd-root', 'identity-mismatch', 'origin-unavailable', 'origin-untrusted'];
202
+ const code = codes.includes(error.message) ? error.message : 'inventory-unsupported';
203
+ return { state: 'unavailable', snapshot: null, diagnostics: [U.diagnostic(code)] };
204
+ }
205
+ }
206
+ module.exports = { produceDocSnapshot };
@@ -4,7 +4,7 @@ description: "Scaffold a verified multi-agent workspace and select bounded speci
4
4
  license: Apache-2.0
5
5
  metadata:
6
6
  author: Miya Daniel
7
- version: 0.36.3
7
+ version: 0.39.0
8
8
  ---
9
9
 
10
10
  # Multi-Agent Workspace