harness-everything 0.44.0 → 0.45.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +10 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2a/harness.js +19 -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-pr-a-conflict-check.test.js +507 -0
- package/docs/doc-workflow-reconciliation.md +2 -1
- package/docs/platform-capabilities.md +4 -0
- package/harness-everything/SKILL.md +1 -1
- package/harness-everything/scripts/doc-check.js +84 -6
- package/harness-everything/scripts/doc-lifecycle.js +51 -6
- package/hooks/scripts/lib/doc-reconciliation-events.js +17 -0
- package/hooks/scripts/lib/doc-reconciliation-pilot.js +3 -0
- package/hooks/scripts/lib/doc-reconciliation-provider.js +80 -7
- 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 +17 -0
- package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-pilot.js +3 -0
- package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-provider.js +80 -7
- 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-lifecycle.js +51 -6
- package/sbom.cdx.json +7 -7
|
@@ -6,14 +6,16 @@
|
|
|
6
6
|
// Prints one JSON result. Exit 0 = both pass, 1 = defect, 2 = usage/config error.
|
|
7
7
|
const fs = require('fs');
|
|
8
8
|
const path = require('path');
|
|
9
|
+
const crypto = require('crypto');
|
|
9
10
|
const { execFileSync, spawnSync } = require('child_process');
|
|
10
11
|
const { collectDocs } = require('./doc-lifecycle');
|
|
11
12
|
|
|
12
13
|
// Logical command IDs (A3 section 3): stdout that repeats an ID is not trusted as credit by itself.
|
|
13
14
|
const CHECK_ID = 'harness.doc-check.check.v1';
|
|
14
15
|
const COMPARE_ID = 'harness.doc-check.compare-derived.v1';
|
|
15
|
-
const USAGE = 'usage: doc-check.js check --workspace <dir> --config <json-file, relative to --workspace> --changed-path <path> [--changed-path <path> ...] [--base <git-rev>]';
|
|
16
|
+
const USAGE = 'usage: doc-check.js check --workspace <dir> --config <json-file, relative to --workspace> --changed-path <path> [--changed-path <path> ...] [--base <git-rev>] [--conflict-assessment]';
|
|
16
17
|
const VALUE_FLAGS = ['--workspace', '--config', '--changed-path', '--base'];
|
|
18
|
+
const CONFLICT_SCHEMA = 'harness.doc-check.conflict-assessment.v1';
|
|
17
19
|
const EXTERNAL = /^(?:https?:|mailto:)/i;
|
|
18
20
|
const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
19
21
|
|
|
@@ -79,10 +81,15 @@ function parseArgs(argv) {
|
|
|
79
81
|
const [command, ...rest] = argv;
|
|
80
82
|
if (command !== 'check') throw new Error(USAGE);
|
|
81
83
|
const options = { changedPaths: [] };
|
|
82
|
-
for (let index = 0; index < rest.length; index
|
|
84
|
+
for (let index = 0; index < rest.length; index++) {
|
|
83
85
|
const flag = rest[index];
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
if (flag === '--conflict-assessment') {
|
|
87
|
+
if (options.conflictAssessment) throw new Error(`${USAGE}\nrepeated argument: ${flag}`);
|
|
88
|
+
options.conflictAssessment = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!VALUE_FLAGS.includes(flag) || rest[index + 1] === undefined) throw new Error(`${USAGE}\nunknown or incomplete argument: ${flag}`);
|
|
92
|
+
const value = rest[++index];
|
|
86
93
|
if (value.startsWith('--')) throw new Error(`${USAGE}\n${flag} requires a value, not a flag: ${value}`);
|
|
87
94
|
if (flag === '--changed-path') options.changedPaths.push(value);
|
|
88
95
|
else {
|
|
@@ -92,16 +99,47 @@ function parseArgs(argv) {
|
|
|
92
99
|
}
|
|
93
100
|
if (!options.workspace || !options.config) throw new Error(`${USAGE}\n--workspace and --config are required`);
|
|
94
101
|
if (!options.changedPaths.length) throw new Error(`${USAGE}\n--changed-path is required`);
|
|
102
|
+
if (options.conflictAssessment && options.base === undefined) throw new Error(`${USAGE}\n--conflict-assessment requires --base`);
|
|
95
103
|
return options;
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
function runLint(workspace, options) {
|
|
99
107
|
const args = [path.join(__dirname, 'doc-lifecycle.js'), 'lint', '--workspace', workspace, '--config', options.config];
|
|
100
|
-
|
|
108
|
+
if (options.conflictAssessment) args.push('--all');
|
|
109
|
+
else for (const changed of options.changedPaths) args.push('--changed-path', changed);
|
|
101
110
|
if (options.base !== undefined) args.push('--base', options.base);
|
|
102
111
|
return spawnSync(process.execPath, args, { encoding: 'utf8' });
|
|
103
112
|
}
|
|
104
113
|
|
|
114
|
+
function workspaceRelative(workspace, value) {
|
|
115
|
+
const absolute = path.resolve(workspace, value);
|
|
116
|
+
const relative = path.relative(workspace, absolute);
|
|
117
|
+
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
|
|
118
|
+
return relative.split(path.sep).join('/');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function resolvedBaseCommit(workspace, base) {
|
|
122
|
+
if (typeof base !== 'string' || !base || base.startsWith('-')) return null;
|
|
123
|
+
try {
|
|
124
|
+
const value = execFileSync('git', ['-C', workspace, 'rev-parse', '--verify', `${base}^{commit}`], {
|
|
125
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']
|
|
126
|
+
}).trim();
|
|
127
|
+
return /^[0-9a-f]{40,64}$/.test(value) ? value : null;
|
|
128
|
+
} catch { return null; }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function baselineArtifacts(workspace, base, paths) {
|
|
132
|
+
const artifacts = {};
|
|
133
|
+
for (const relative of paths) {
|
|
134
|
+
if (fs.existsSync(path.join(workspace, relative))) continue;
|
|
135
|
+
try {
|
|
136
|
+
const bytes = execFileSync('git', ['-C', workspace, 'show', `${base}:${relative}`], { encoding: 'buffer', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
137
|
+
artifacts[relative] = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
138
|
+
} catch { /* Paths absent from both snapshots need no baseline artifact. */ }
|
|
139
|
+
}
|
|
140
|
+
return artifacts;
|
|
141
|
+
}
|
|
142
|
+
|
|
105
143
|
// Scan nested label spans depth-first, then resume after each destination. Matching brackets are precomputed per line,
|
|
106
144
|
// and an explicit stack keeps nesting from consuming the JS call stack. A nested link inside a label remains an
|
|
107
145
|
// intentional over-check: CommonMark keeps only the inner link, so both targets are checked and neither is silent.
|
|
@@ -340,7 +378,8 @@ function check(options) {
|
|
|
340
378
|
try { analysis = JSON.parse(lint.stdout); } catch {
|
|
341
379
|
return emit({ schema: CHECK_ID, command: CHECK_ID, lint: { exit: 2, error: 'lint output was not JSON', diagnostics: [] }, links: { checked: 0, broken: [], skipped: 'lint-usage-error' }, coverage: { unsupported: [] } }, 2);
|
|
342
380
|
}
|
|
343
|
-
const
|
|
381
|
+
const configBytes = fs.readFileSync(configPath);
|
|
382
|
+
const config = JSON.parse(configBytes.toString('utf8'));
|
|
344
383
|
const docs = collectDocs(workspace, config);
|
|
345
384
|
const governed = new Set(docs.filter(doc => doc.role).map(doc => doc.path));
|
|
346
385
|
|
|
@@ -408,6 +447,7 @@ function check(options) {
|
|
|
408
447
|
const result = {
|
|
409
448
|
schema: CHECK_ID,
|
|
410
449
|
command: CHECK_ID,
|
|
450
|
+
...(options.conflictAssessment ? { mode: 'conflict-assessment' } : {}),
|
|
411
451
|
lint: { exit: lintExit, evidence: analysis.evidence, diagnostics: analysis.diagnostics },
|
|
412
452
|
links: { checked, broken },
|
|
413
453
|
coverage: { unsupported },
|
|
@@ -425,6 +465,44 @@ function check(options) {
|
|
|
425
465
|
},
|
|
426
466
|
}
|
|
427
467
|
};
|
|
468
|
+
if (options.conflictAssessment) {
|
|
469
|
+
const scopePaths = [...new Set(options.changedPaths.map(value => workspaceRelative(workspace, value)).filter(Boolean))].sort();
|
|
470
|
+
const corpus = analysis.corpus;
|
|
471
|
+
const baseCommit = resolvedBaseCommit(workspace, options.base);
|
|
472
|
+
const configRelative = workspaceRelative(workspace, options.config);
|
|
473
|
+
let reason = null;
|
|
474
|
+
if (!scopePaths.length || scopePaths.length !== new Set(options.changedPaths.map(value => workspaceRelative(workspace, value))).size) reason = 'scope-unavailable';
|
|
475
|
+
else if (!Array.isArray(config.domains) || config.domains.length === 0) reason = 'domains-unknown';
|
|
476
|
+
else if (!corpus || corpus.complete !== true || !Array.isArray(corpus.paths) || !corpus.paths.length) reason = 'corpus-incomplete';
|
|
477
|
+
else if (!corpus.digests || Object.keys(corpus.digests).length !== corpus.paths.length
|
|
478
|
+
|| corpus.paths.some(relative => !/^[0-9a-f]{64}$/.test(corpus.digests[relative] || ''))) reason = 'corpus-digests-incomplete';
|
|
479
|
+
else if (!baseCommit) reason = 'base-unavailable';
|
|
480
|
+
else if (options.base !== baseCommit) reason = 'base-not-pinned';
|
|
481
|
+
else if (!configRelative) reason = 'config-outside-workspace';
|
|
482
|
+
else if (!corpus.config || corpus.config.path !== configRelative) reason = 'config-unavailable';
|
|
483
|
+
else if (corpus.config.digest !== crypto.createHash('sha256').update(configBytes).digest('hex')) reason = 'config-changed-during-check';
|
|
484
|
+
const hasErrors = lintExit !== 0 || !Array.isArray(analysis.diagnostics)
|
|
485
|
+
|| analysis.diagnostics.some(item => item && item.severity === 'error');
|
|
486
|
+
const corpusIdentity = corpus && {
|
|
487
|
+
complete: corpus.complete === true,
|
|
488
|
+
paths: corpus.paths,
|
|
489
|
+
digests: corpus.digests,
|
|
490
|
+
config: corpus.config,
|
|
491
|
+
baseCommit,
|
|
492
|
+
};
|
|
493
|
+
const state = reason ? 'unavailable' : hasErrors ? 'fail' : 'pass';
|
|
494
|
+
result.conflictAssessment = {
|
|
495
|
+
schema: CONFLICT_SCHEMA,
|
|
496
|
+
state,
|
|
497
|
+
...(reason ? { reason } : {}),
|
|
498
|
+
...(corpusIdentity ? { corpus: corpusIdentity } : {}),
|
|
499
|
+
...(state === 'pass' ? { baselineArtifacts: baselineArtifacts(workspace, baseCommit, scopePaths) } : {}),
|
|
500
|
+
};
|
|
501
|
+
if (state === 'pass') result.capabilities['conflict-check'] = {
|
|
502
|
+
state: 'pass', checkedPaths: scopePaths, corpus: corpusIdentity,
|
|
503
|
+
};
|
|
504
|
+
return emit(result, state === 'unavailable' ? 2 : lintExit === 1 || broken.length ? 1 : 0);
|
|
505
|
+
}
|
|
428
506
|
return emit(result, lintExit === 1 || broken.length ? 1 : 0);
|
|
429
507
|
}
|
|
430
508
|
|
|
@@ -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();
|
|
@@ -101,6 +101,23 @@ function acceptDocEvents(input = {}, dependencies = {}) {
|
|
|
101
101
|
// Supplied extra digests must also describe this capture, never another tree.
|
|
102
102
|
for (const p of Object.keys(digests)) if (!artifacts[p] || digests[p] !== artifacts[p].digest) reject('artifact-mismatch');
|
|
103
103
|
}
|
|
104
|
+
if (event?.capability === 'conflict-check') {
|
|
105
|
+
const binding = event.corpusBinding;
|
|
106
|
+
const identity = snapshot.identity;
|
|
107
|
+
const expectedPaths = U.runtimeModule('doc-lifecycle.js').documentCorpusPaths(
|
|
108
|
+
snapshot.candidate.records, snapshot.config.resolution.config
|
|
109
|
+
);
|
|
110
|
+
if (!U.record(binding) || binding.complete !== true || !Array.isArray(binding.paths)
|
|
111
|
+
|| U.stable(binding.paths) !== U.stable(expectedPaths) || !U.record(binding.digests)
|
|
112
|
+
|| U.stable(Object.keys(binding.digests).sort()) !== U.stable(expectedPaths)
|
|
113
|
+
|| !expectedPaths.every(p => artifacts[p] && binding.digests[p] === artifacts[p].digest)) reject('corpus-mismatch');
|
|
114
|
+
if (binding?.baselineDigest !== identity.baselineDigest || binding?.candidateDigest !== identity.candidateDigest
|
|
115
|
+
|| binding?.configIdentityDigest !== identity.configDigest
|
|
116
|
+
|| binding?.baseCommit !== snapshot.originContext.baseline.commit) reject('snapshot-changed');
|
|
117
|
+
const configSource = snapshot.config.capture.sources.find(source => source.path === binding?.config?.path);
|
|
118
|
+
if (!configSource || configSource.presence !== 'present'
|
|
119
|
+
|| U.sha(U.bytes(configSource.bytes)) !== binding?.config?.digest) reject('config-capture-mismatch');
|
|
120
|
+
}
|
|
104
121
|
if (event?.kind === 'review') {
|
|
105
122
|
if (event.verdict !== 'approve' || !U.text(event.summary)) reject('event-invalid');
|
|
106
123
|
const principals = context?.principals;
|
|
@@ -247,6 +247,9 @@ function captureReferences(context) {
|
|
|
247
247
|
const ordered = index ? candidates.slice(1).concat(candidates.slice(0, 1)) : candidates;
|
|
248
248
|
references.push(...ordered.map(item => `${item.ref}#${capability}`));
|
|
249
249
|
}
|
|
250
|
+
const provider = require('./doc-reconciliation-provider');
|
|
251
|
+
references.push(...candidates.filter(item => provider.resolveDocCheckInvocation(item.command, item.cwd).conflictAssessment === true)
|
|
252
|
+
.map(item => `${item.ref}#conflict-check`));
|
|
250
253
|
// The adapter accepts one event per capability; rotating candidate order lets separate valid runs fill each capability.
|
|
251
254
|
return [...references, ...reviews.map(item => `${item.ref}#quality-review`)];
|
|
252
255
|
}
|
|
@@ -46,7 +46,9 @@ const HEX64 = /^[0-9a-f]{64}$/;
|
|
|
46
46
|
const PRE_CAPTURE_TOOLS = ['Bash', 'PowerShell', 'Task', 'Agent'];
|
|
47
47
|
const STDOUT_CAP_BYTES = 1024 * 1024;
|
|
48
48
|
const DOC_CHECK_FLAGS = new Set(['--workspace', '--config', '--changed-path', '--base']);
|
|
49
|
+
const DOC_CHECK_BOOLEAN_FLAGS = new Set(['--conflict-assessment']);
|
|
49
50
|
const DOC_CHECK_SCHEMA = 'harness.doc-check.check.v1';
|
|
51
|
+
const DOC_CHECK_CONFLICT_SCHEMA = 'harness.doc-check.conflict-assessment.v1';
|
|
50
52
|
const DOC_CHECK_SHELLS = new Set(['sh', 'bash', 'dash', 'zsh', 'ksh', 'fish']);
|
|
51
53
|
const DOC_CHECK_LAUNCHERS = new Set(['node', 'nodejs', 'bun', 'deno', 'tsx', 'ts-node']);
|
|
52
54
|
const DOC_CHECK_LAUNCHER_VALUE_FLAGS = new Set(['-r', '--require', '--import', '--loader', '--experimental-loader', '--inspect-port', '--input-type', '--conditions', '--env-file']);
|
|
@@ -65,7 +67,7 @@ function isUncPathPrefix(token, command, index) {
|
|
|
65
67
|
return token === '' && command[index] === '\\' && command[index + 1] === '\\';
|
|
66
68
|
}
|
|
67
69
|
// Canonical SHA-256 of harness-everything/scripts/doc-check.js (CRLF normalised to LF). Re-pin deliberately when that script changes.
|
|
68
|
-
const SHIPPED_DOC_CHECK_DIGEST = '
|
|
70
|
+
const SHIPPED_DOC_CHECK_DIGEST = '1afd94d66b356c94206d2441529ec0b7fafa1bcb453b0f1e2cf6994b1e4858e5';
|
|
69
71
|
// This install's own doc-check runtime, relative to hooks/scripts/lib: the source checkout or the packaged plugin.
|
|
70
72
|
const DOC_CHECK_INSTALL_LAYOUTS = [
|
|
71
73
|
['..', '..', '..', 'harness-everything', 'scripts', 'doc-check.js'],
|
|
@@ -357,10 +359,16 @@ function resolveDocCheckInvocation(command, cwd) {
|
|
|
357
359
|
|
|
358
360
|
const values = new Map();
|
|
359
361
|
const changedPaths = [];
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
const
|
|
362
|
+
let conflictAssessment = false;
|
|
363
|
+
for (let index = 3; index < tokens.length;) {
|
|
364
|
+
const flag = tokens[index++];
|
|
365
|
+
if (DOC_CHECK_BOOLEAN_FLAGS.has(flag)) {
|
|
366
|
+
if (conflictAssessment) return rejected('duplicate-flag');
|
|
367
|
+
conflictAssessment = true;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
363
370
|
if (!DOC_CHECK_FLAGS.has(flag)) return rejected('unknown-flag');
|
|
371
|
+
const value = tokens[index++];
|
|
364
372
|
if (value === undefined || !value || value.startsWith('--')) return rejected('flag-value-missing');
|
|
365
373
|
if (flag === '--changed-path') changedPaths.push(value);
|
|
366
374
|
else {
|
|
@@ -371,6 +379,8 @@ function resolveDocCheckInvocation(command, cwd) {
|
|
|
371
379
|
if (!values.has('--workspace') || !values.has('--config') || !changedPaths.length) {
|
|
372
380
|
return rejected('required-flag-missing');
|
|
373
381
|
}
|
|
382
|
+
if (conflictAssessment && !values.has('--base')) return rejected('conflict-assessment-base-required');
|
|
383
|
+
if (conflictAssessment && !/^[0-9a-f]{40,64}$/.test(values.get('--base'))) return rejected('conflict-assessment-base-unpinned');
|
|
374
384
|
|
|
375
385
|
installed = installedDocCheck();
|
|
376
386
|
if (!installed) return rejected('implementation-not-installed');
|
|
@@ -382,7 +392,8 @@ function resolveDocCheckInvocation(command, cwd) {
|
|
|
382
392
|
if (fileDigest(registered.installed) !== SHIPPED_DOC_CHECK_DIGEST) return rejected('implementation-pin-mismatch', 'the installed doc-check is not the shipped script (canonical digest differs from SHIPPED_DOC_CHECK_DIGEST); no credit');
|
|
383
393
|
}
|
|
384
394
|
}
|
|
385
|
-
return { kind: 'accepted', reason: null, installed: registered.installed, digest, argv
|
|
395
|
+
return { kind: 'accepted', reason: null, installed: registered.installed, digest, argv,
|
|
396
|
+
conflictAssessment, base: values.get('--base') || null, changedPaths };
|
|
386
397
|
}
|
|
387
398
|
|
|
388
399
|
// Observation-time digests of the checked paths, read from the workspace when the check ran. Absent paths are omitted.
|
|
@@ -714,6 +725,9 @@ const createClaudeOriginProvider = input => {
|
|
|
714
725
|
if (cap.event !== 'PostToolUse') return rejected('capability-mismatch', 'only a PostToolUse check invocation can cover a doc capability');
|
|
715
726
|
const registered = resolveDocCheckInvocation(cap.command, cap.cwd);
|
|
716
727
|
if (registered.kind === 'not-candidate') return rejected('unregistered-command', 'the command is not a registered doc-check invocation; an arbitrary successful shell command never covers a doc capability');
|
|
728
|
+
if (capability === 'conflict-check' && registered.conflictAssessment !== true) {
|
|
729
|
+
return rejected('capability-mismatch', 'conflict-check requires the structurally parsed --conflict-assessment mode');
|
|
730
|
+
}
|
|
717
731
|
if (registered.kind === 'rejected' && registered.reason !== 'implementation-pin-mismatch') {
|
|
718
732
|
if (registered.reason === 'implementation-not-installed') return rejected('implementation-not-installed', 'the registered check does not resolve to this install\'s own doc-check runtime; a different path earns no credit');
|
|
719
733
|
return rejected('unregistered-command', `doc-check invocation rejected (${registered.reason}); no credit`);
|
|
@@ -744,11 +758,55 @@ const createClaudeOriginProvider = input => {
|
|
|
744
758
|
}
|
|
745
759
|
const paths = Array.isArray(projection.checkedPaths) ? projection.checkedPaths.slice() : [];
|
|
746
760
|
if (!paths.length) return rejected('scope-empty', `${capability} checked no governed paths`);
|
|
761
|
+
let corpusBinding;
|
|
762
|
+
let baselineArtifactDigests;
|
|
763
|
+
if (capability === 'conflict-check') {
|
|
764
|
+
const assessment = report.conflictAssessment;
|
|
765
|
+
const corpus = assessment && assessment.corpus;
|
|
766
|
+
const config = corpus && corpus.config;
|
|
767
|
+
if (registered.conflictAssessment !== true || report.mode !== 'conflict-assessment'
|
|
768
|
+
|| assessment?.schema !== DOC_CHECK_CONFLICT_SCHEMA || assessment.state !== 'pass'
|
|
769
|
+
|| projection.corpus?.complete !== true || corpus?.complete !== true
|
|
770
|
+
|| !Array.isArray(corpus.paths) || !corpus.paths.length || !recordOf(corpus.digests)
|
|
771
|
+
|| !recordOf(config) || !textOf(config.path) || path.isAbsolute(config.path)
|
|
772
|
+
|| config.path.split(/[\\/]/).includes('..') || !HEX64.test(String(config.digest || ''))
|
|
773
|
+
|| !/^[0-9a-f]{40,64}$/.test(String(corpus.baseCommit || ''))
|
|
774
|
+
|| JSON.stringify([...new Set(corpus.paths)].sort(compareKeys)) !== JSON.stringify(corpus.paths)
|
|
775
|
+
|| JSON.stringify(Object.keys(corpus.digests).sort(compareKeys)) !== JSON.stringify(corpus.paths)
|
|
776
|
+
|| corpus.paths.some(relative => !textOf(relative) || path.isAbsolute(relative)
|
|
777
|
+
|| relative.split(/[\\/]/).includes('..') || !HEX64.test(String(corpus.digests[relative] || '')))) {
|
|
778
|
+
return rejected('corpus-binding-invalid', 'conflict-check stdout does not carry a complete, structured full-corpus assessment');
|
|
779
|
+
}
|
|
780
|
+
const observedCorpus = recordOf(cap.observedCorpusDigests) ? cap.observedCorpusDigests : {};
|
|
781
|
+
if (corpus.paths.some(relative => observedCorpus[relative] !== corpus.digests[relative])) {
|
|
782
|
+
return rejected('corpus-changed-after-check', 'a full-corpus path is missing or changed between assessment and capture');
|
|
783
|
+
}
|
|
784
|
+
if (cap.observedConfigDigest !== config.digest) {
|
|
785
|
+
return rejected('config-changed-after-check', 'the project-docs config changed between assessment and capture');
|
|
786
|
+
}
|
|
787
|
+
if (!recordOf(request.identity) || !HEX64.test(String(request.identity.baselineDigest || ''))
|
|
788
|
+
|| !HEX64.test(String(request.identity.candidateDigest || '')) || !HEX64.test(String(request.identity.configDigest || ''))) {
|
|
789
|
+
return rejected('identity-missing', 'conflict-check needs the complete snapshot identity');
|
|
790
|
+
}
|
|
791
|
+
corpusBinding = {
|
|
792
|
+
complete: true,
|
|
793
|
+
paths: corpus.paths.slice(),
|
|
794
|
+
digests: { ...corpus.digests },
|
|
795
|
+
config: { ...config },
|
|
796
|
+
baseCommit: corpus.baseCommit,
|
|
797
|
+
baselineDigest: request.identity.baselineDigest,
|
|
798
|
+
candidateDigest: request.identity.candidateDigest,
|
|
799
|
+
configIdentityDigest: request.identity.configDigest,
|
|
800
|
+
};
|
|
801
|
+
baselineArtifactDigests = recordOf(assessment.baselineArtifacts) ? assessment.baselineArtifacts : {};
|
|
802
|
+
}
|
|
747
803
|
const observed = recordOf(cap.observedDigests) ? cap.observedDigests : {};
|
|
748
804
|
const artifactDigests = {};
|
|
805
|
+
const baselineArtifacts = {};
|
|
749
806
|
for (const relative of paths) {
|
|
750
|
-
if (typeof observed[relative]
|
|
751
|
-
|
|
807
|
+
if (typeof observed[relative] === 'string') artifactDigests[relative] = observed[relative];
|
|
808
|
+
else if (capability === 'conflict-check' && HEX64.test(String(baselineArtifactDigests[relative] || ''))) baselineArtifacts[relative] = baselineArtifactDigests[relative];
|
|
809
|
+
else return rejected('artifact-unobserved', `no observation-time digest for ${relative}`);
|
|
752
810
|
}
|
|
753
811
|
const argv = ['node', ...registered.argv];
|
|
754
812
|
const command = argv.join(' ');
|
|
@@ -772,6 +830,8 @@ const createClaudeOriginProvider = input => {
|
|
|
772
830
|
endIdentity: recordOf(request.identity) ? JSON.parse(JSON.stringify(request.identity)) : null,
|
|
773
831
|
paths,
|
|
774
832
|
artifactDigests,
|
|
833
|
+
...(Object.keys(baselineArtifacts).length ? { baselineArtifacts } : {}),
|
|
834
|
+
...(corpusBinding ? { corpusBinding } : {}),
|
|
775
835
|
captured: { responseRef: cap.responseRef, responseDigest: cap.responseDigest },
|
|
776
836
|
};
|
|
777
837
|
return { status: 'accepted', originRef, event, correlation: { toolCorrelationId: cap.tool_use_id, resultRef: cap.responseRef } };
|
|
@@ -1086,12 +1146,25 @@ function buildCaptureRecord(payload, eventName, workspaceRoot) {
|
|
|
1086
1146
|
record.toolResponse = toolResponse;
|
|
1087
1147
|
} else { record.stdout = null; record.stdoutTruncated = true; }
|
|
1088
1148
|
let checkedPaths = [];
|
|
1149
|
+
let conflictCorpus = null;
|
|
1089
1150
|
try {
|
|
1090
1151
|
const report = JSON.parse(stdout);
|
|
1091
1152
|
checkedPaths = ['doc-lint', 'link-check'].flatMap(capability => (recordOf(report.capabilities) && recordOf(report.capabilities[capability])
|
|
1092
1153
|
&& Array.isArray(report.capabilities[capability].checkedPaths) ? report.capabilities[capability].checkedPaths : []));
|
|
1154
|
+
const conflict = recordOf(report.capabilities) && report.capabilities['conflict-check'];
|
|
1155
|
+
if (installedCheck.conflictAssessment === true && report.mode === 'conflict-assessment'
|
|
1156
|
+
&& recordOf(conflict) && conflict.state === 'pass' && recordOf(conflict.corpus)) {
|
|
1157
|
+
checkedPaths.push(...(Array.isArray(conflict.checkedPaths) ? conflict.checkedPaths : []));
|
|
1158
|
+
conflictCorpus = report.conflictAssessment && report.conflictAssessment.corpus;
|
|
1159
|
+
}
|
|
1093
1160
|
} catch { /* not JSON: no paths observed, the event will be withheld */ }
|
|
1094
1161
|
record.observedDigests = typeof workspaceRoot === 'string' && workspaceRoot ? observedDigestsFor(workspaceRoot, [...new Set(checkedPaths)]) : {};
|
|
1162
|
+
if (recordOf(conflictCorpus) && Array.isArray(conflictCorpus.paths) && recordOf(conflictCorpus.config)) {
|
|
1163
|
+
record.observedCorpusDigests = typeof workspaceRoot === 'string' && workspaceRoot
|
|
1164
|
+
? observedDigestsFor(workspaceRoot, conflictCorpus.paths) : {};
|
|
1165
|
+
record.observedConfigDigest = typeof workspaceRoot === 'string' && workspaceRoot
|
|
1166
|
+
? observedDigestsFor(workspaceRoot, [conflictCorpus.config.path])[conflictCorpus.config.path] || null : null;
|
|
1167
|
+
}
|
|
1095
1168
|
}
|
|
1096
1169
|
} else if (event === 'PostToolUseFailure') {
|
|
1097
1170
|
record.is_interrupt = Boolean(payload.is_interrupt);
|
package/package.json
CHANGED
|
@@ -101,6 +101,23 @@ function acceptDocEvents(input = {}, dependencies = {}) {
|
|
|
101
101
|
// Supplied extra digests must also describe this capture, never another tree.
|
|
102
102
|
for (const p of Object.keys(digests)) if (!artifacts[p] || digests[p] !== artifacts[p].digest) reject('artifact-mismatch');
|
|
103
103
|
}
|
|
104
|
+
if (event?.capability === 'conflict-check') {
|
|
105
|
+
const binding = event.corpusBinding;
|
|
106
|
+
const identity = snapshot.identity;
|
|
107
|
+
const expectedPaths = U.runtimeModule('doc-lifecycle.js').documentCorpusPaths(
|
|
108
|
+
snapshot.candidate.records, snapshot.config.resolution.config
|
|
109
|
+
);
|
|
110
|
+
if (!U.record(binding) || binding.complete !== true || !Array.isArray(binding.paths)
|
|
111
|
+
|| U.stable(binding.paths) !== U.stable(expectedPaths) || !U.record(binding.digests)
|
|
112
|
+
|| U.stable(Object.keys(binding.digests).sort()) !== U.stable(expectedPaths)
|
|
113
|
+
|| !expectedPaths.every(p => artifacts[p] && binding.digests[p] === artifacts[p].digest)) reject('corpus-mismatch');
|
|
114
|
+
if (binding?.baselineDigest !== identity.baselineDigest || binding?.candidateDigest !== identity.candidateDigest
|
|
115
|
+
|| binding?.configIdentityDigest !== identity.configDigest
|
|
116
|
+
|| binding?.baseCommit !== snapshot.originContext.baseline.commit) reject('snapshot-changed');
|
|
117
|
+
const configSource = snapshot.config.capture.sources.find(source => source.path === binding?.config?.path);
|
|
118
|
+
if (!configSource || configSource.presence !== 'present'
|
|
119
|
+
|| U.sha(U.bytes(configSource.bytes)) !== binding?.config?.digest) reject('config-capture-mismatch');
|
|
120
|
+
}
|
|
104
121
|
if (event?.kind === 'review') {
|
|
105
122
|
if (event.verdict !== 'approve' || !U.text(event.summary)) reject('event-invalid');
|
|
106
123
|
const principals = context?.principals;
|
|
@@ -247,6 +247,9 @@ function captureReferences(context) {
|
|
|
247
247
|
const ordered = index ? candidates.slice(1).concat(candidates.slice(0, 1)) : candidates;
|
|
248
248
|
references.push(...ordered.map(item => `${item.ref}#${capability}`));
|
|
249
249
|
}
|
|
250
|
+
const provider = require('./doc-reconciliation-provider');
|
|
251
|
+
references.push(...candidates.filter(item => provider.resolveDocCheckInvocation(item.command, item.cwd).conflictAssessment === true)
|
|
252
|
+
.map(item => `${item.ref}#conflict-check`));
|
|
250
253
|
// The adapter accepts one event per capability; rotating candidate order lets separate valid runs fill each capability.
|
|
251
254
|
return [...references, ...reviews.map(item => `${item.ref}#quality-review`)];
|
|
252
255
|
}
|