harness-everything 0.47.4 → 0.48.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 +2 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +33 -0
- package/behavioral-evals/README.md +17 -0
- package/behavioral-evals/case-validator.js +15 -1
- package/behavioral-evals/cases/baseline-tdd-workflow.yaml +4 -0
- package/behavioral-evals/run.js +172 -0
- package/behavioral-evals/transcript-parser.js +43 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2a/run13-workflow-requirements.json +42 -0
- package/ci/mechanism-2s-tdd-quality.test.js +149 -6
- package/ci/mechanism-e1-dependency-artifact-scope.test.js +657 -0
- package/ci/mechanism-prb-outline-review.test.js +1 -1
- package/ci/test-behavioral-case-validation.js +215 -1
- package/code-review/SKILL.md +1 -1
- package/code-review/references/review-playbook.md +9 -0
- package/docs/doc-workflow-reconciliation.md +3 -1
- package/harness-everything/SKILL.md +1 -1
- package/harness-everything/scripts/doc-lifecycle.js +11 -3
- package/harness-everything/scripts/project-docs-resolver.js +12 -0
- package/hooks/scripts/lib/doc-reconciliation-core.js +56 -0
- package/hooks/scripts/lib/doc-reconciliation-producer.js +274 -12
- package/hooks/scripts/lib/doc-reconciliation-provider.js +1 -1
- package/multi-agent-workspace/SKILL.md +1 -1
- package/multi-agent-workspace/scripts/project-docs-resolver.js +12 -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-core.js +56 -0
- package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-producer.js +274 -12
- package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-provider.js +1 -1
- package/plugins/harness-everything/plugin.json +1 -1
- package/plugins/harness-everything/skills/code-review/SKILL.md +1 -1
- package/plugins/harness-everything/skills/code-review/references/review-playbook.md +9 -0
- package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
- package/plugins/harness-everything/skills/harness-everything/scripts/doc-lifecycle.js +11 -3
- package/plugins/harness-everything/skills/harness-everything/scripts/project-docs-resolver.js +12 -0
- package/plugins/harness-everything/skills/multi-agent-workspace/SKILL.md +1 -1
- package/plugins/harness-everything/skills/multi-agent-workspace/scripts/project-docs-resolver.js +12 -0
- package/plugins/harness-everything/skills/tdd/SKILL.md +1 -1
- package/plugins/harness-everything/skills/tdd/fixtures/quality-scenarios.json +10 -2
- package/plugins/harness-everything/skills/tdd/references/quality-model.md +2 -0
- package/plugins/harness-everything/skills/tdd/schemas/quality-evidence.schema.json +21 -0
- package/plugins/harness-everything/skills/tdd/schemas/quality-report.schema.json +2 -0
- package/plugins/harness-everything/skills/tdd/scripts/quality-gate.js +165 -1
- package/plugins/harness-everything/skills/to-spec/SKILL.md +1 -1
- package/plugins/harness-everything/skills/to-spec/scripts/project-docs-resolver.js +12 -0
- package/plugins/harness-everything/skills/zoom-out/SKILL.md +1 -1
- package/plugins/harness-everything/skills/zoom-out/references/circuit-breaker.md +1 -0
- package/sbom.cdx.json +7 -7
- package/tdd/SKILL.md +1 -1
- package/tdd/fixtures/quality-scenarios.json +10 -2
- package/tdd/references/quality-model.md +2 -0
- package/tdd/schemas/quality-evidence.schema.json +21 -0
- package/tdd/schemas/quality-report.schema.json +2 -0
- package/tdd/scripts/quality-gate.js +165 -1
- package/to-spec/SKILL.md +1 -1
- package/to-spec/scripts/project-docs-resolver.js +12 -0
- package/zoom-out/SKILL.md +1 -1
- package/zoom-out/references/circuit-breaker.md +1 -0
|
@@ -4,14 +4,17 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { execFileSync } = require('child_process');
|
|
6
6
|
const { _internals: U } = require('./doc-reconciliation-core');
|
|
7
|
-
const {
|
|
8
|
-
const { resolveDocsConfigCaptured } = U.runtimeModule('project-docs-resolver.js');
|
|
7
|
+
const { convertWorkspaceFiles } = U.runtimeModule('doc-lifecycle.js');
|
|
8
|
+
const { resolveDocsConfigCaptured, validateDocsConfig } = U.runtimeModule('project-docs-resolver.js');
|
|
9
9
|
const { deriveDocFacts, deriveDocObligations } = U.runtimeModule('doc-obligations.js');
|
|
10
10
|
|
|
11
11
|
const MAX_FILES = 10000;
|
|
12
12
|
const MAX_BYTES = 64 * 1024 * 1024;
|
|
13
13
|
const POSIX_MODES = process.platform !== 'win32';
|
|
14
|
-
|
|
14
|
+
const PROJECT_CONFIG_PATH = '.agents/harness-everything/project-docs.json';
|
|
15
|
+
const SUPPORTED_ARTIFACT_ROOTS = new Set(['node_modules', '.husky/_']);
|
|
16
|
+
const POLICY_VERSION = 'dependency-artifact-scope/1';
|
|
17
|
+
function fail(code, reasonCode) { throw Object.assign(new Error(code), reasonCode ? { reasonCode } : {}); }
|
|
15
18
|
// The JS realpath keeps Windows 8.3 spellings (RUNNER~1) while Git reports the long name, so
|
|
16
19
|
// directory identity uses native resolution and is case-insensitive on Windows (as workflow-isolation).
|
|
17
20
|
function canonicalPath(value) { return fs.realpathSync.native(path.resolve(value)); }
|
|
@@ -64,7 +67,249 @@ function captureBaseline(root, pin) {
|
|
|
64
67
|
validatePaths(records);
|
|
65
68
|
return { commit, treeRef, records };
|
|
66
69
|
}
|
|
67
|
-
|
|
70
|
+
|
|
71
|
+
function pathUnderRoot(relative, root) {
|
|
72
|
+
const candidate = String(relative).replace(/\\/g, '/').normalize('NFC').toLowerCase();
|
|
73
|
+
const expected = root.normalize('NFC').toLowerCase();
|
|
74
|
+
return candidate === expected || candidate.startsWith(`${expected}/`);
|
|
75
|
+
}
|
|
76
|
+
function rootMention(value, root) {
|
|
77
|
+
const normalized = String(value).replace(/\\/g, '/').normalize('NFC').toLowerCase();
|
|
78
|
+
const needle = root.normalize('NFC').toLowerCase();
|
|
79
|
+
let offset = 0;
|
|
80
|
+
while ((offset = normalized.indexOf(needle, offset)) >= 0) {
|
|
81
|
+
const before = offset === 0 || !/[a-z0-9_.-]/.test(normalized[offset - 1]);
|
|
82
|
+
const after = offset + needle.length;
|
|
83
|
+
const boundary = after === normalized.length || /[/*?\s"'`:#),;\]]/.test(normalized[after]);
|
|
84
|
+
if (before && boundary) return true;
|
|
85
|
+
offset++;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
function globSegmentMatches(rawPattern, rawSegment) {
|
|
90
|
+
// Same NFC + lowercase folding as rootMention()/pathUnderRoot(), so a wildcard spelling cannot dodge it.
|
|
91
|
+
const pattern = rawPattern.normalize('NFC').toLowerCase();
|
|
92
|
+
const segment = rawSegment.normalize('NFC').toLowerCase();
|
|
93
|
+
if (/[\[\]{}]/.test(pattern)) return true;
|
|
94
|
+
const escaped = pattern.split(/([*?])/).map(part => part === '*' ? '[\\s\\S]*' : part === '?' ? '[\\s\\S]'
|
|
95
|
+
: part.replace(/[|\\{}()[\]^$+?.]/g, '\\$&')).join('');
|
|
96
|
+
return new RegExp(`^${escaped}$`).test(segment);
|
|
97
|
+
}
|
|
98
|
+
function relativeSelectorMayIntersectRoot(selector, root) {
|
|
99
|
+
const normalized = selector.replace(/\\/g, '/');
|
|
100
|
+
if (/[\[\]{}]/.test(normalized) || normalized.split('/').includes('..')) return true;
|
|
101
|
+
// `.` and empty segments are redundant anywhere in a workspace-relative selector.
|
|
102
|
+
const pattern = normalized.split('/').filter(segment => segment !== '' && segment !== '.');
|
|
103
|
+
const target = root.split('/');
|
|
104
|
+
let states = new Set([0]);
|
|
105
|
+
const closure = values => {
|
|
106
|
+
const expanded = new Set(values);
|
|
107
|
+
let changed = true;
|
|
108
|
+
while (changed) {
|
|
109
|
+
changed = false;
|
|
110
|
+
for (const index of expanded) {
|
|
111
|
+
if (pattern[index] === '**' && !expanded.has(index + 1)) {
|
|
112
|
+
expanded.add(index + 1);
|
|
113
|
+
changed = true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return expanded;
|
|
118
|
+
};
|
|
119
|
+
states = closure(states);
|
|
120
|
+
for (const segment of target) {
|
|
121
|
+
const next = new Set();
|
|
122
|
+
for (const index of states) {
|
|
123
|
+
if (index >= pattern.length) continue;
|
|
124
|
+
if (pattern[index] === '**') next.add(index);
|
|
125
|
+
else if (globSegmentMatches(pattern[index], segment)) next.add(index + 1);
|
|
126
|
+
}
|
|
127
|
+
states = closure(next);
|
|
128
|
+
if (!states.size) return false;
|
|
129
|
+
}
|
|
130
|
+
return states.size > 0;
|
|
131
|
+
}
|
|
132
|
+
function relativePathWithin(workspace, candidate, pathApi) {
|
|
133
|
+
const relative = pathApi.relative(workspace, candidate);
|
|
134
|
+
if (!relative) return '';
|
|
135
|
+
const normalized = relative.replace(/\\/g, '/');
|
|
136
|
+
// Case and filesystem aliases that cannot be resolved exactly must fail closed.
|
|
137
|
+
const workspaceParts = workspace.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
138
|
+
const candidateParts = candidate.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
139
|
+
const samePrefix = workspaceParts.length <= candidateParts.length
|
|
140
|
+
&& workspaceParts.every((part, index) => part.normalize('NFC').toLowerCase()
|
|
141
|
+
=== candidateParts[index].normalize('NFC').toLowerCase());
|
|
142
|
+
if (samePrefix) return candidateParts.slice(workspaceParts.length).join('/');
|
|
143
|
+
if (pathApi.isAbsolute(relative) || normalized === '..' || normalized.startsWith('../')) return null;
|
|
144
|
+
return normalized;
|
|
145
|
+
}
|
|
146
|
+
function realpathWithMissingSuffix(candidate, pathApi) {
|
|
147
|
+
let current = candidate;
|
|
148
|
+
const suffix = [];
|
|
149
|
+
while (true) {
|
|
150
|
+
try { return pathApi.resolve(fs.realpathSync.native(current), ...suffix); }
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (!['ENOENT', 'ENOTDIR'].includes(error.code)) return null;
|
|
153
|
+
const parent = pathApi.dirname(current);
|
|
154
|
+
if (parent === current) return null;
|
|
155
|
+
suffix.unshift(pathApi.basename(current));
|
|
156
|
+
current = parent;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function absoluteSelectorRelatives(selector, workspaceRoot) {
|
|
161
|
+
const windowsDrive = /^[A-Za-z]:/.test(selector);
|
|
162
|
+
if ((windowsDrive || selector.startsWith('\\') || selector.startsWith('//')) && process.platform !== 'win32') return true;
|
|
163
|
+
const pathApi = process.platform === 'win32' ? path.win32 : path;
|
|
164
|
+
if (windowsDrive && !pathApi.isAbsolute(selector)) return true;
|
|
165
|
+
if (!pathApi.isAbsolute(selector)) return null;
|
|
166
|
+
|
|
167
|
+
const workspace = canonicalPath(workspaceRoot);
|
|
168
|
+
const absolute = pathApi.resolve(selector);
|
|
169
|
+
const lexicalRelative = relativePathWithin(workspace, absolute, pathApi);
|
|
170
|
+
const resolved = realpathWithMissingSuffix(absolute, pathApi);
|
|
171
|
+
if (!resolved) return true;
|
|
172
|
+
const realRelative = relativePathWithin(workspace, resolved, pathApi);
|
|
173
|
+
if (lexicalRelative !== null && realRelative !== null) return [lexicalRelative, realRelative];
|
|
174
|
+
if (lexicalRelative !== null || realRelative !== null) return true;
|
|
175
|
+
// A glob outside the workspace can still expand into it (via a parent such as /tmp/*/node_modules/**
|
|
176
|
+
// or a symlinked entry under an unrelated directory); only literal outside paths are provably disjoint.
|
|
177
|
+
if (/[*?\[\]{}]/.test(selector)) return true;
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
function selectorMayIntersectRoot(selector, root, workspaceRoot) {
|
|
181
|
+
if (typeof selector !== 'string' || !selector.trim()) return true;
|
|
182
|
+
const absolute = absoluteSelectorRelatives(selector, workspaceRoot);
|
|
183
|
+
if (absolute === true) return true;
|
|
184
|
+
if (absolute === false) return false;
|
|
185
|
+
if (absolute !== null) return absolute.some(relative => relativeSelectorMayIntersectRoot(relative || '.', root));
|
|
186
|
+
return relativeSelectorMayIntersectRoot(selector, root);
|
|
187
|
+
}
|
|
188
|
+
function valueMayIntersectRoots(value, roots, workspaceRoot) {
|
|
189
|
+
if (typeof value === 'string') {
|
|
190
|
+
const absolute = absoluteSelectorRelatives(value, workspaceRoot);
|
|
191
|
+
if (absolute === true) return true;
|
|
192
|
+
if (absolute === false) return false;
|
|
193
|
+
if (absolute !== null) return roots.some(root => absolute.some(relative => {
|
|
194
|
+
const selector = relative || '.';
|
|
195
|
+
return rootMention(selector, root)
|
|
196
|
+
|| ((selector.includes('/') || /[*?\[\]{}]/.test(selector)) && selectorMayIntersectRoot(selector, root, workspaceRoot));
|
|
197
|
+
}));
|
|
198
|
+
return roots.some(root => rootMention(value, root)
|
|
199
|
+
|| ((!value.trim() || value.includes('/') || /[*?\[\]{}]/.test(value))
|
|
200
|
+
&& selectorMayIntersectRoot(value, root, workspaceRoot)));
|
|
201
|
+
}
|
|
202
|
+
if (Array.isArray(value)) return value.some(item => valueMayIntersectRoots(item, roots, workspaceRoot));
|
|
203
|
+
if (U.record(value)) return Object.entries(value).some(([key, item]) => valueMayIntersectRoots(key, roots, workspaceRoot)
|
|
204
|
+
|| valueMayIntersectRoots(item, roots, workspaceRoot));
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
function withoutTaskPinIdentity(value) {
|
|
208
|
+
if (Array.isArray(value)) return value.map(withoutTaskPinIdentity);
|
|
209
|
+
if (!U.record(value)) return value;
|
|
210
|
+
return Object.fromEntries(Object.entries(value)
|
|
211
|
+
.filter(([key]) => !/ids?$/i.test(key))
|
|
212
|
+
.map(([key, item]) => [key, withoutTaskPinIdentity(item)]));
|
|
213
|
+
}
|
|
214
|
+
function projectConfigFromBytes(bytes) {
|
|
215
|
+
try {
|
|
216
|
+
const config = JSON.parse(Buffer.from(bytes).toString('utf8'));
|
|
217
|
+
return { config, valid: validateDocsConfig(config).valid };
|
|
218
|
+
} catch { return { config: null, valid: false }; }
|
|
219
|
+
}
|
|
220
|
+
function baselineProjectConfig(baseline) {
|
|
221
|
+
const source = baseline.records.find(item => item.path === PROJECT_CONFIG_PATH && item.presence === 'present');
|
|
222
|
+
return source ? { source, ...projectConfigFromBytes(source.storedBytes) } : { source: null, config: null, valid: false };
|
|
223
|
+
}
|
|
224
|
+
function capturedProjectConfig(config) {
|
|
225
|
+
const source = config.capture.sources.find(item => item.path === PROJECT_CONFIG_PATH);
|
|
226
|
+
return source && source.presence === 'present'
|
|
227
|
+
? { source, ...projectConfigFromBytes(source.bytes) }
|
|
228
|
+
: { source: source || null, config: null, valid: false };
|
|
229
|
+
}
|
|
230
|
+
function dependencyRoots(config) {
|
|
231
|
+
return U.record(config) && U.record(config.inventory)
|
|
232
|
+
&& Object.prototype.hasOwnProperty.call(config.inventory, 'dependencyArtifactRoots')
|
|
233
|
+
? config.inventory.dependencyArtifactRoots : undefined;
|
|
234
|
+
}
|
|
235
|
+
function sameRootField(left, right) {
|
|
236
|
+
if (left === undefined || right === undefined) return left === right;
|
|
237
|
+
return U.stable([...left].sort()) === U.stable([...right].sort());
|
|
238
|
+
}
|
|
239
|
+
function validateExcludedRoots(workspaceRoot, roots) {
|
|
240
|
+
const workspace = canonicalPath(workspaceRoot);
|
|
241
|
+
const states = [];
|
|
242
|
+
for (const root of roots) {
|
|
243
|
+
let parent = workspace;
|
|
244
|
+
let presence = 'present-directory';
|
|
245
|
+
for (const component of root.split('/')) {
|
|
246
|
+
let entries;
|
|
247
|
+
try { entries = fs.readdirSync(parent, { withFileTypes: true }); }
|
|
248
|
+
catch { fail('inventory-incomplete', 'dependency-root-parent-unreadable'); }
|
|
249
|
+
const aliases = entries.filter(entry => entry.name.normalize('NFC').toLowerCase() === component.normalize('NFC').toLowerCase());
|
|
250
|
+
if (aliases.length > 1 || aliases.some(entry => entry.name !== component)) fail('inventory-unsupported', 'dependency-root-alias');
|
|
251
|
+
const current = path.join(parent, component);
|
|
252
|
+
let stat;
|
|
253
|
+
try { stat = fs.lstatSync(current); }
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (error.code === 'ENOENT') { presence = 'absent'; break; }
|
|
256
|
+
fail('inventory-incomplete', 'dependency-root-unreadable');
|
|
257
|
+
}
|
|
258
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) fail('inventory-unsupported', 'dependency-root-not-plain-directory');
|
|
259
|
+
let real;
|
|
260
|
+
try { real = fs.realpathSync.native(current); }
|
|
261
|
+
catch { fail('inventory-incomplete', 'dependency-root-unreadable'); }
|
|
262
|
+
const within = process.platform === 'win32'
|
|
263
|
+
? real.toLowerCase().startsWith(`${workspace.toLowerCase()}${path.sep}`)
|
|
264
|
+
: real.startsWith(`${workspace}${path.sep}`);
|
|
265
|
+
if (!within) fail('inventory-unsupported', 'dependency-root-outside-workspace');
|
|
266
|
+
parent = current;
|
|
267
|
+
}
|
|
268
|
+
states.push({ path: root, presence });
|
|
269
|
+
}
|
|
270
|
+
return states;
|
|
271
|
+
}
|
|
272
|
+
function guardedPolicy(root, baseline, capturedConfig, input) {
|
|
273
|
+
const base = baselineProjectConfig(baseline);
|
|
274
|
+
const current = capturedProjectConfig(capturedConfig);
|
|
275
|
+
const baselineRoots = base.valid ? dependencyRoots(base.config) : undefined;
|
|
276
|
+
const candidateRoots = current.valid ? dependencyRoots(current.config) : undefined;
|
|
277
|
+
if (current.valid && candidateRoots !== undefined && (!base.valid || !sameRootField(baselineRoots, candidateRoots))) {
|
|
278
|
+
fail('inventory-unsupported', 'dependency-artifact-policy-changed-or-unpinned');
|
|
279
|
+
}
|
|
280
|
+
if (!base.valid || baselineRoots === undefined || baselineRoots.length === 0) return null;
|
|
281
|
+
if (!current.valid || !sameRootField(baselineRoots, candidateRoots)) fail('inventory-unsupported', 'dependency-artifact-policy-changed-or-unpinned');
|
|
282
|
+
const roots = [...baselineRoots].sort();
|
|
283
|
+
const indexed = git(root, ['ls-files', '-z']).toString('utf8').split('\0').filter(Boolean);
|
|
284
|
+
if (baseline.records.some(item => roots.some(value => pathUnderRoot(item.path, value)))) fail('inventory-unsupported', 'baseline-path-overlap');
|
|
285
|
+
if (indexed.some(item => roots.some(value => pathUnderRoot(item, value)))) fail('inventory-unsupported', 'index-path-overlap');
|
|
286
|
+
if (capturedConfig.capture.sources.some(item => roots.some(value => pathUnderRoot(item.path, value)))) {
|
|
287
|
+
fail('inventory-unsupported', 'config-source-overlap');
|
|
288
|
+
}
|
|
289
|
+
const configs = [base.config, capturedConfig.resolution.config];
|
|
290
|
+
if (configs.some(config => {
|
|
291
|
+
if (!U.record(config)) return false;
|
|
292
|
+
const selectors = Object.fromEntries(Object.entries(config).filter(([key]) => key !== 'inventory'));
|
|
293
|
+
return valueMayIntersectRoots(selectors, roots, root);
|
|
294
|
+
})) fail('inventory-unsupported', 'config-selector-overlap');
|
|
295
|
+
if (valueMayIntersectRoots(withoutTaskPinIdentity(input.task), roots, root) || valueMayIntersectRoots(input.scope, roots, root)) {
|
|
296
|
+
fail('inventory-unsupported', 'task-input-overlap');
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
roots,
|
|
300
|
+
baselineCommit: baseline.commit,
|
|
301
|
+
storedBytesDigest: U.sha(base.source.storedBytes),
|
|
302
|
+
guardOutcomes: [
|
|
303
|
+
{ guard: 'baseline-paths', state: 'clear', reason: 'No baseline path intersects an approved root.' },
|
|
304
|
+
{ guard: 'index-paths', state: 'clear', reason: 'No current index path intersects an approved root.' },
|
|
305
|
+
{ guard: 'config-sources', state: 'clear', reason: 'No baseline or current config source intersects an approved root.' },
|
|
306
|
+
{ guard: 'config-selectors', state: 'clear', reason: 'Baseline and current config declarations are disjoint from approved roots.' },
|
|
307
|
+
{ guard: 'task-inputs', state: 'clear', reason: 'No explicit task or requirement input intersects an approved root.' }
|
|
308
|
+
]
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function captureCandidate(root, baseline, excludedRoots = []) {
|
|
312
|
+
const excludedRootStates = excludedRoots.length ? validateExcludedRoots(root, excludedRoots) : [];
|
|
68
313
|
const raw = [];
|
|
69
314
|
let total = 0;
|
|
70
315
|
let visited = 0;
|
|
@@ -72,8 +317,9 @@ function captureCandidate(root, baseline) {
|
|
|
72
317
|
const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => U.compare(a.name, b.name));
|
|
73
318
|
for (const entry of entries) {
|
|
74
319
|
if (!prefix && entry.name === '.git') continue;
|
|
75
|
-
if (++visited > MAX_FILES) fail('inventory-unsupported');
|
|
76
320
|
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
321
|
+
if (excludedRoots.includes(relative)) continue;
|
|
322
|
+
if (++visited > MAX_FILES) fail('inventory-unsupported');
|
|
77
323
|
const absolute = path.join(dir, entry.name);
|
|
78
324
|
const stat = fs.lstatSync(absolute);
|
|
79
325
|
if (stat.isDirectory()) {
|
|
@@ -102,7 +348,8 @@ function captureCandidate(root, baseline) {
|
|
|
102
348
|
}
|
|
103
349
|
}
|
|
104
350
|
// Lifecycle owns text/eol/autocrlf conversion. A completeness walk above detects its skipped paths.
|
|
105
|
-
const stored = new Map(
|
|
351
|
+
const stored = new Map(convertWorkspaceFiles(root, raw.map(item => ({ path: item.path, bytes: item.raw })))
|
|
352
|
+
.map(item => [item.path, item.bytes]));
|
|
106
353
|
const records = raw.map(item => {
|
|
107
354
|
const converted = stored.get(item.path);
|
|
108
355
|
if (!converted) fail('inventory-unsupported');
|
|
@@ -111,7 +358,7 @@ function captureCandidate(root, baseline) {
|
|
|
111
358
|
return fileRecord(item.path, item.mode, converted);
|
|
112
359
|
});
|
|
113
360
|
for (const item of baseline.records) if (!stored.has(item.path)) records.push(fileRecord(item.path, '', Buffer.alloc(0), 'deleted'));
|
|
114
|
-
return records;
|
|
361
|
+
return { records, excludedRootStates };
|
|
115
362
|
}
|
|
116
363
|
const progressKeys = new Set(['status', 'state', 'evidence', 'loaded', 'loadedAt', 'updatedAt', 'createdAt', 'completedAt', 'timestamp', 'report', 'reports']);
|
|
117
364
|
// Progress fields are excluded only at their defined record locations (top-level task/workflow, each
|
|
@@ -169,8 +416,20 @@ function produceDocSnapshot(input, dependencies = {}) {
|
|
|
169
416
|
const baseline = captureBaseline(root, detached.baseline);
|
|
170
417
|
const config = resolveDocsConfigCaptured(root, { activeHost: detached.activeHost });
|
|
171
418
|
if (!['project', 'host'].includes(config.resolution.status) || config.capture.sources.some(s => s.presence === 'unreadable')) fail('config-unavailable');
|
|
172
|
-
const
|
|
173
|
-
const
|
|
419
|
+
const policy = guardedPolicy(root, baseline, config, detached);
|
|
420
|
+
const firstCapture = captureCandidate(root, baseline, policy?.roots || []);
|
|
421
|
+
const coverage = policy ? {
|
|
422
|
+
kind: 'project-with-dependency-artifact-exclusions',
|
|
423
|
+
policyVersion: POLICY_VERSION,
|
|
424
|
+
policySource: { path: PROJECT_CONFIG_PATH, baselineCommit: policy.baselineCommit, storedBytesDigest: policy.storedBytesDigest },
|
|
425
|
+
dependencyArtifactRoots: policy.roots,
|
|
426
|
+
excluded: ['.git', ...policy.roots],
|
|
427
|
+
excludedRootStates: firstCapture.excludedRootStates,
|
|
428
|
+
guardOutcomes: [...policy.guardOutcomes,
|
|
429
|
+
{ guard: 'excluded-root-types', state: 'clear', reason: 'Each approved root is an in-workspace directory or absent.', roots: firstCapture.excludedRootStates }],
|
|
430
|
+
scope: U.clone(detached.scope)
|
|
431
|
+
} : { kind: 'whole-project', excluded: ['.git'], scope: U.clone(detached.scope) };
|
|
432
|
+
const candidate = { records: firstCapture.records, coverage };
|
|
174
433
|
// Same owned config bytes must be the bytes covered by the working-tree capture.
|
|
175
434
|
for (const source of config.capture.sources) {
|
|
176
435
|
const file = candidate.records.find(r => r.path === source.path && r.presence === 'present');
|
|
@@ -189,18 +448,21 @@ function produceDocSnapshot(input, dependencies = {}) {
|
|
|
189
448
|
const facts = derived.facts;
|
|
190
449
|
const obligations = deriveDocObligations(facts);
|
|
191
450
|
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);
|
|
451
|
+
const again = captureCandidate(root, baseline, policy?.roots || []);
|
|
193
452
|
const configAgain = resolveDocsConfigCaptured(root, { activeHost: detached.activeHost });
|
|
453
|
+
const policyAgain = guardedPolicy(root, baseline, configAgain, detached);
|
|
194
454
|
let finalContext;
|
|
195
455
|
try { finalContext = contextFor(detached, originProvider); }
|
|
196
456
|
catch { fail('snapshot-changed'); }
|
|
197
|
-
if (U.snapshotDigest('candidate', baseline, coverage, again) !== candidate.digest
|
|
457
|
+
if (U.snapshotDigest('candidate', baseline, coverage, again.records) !== candidate.digest
|
|
458
|
+
|| U.stable(again.excludedRootStates) !== U.stable(firstCapture.excludedRootStates)
|
|
459
|
+
|| U.stable(policyAgain) !== U.stable(policy) || U.configDigest(configAgain) !== config.digest
|
|
198
460
|
|| U.stable(originContext) !== U.stable(finalContext)) fail('snapshot-changed');
|
|
199
461
|
return { state: 'ready', snapshot: { identity, baseline, candidate, config, originContext, taskSemantics: planIdentity, facts, obligations, derivationReasons }, diagnostics: [] };
|
|
200
462
|
} catch (error) {
|
|
201
463
|
const codes = ['invalid-input', 'baseline-unavailable', 'inventory-incomplete', 'inventory-unsupported', 'snapshot-changed', 'config-unavailable', 'unsupported-prd-root', 'identity-mismatch', 'origin-unavailable', 'origin-untrusted'];
|
|
202
464
|
const code = codes.includes(error.message) ? error.message : 'inventory-unsupported';
|
|
203
|
-
return { state: 'unavailable', snapshot: null, diagnostics: [U.diagnostic(code)] };
|
|
465
|
+
return { state: 'unavailable', snapshot: null, diagnostics: [U.diagnostic(code, error.reasonCode ? { reasonCode: error.reasonCode } : {})] };
|
|
204
466
|
}
|
|
205
467
|
}
|
|
206
468
|
module.exports = { produceDocSnapshot };
|
|
@@ -77,7 +77,7 @@ const OUTLINE_INVENTORY_PIN = Object.freeze({
|
|
|
77
77
|
inputSha: '0ef0541c6cae550e8269ba712e03797e0e987dfb',
|
|
78
78
|
approvalRef: { issue: 325, commentId: '6436207729', decision: 1 },
|
|
79
79
|
rowDomainMappings: Object.freeze([Object.freeze({ rowIds: Object.freeze(['15', '16', '17', '18']),
|
|
80
|
-
sourceDomain: 'VERI', domain: 'EVID', approvalRef: '
|
|
80
|
+
sourceDomain: 'VERI', domain: 'EVID', approvalRef: 'https://github.com/dyphn1/Harness-everything/issues/325#issuecomment-6706805112' })]),
|
|
81
81
|
retainedArtifactPath: `inventory/${OUTLINE_INVENTORY_SHA256}.md`,
|
|
82
82
|
});
|
|
83
83
|
const OUTLINE_MARKER = 'HARNESS_OUTLINE_DISPATCH_V1';
|
|
@@ -21,6 +21,7 @@ const PROJECT_DOCS_CONFIG = path.join('.agents', 'harness-everything', 'project-
|
|
|
21
21
|
const PROJECT_DOC_ROLES = Object.freeze(['prd', 'adr', 'ticket', 'reference', 'log', 'report']);
|
|
22
22
|
const PROJECT_DOC_CONFIG_KEYS = new Set([
|
|
23
23
|
'governed', 'domains', 'legacy', 'derived', 'tickets',
|
|
24
|
+
'inventory',
|
|
24
25
|
'docLocation', 'tracker', 'issueDefinition', 'contextMap', 'contextMapPath',
|
|
25
26
|
'decisionLocation', 'decisions', 'adrLocation', 'adr',
|
|
26
27
|
'domainLocation', 'contextLocation', 'contexts', 'domain',
|
|
@@ -327,6 +328,17 @@ function validateDocsConfig(config) {
|
|
|
327
328
|
}
|
|
328
329
|
}
|
|
329
330
|
|
|
331
|
+
if (Object.prototype.hasOwnProperty.call(config, 'inventory')) {
|
|
332
|
+
const inventory = config.inventory;
|
|
333
|
+
const roots = inventory && inventory.dependencyArtifactRoots;
|
|
334
|
+
const supportedRoots = new Set(['node_modules', '.husky/_']);
|
|
335
|
+
if (!isPlainObject(inventory) || Object.keys(inventory).some(key => key !== 'dependencyArtifactRoots')
|
|
336
|
+
|| !Array.isArray(roots) || roots.some(root => typeof root !== 'string' || !supportedRoots.has(root))
|
|
337
|
+
|| new Set(roots).size !== roots.length) {
|
|
338
|
+
invalid('invalid-dependency-artifact-roots', 'inventory.dependencyArtifactRoots must contain distinct exact roots from node_modules and .husky/_');
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
330
342
|
if (Object.prototype.hasOwnProperty.call(config, 'tickets') &&
|
|
331
343
|
config.tickets !== 'tracker' && !isRepoRelativePath(config.tickets)) {
|
|
332
344
|
invalid('invalid-tickets', 'tickets must be "tracker" or a repo-relative path');
|
package/package.json
CHANGED
|
@@ -12,6 +12,7 @@ function runtimeModule(name) {
|
|
|
12
12
|
}
|
|
13
13
|
const { checkDocObligations } = runtimeModule('doc-evidence.js');
|
|
14
14
|
const { deriveDocFacts, deriveDocObligations } = runtimeModule('doc-obligations.js');
|
|
15
|
+
const { validateDocsConfig } = runtimeModule('project-docs-resolver.js');
|
|
15
16
|
const text = value => typeof value === 'string' && value.trim().length > 0;
|
|
16
17
|
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
17
18
|
const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
@@ -38,6 +39,9 @@ const sameIdentity = (a, b) => validIdentity(a) && validIdentity(b) && identityF
|
|
|
38
39
|
const diagnostic = (code, context = {}) => ({ code, message: code.replace(/-/g, ' '), ...context });
|
|
39
40
|
const pathCompare = (a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path));
|
|
40
41
|
const codedError = code => Object.assign(new Error(code), { code });
|
|
42
|
+
const PROJECT_CONFIG_PATH = '.agents/harness-everything/project-docs.json';
|
|
43
|
+
const DEPENDENCY_ARTIFACT_ROOTS = new Set(['node_modules', '.husky/_']);
|
|
44
|
+
const DEPENDENCY_POLICY_VERSION = 'dependency-artifact-scope/1';
|
|
41
45
|
// The derive contract (doc-obligations.js) decides applicability from governed/legacy documents, derived
|
|
42
46
|
// outputs and code/REQ-test paths. Records outside them cannot affect document obligations.
|
|
43
47
|
// shortcut: mirrors derive path rules, update them together until a shared classifier is exposed.
|
|
@@ -136,6 +140,57 @@ function snapshotDigest(kind, baseline, coverage, records) {
|
|
|
136
140
|
for (const item of [...records].sort(pathCompare)) fields.push(item.path, item.presence, item.type, item.mode, bytes(item.storedBytes));
|
|
137
141
|
return hashFrames(fields);
|
|
138
142
|
}
|
|
143
|
+
function pathUnderDependencyRoot(relative, root) {
|
|
144
|
+
const candidate = String(relative).replace(/\\/g, '/').normalize('NFC').toLowerCase();
|
|
145
|
+
const prefix = root.normalize('NFC').toLowerCase();
|
|
146
|
+
return candidate === prefix || candidate.startsWith(`${prefix}/`);
|
|
147
|
+
}
|
|
148
|
+
function projectConfigFromStoredBytes(value) {
|
|
149
|
+
try {
|
|
150
|
+
const config = JSON.parse(bytes(value).toString('utf8'));
|
|
151
|
+
return validateDocsConfig(config).valid ? config : null;
|
|
152
|
+
} catch { return null; }
|
|
153
|
+
}
|
|
154
|
+
function validCoverage(snapshot) {
|
|
155
|
+
const coverage = snapshot.candidate?.coverage;
|
|
156
|
+
if (!record(coverage) || !Array.isArray(coverage.excluded) || !record(coverage.scope)) return false;
|
|
157
|
+
if (coverage.kind === 'whole-project') {
|
|
158
|
+
return Object.keys(coverage).sort(compare).join('\0') === ['excluded', 'kind', 'scope'].sort(compare).join('\0')
|
|
159
|
+
&& stable(coverage.excluded) === stable(['.git']);
|
|
160
|
+
}
|
|
161
|
+
const fields = ['dependencyArtifactRoots', 'excluded', 'excludedRootStates', 'guardOutcomes', 'kind', 'policySource', 'policyVersion', 'scope'];
|
|
162
|
+
if (coverage.kind !== 'project-with-dependency-artifact-exclusions'
|
|
163
|
+
|| Object.keys(coverage).sort(compare).join('\0') !== fields.sort(compare).join('\0')
|
|
164
|
+
|| coverage.policyVersion !== DEPENDENCY_POLICY_VERSION || !record(coverage.policySource)
|
|
165
|
+
|| Object.keys(coverage.policySource).sort(compare).join('\0') !== ['baselineCommit', 'path', 'storedBytesDigest'].sort(compare).join('\0')
|
|
166
|
+
|| coverage.policySource.path !== PROJECT_CONFIG_PATH || coverage.policySource.baselineCommit !== snapshot.baseline.commit
|
|
167
|
+
|| !/^[a-f0-9]{64}$/.test(coverage.policySource.storedBytesDigest || '')
|
|
168
|
+
|| !Array.isArray(coverage.dependencyArtifactRoots) || !coverage.dependencyArtifactRoots.length
|
|
169
|
+
|| coverage.dependencyArtifactRoots.some(root => !DEPENDENCY_ARTIFACT_ROOTS.has(root))
|
|
170
|
+
|| stable(coverage.dependencyArtifactRoots) !== stable([...coverage.dependencyArtifactRoots].sort())
|
|
171
|
+
|| new Set(coverage.dependencyArtifactRoots).size !== coverage.dependencyArtifactRoots.length
|
|
172
|
+
|| stable(coverage.excluded) !== stable(['.git', ...coverage.dependencyArtifactRoots])) return false;
|
|
173
|
+
const roots = coverage.dependencyArtifactRoots;
|
|
174
|
+
const baselinePolicy = snapshot.baseline.records.find(item => item.path === PROJECT_CONFIG_PATH && item.presence === 'present');
|
|
175
|
+
const candidatePolicy = snapshot.config.capture.sources.find(item => item.path === PROJECT_CONFIG_PATH && item.presence === 'present');
|
|
176
|
+
if (!baselinePolicy || !candidatePolicy || sha(bytes(baselinePolicy.storedBytes)) !== coverage.policySource.storedBytesDigest) return false;
|
|
177
|
+
const baseConfig = projectConfigFromStoredBytes(baselinePolicy.storedBytes);
|
|
178
|
+
const candidateConfig = projectConfigFromStoredBytes(candidatePolicy.bytes);
|
|
179
|
+
const baseRoots = baseConfig?.inventory?.dependencyArtifactRoots;
|
|
180
|
+
const candidateRoots = candidateConfig?.inventory?.dependencyArtifactRoots;
|
|
181
|
+
if (!Array.isArray(baseRoots) || !Array.isArray(candidateRoots)
|
|
182
|
+
|| stable([...baseRoots].sort()) !== stable([...candidateRoots].sort())
|
|
183
|
+
|| stable([...baseRoots].sort()) !== stable(roots)) return false;
|
|
184
|
+
if ([...snapshot.baseline.records, ...snapshot.candidate.records].some(item => roots.some(root => pathUnderDependencyRoot(item.path, root)))) return false;
|
|
185
|
+
if (!Array.isArray(coverage.excludedRootStates) || coverage.excludedRootStates.length !== roots.length
|
|
186
|
+
|| coverage.excludedRootStates.some((item, index) => !record(item) || item.path !== roots[index]
|
|
187
|
+
|| !['absent', 'present-directory'].includes(item.presence)
|
|
188
|
+
|| Object.keys(item).sort(compare).join('\0') !== ['path', 'presence'].sort(compare).join('\0'))) return false;
|
|
189
|
+
const guards = ['baseline-paths', 'index-paths', 'config-sources', 'config-selectors', 'task-inputs', 'excluded-root-types'];
|
|
190
|
+
return Array.isArray(coverage.guardOutcomes) && coverage.guardOutcomes.length === guards.length
|
|
191
|
+
&& coverage.guardOutcomes.every((item, index) => record(item) && item.guard === guards[index] && item.state === 'clear'
|
|
192
|
+
&& text(item.reason) && (index !== guards.length - 1 || stable(item.roots) === stable(coverage.excludedRootStates)));
|
|
193
|
+
}
|
|
139
194
|
function configDigest(config) {
|
|
140
195
|
const sources = [...config.capture.sources].sort(pathCompare);
|
|
141
196
|
const fields = ['doc-config-v1', stable({ activeHost: config.capture.activeHost, selectionInputs: config.capture.selectionInputs, resolution: config.resolution }), String(sources.length)];
|
|
@@ -146,6 +201,7 @@ function validateSnapshot(snapshot) {
|
|
|
146
201
|
const errors = [];
|
|
147
202
|
try {
|
|
148
203
|
if (!validIdentity(snapshot.identity) || !record(snapshot.facts) || !Array.isArray(snapshot.obligations)) throw new Error('invalid-input');
|
|
204
|
+
if (!validCoverage(snapshot)) errors.push(diagnostic('inventory-unsupported'));
|
|
149
205
|
// Roots are canonicalized at capture; keep reconciliation detached and free of filesystem reads.
|
|
150
206
|
const context = snapshot.originContext;
|
|
151
207
|
if (!record(context) || !record(context.baseline) || !text(context.workspaceRoot)
|