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.
Files changed (60) hide show
  1. package/.agents/harness-everything/project-docs.json +2 -1
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/CHANGELOG.md +33 -0
  5. package/behavioral-evals/README.md +17 -0
  6. package/behavioral-evals/case-validator.js +15 -1
  7. package/behavioral-evals/cases/baseline-tdd-workflow.yaml +4 -0
  8. package/behavioral-evals/run.js +172 -0
  9. package/behavioral-evals/transcript-parser.js +43 -0
  10. package/ci/fixtures/doc-obligations/reconciliation-s2a/run13-workflow-requirements.json +42 -0
  11. package/ci/mechanism-2s-tdd-quality.test.js +149 -6
  12. package/ci/mechanism-e1-dependency-artifact-scope.test.js +657 -0
  13. package/ci/mechanism-prb-outline-review.test.js +1 -1
  14. package/ci/test-behavioral-case-validation.js +215 -1
  15. package/code-review/SKILL.md +1 -1
  16. package/code-review/references/review-playbook.md +9 -0
  17. package/docs/doc-workflow-reconciliation.md +3 -1
  18. package/harness-everything/SKILL.md +1 -1
  19. package/harness-everything/scripts/doc-lifecycle.js +11 -3
  20. package/harness-everything/scripts/project-docs-resolver.js +12 -0
  21. package/hooks/scripts/lib/doc-reconciliation-core.js +56 -0
  22. package/hooks/scripts/lib/doc-reconciliation-producer.js +274 -12
  23. package/hooks/scripts/lib/doc-reconciliation-provider.js +1 -1
  24. package/multi-agent-workspace/SKILL.md +1 -1
  25. package/multi-agent-workspace/scripts/project-docs-resolver.js +12 -0
  26. package/opencode-plugin/plugin.json +1 -1
  27. package/package.json +1 -1
  28. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  29. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-core.js +56 -0
  30. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-producer.js +274 -12
  31. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-provider.js +1 -1
  32. package/plugins/harness-everything/plugin.json +1 -1
  33. package/plugins/harness-everything/skills/code-review/SKILL.md +1 -1
  34. package/plugins/harness-everything/skills/code-review/references/review-playbook.md +9 -0
  35. package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
  36. package/plugins/harness-everything/skills/harness-everything/scripts/doc-lifecycle.js +11 -3
  37. package/plugins/harness-everything/skills/harness-everything/scripts/project-docs-resolver.js +12 -0
  38. package/plugins/harness-everything/skills/multi-agent-workspace/SKILL.md +1 -1
  39. package/plugins/harness-everything/skills/multi-agent-workspace/scripts/project-docs-resolver.js +12 -0
  40. package/plugins/harness-everything/skills/tdd/SKILL.md +1 -1
  41. package/plugins/harness-everything/skills/tdd/fixtures/quality-scenarios.json +10 -2
  42. package/plugins/harness-everything/skills/tdd/references/quality-model.md +2 -0
  43. package/plugins/harness-everything/skills/tdd/schemas/quality-evidence.schema.json +21 -0
  44. package/plugins/harness-everything/skills/tdd/schemas/quality-report.schema.json +2 -0
  45. package/plugins/harness-everything/skills/tdd/scripts/quality-gate.js +165 -1
  46. package/plugins/harness-everything/skills/to-spec/SKILL.md +1 -1
  47. package/plugins/harness-everything/skills/to-spec/scripts/project-docs-resolver.js +12 -0
  48. package/plugins/harness-everything/skills/zoom-out/SKILL.md +1 -1
  49. package/plugins/harness-everything/skills/zoom-out/references/circuit-breaker.md +1 -0
  50. package/sbom.cdx.json +7 -7
  51. package/tdd/SKILL.md +1 -1
  52. package/tdd/fixtures/quality-scenarios.json +10 -2
  53. package/tdd/references/quality-model.md +2 -0
  54. package/tdd/schemas/quality-evidence.schema.json +21 -0
  55. package/tdd/schemas/quality-report.schema.json +2 -0
  56. package/tdd/scripts/quality-gate.js +165 -1
  57. package/to-spec/SKILL.md +1 -1
  58. package/to-spec/scripts/project-docs-resolver.js +12 -0
  59. package/zoom-out/SKILL.md +1 -1
  60. package/zoom-out/references/circuit-breaker.md +1 -0
@@ -533,7 +533,7 @@ test('the trusted inventory pin maps only owner-approved VERI rows 15–18 to EV
533
533
  const bytes = fs.readFileSync(path.join(ROOT,
534
534
  'benchmarks/results/live-host/claude-341-outline-dispatch-2026-10-28/inventory/2741bd4514d2cd68f57d93a2b3ce94cbe597ca62d30e26f0ca58e882df5b03c6.md'));
535
535
  const mapping = [{ rowIds: ['15', '16', '17', '18'], sourceDomain: 'VERI', domain: 'EVID',
536
- approvalRef: '../e1-feasibility-2026-10-28/owner-decision-veri-evid.md' }];
536
+ approvalRef: 'https://github.com/dyphn1/Harness-everything/issues/325#issuecomment-6706805112' }];
537
537
  assert.deepStrictEqual(pin.rowDomainMappings, mapping, 'the owner approval is part of the trusted pin');
538
538
  const valid = internals.validateOutlineInventoryPin({ bytes, pin, expectedPin: pin });
539
539
  assert.strictEqual(valid.state, 'accepted');
@@ -230,4 +230,218 @@ try {
230
230
  fs.rmSync(jsonOnlyDir, { recursive: true, force: true });
231
231
  }
232
232
 
233
- console.log(`Behavioral case validation passed (${fs.readdirSync(casesDir).filter(name => name.endsWith('.yaml')).length} cases + negative controls + ordered execution evidence + json-only final output).`);
233
+ // Skill-core completion: passing project tests is artifact evidence only; the
234
+ // TDD case also requires an executed quality gate command.
235
+ const tddCase = parseSimpleYaml(fs.readFileSync(path.join(casesDir, 'baseline-tdd-workflow.yaml'), 'utf8'));
236
+ const qualityGate = tddCase.expectations.find(expectation => expectation.type === 'tdd_quality_gate');
237
+ assert.ok(qualityGate, 'baseline TDD case must require a passed TDD quality gate');
238
+ // The gate is graded by independently re-evaluating retained evidence, so the
239
+ // workspace holds a real passing evidence file and a failing variant.
240
+ const PASS_EVIDENCE = fs.readFileSync(path.join(__dirname, '..', 'tdd', 'fixtures', 'pass-evidence.json'), 'utf8');
241
+ const FAIL_EVIDENCE = (() => {
242
+ const evidence = JSON.parse(PASS_EVIDENCE);
243
+ evidence.requirements = evidence.requirements.map(requirement => ({ ...requirement, sourceConformance: 'FAIL' }));
244
+ return JSON.stringify(evidence);
245
+ })();
246
+ const gateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'behavioral-tdd-quality-gate-'));
247
+ try {
248
+ fs.writeFileSync(path.join(gateDir, 'evidence.json'), PASS_EVIDENCE);
249
+ fs.writeFileSync(path.join(gateDir, 'failing-evidence.json'), FAIL_EVIDENCE);
250
+ fs.writeFileSync(path.join(gateDir, 'broken-evidence.json'), '{ not json');
251
+ // A string command defaults to the gate's own success line when it is a
252
+ // quality-gate invocation, so existing positives stay meaningful. An object
253
+ // command states the observed output and exit code explicitly.
254
+ const QUALITY_GATE_COMMAND_RE = /quality-gate\.js|tdd:quality|test:tdd-quality/;
255
+ const gradeCommands = (name, commands) => {
256
+ const transcriptPath = path.join(gateDir, `${name}.jsonl`);
257
+ const events = commands.map((entry, index) => {
258
+ const { command, output, exit } = typeof entry === 'string'
259
+ ? { command: entry, output: QUALITY_GATE_COMMAND_RE.test(entry) ? 'TDD Quality Gate: PASS' : '', exit: undefined }
260
+ : entry;
261
+ const state = { status: 'completed', input: { command } };
262
+ if (output !== undefined) state.output = output;
263
+ if (exit !== undefined) state.metadata = { exit };
264
+ return { type: 'tool_use', part: { type: 'tool', tool: 'Bash', callID: `${name}-${index}`, state } };
265
+ });
266
+ events.push({ type: 'text', part: { type: 'text', text: 'done' } });
267
+ fs.writeFileSync(transcriptPath, events.map(event => JSON.stringify(event)).join('\n'));
268
+ return grade({ expectations: [qualityGate] }, gateDir, transcriptPath);
269
+ };
270
+ const testsOnly = gradeCommands('tests-only', ['npm test']);
271
+ assert.strictEqual(testsOnly.passed, false, 'passing project tests without the quality gate must fail the TDD case');
272
+ assert.strictEqual(testsOnly.results[0].status, 'fail', 'missing quality gate must be a definitive fail, not inconclusive evidence');
273
+ assert.strictEqual(gradeCommands('npm-gate', ['npm test', 'npm run tdd:quality -- evidence.json']).passed, true, 'npm run tdd:quality with evidence must satisfy the gate');
274
+ assert.strictEqual(gradeCommands('node-gate', ['npm test', 'node tdd/scripts/quality-gate.js evidence.json']).passed, true, 'node quality-gate.js with evidence must satisfy the gate');
275
+ assert.strictEqual(gradeCommands('installed-skill-gate', ['npm test', 'node .claude/skills/tdd/scripts/quality-gate.js evidence.json']).passed, true, 'quality gate under the installed skill dir must satisfy the gate');
276
+ assert.strictEqual(gradeCommands('absolute-gate', ['npm test', 'node /tmp/ws/.harness/tdd/scripts/quality-gate.js evidence.json']).passed, true, 'absolute quality-gate path must satisfy the gate');
277
+ for (const [name, commands] of [
278
+ ['echo-mention', ['echo npm run tdd:quality']],
279
+ ['compound-gate', ['npm run tdd:quality -- evidence.json && rm -rf build']],
280
+ ['similar-name', ['npm run tdd:quality-notes']],
281
+ ]) {
282
+ assert.strictEqual(gradeCommands(name, commands).passed, false, `${name} must not count as an executed quality gate`);
283
+ }
284
+ // A completed tool call is not completion evidence: the gate outcome is
285
+ // re-evaluated from the retained evidence, independent of transcript claims.
286
+ const gateFailures = [
287
+ ['nonzero-exit', ['npm test', 'npm run tdd:quality -- failing-evidence.json'], /re-evaluation of failing-evidence\.json is FAIL/],
288
+ ['missing-evidence', ['npm test', 'npm run tdd:quality -- absent.json'], /not retained/],
289
+ ['invalid-evidence', ['npm test', 'node tdd/scripts/quality-gate.js broken-evidence.json'], /not valid JSON/],
290
+ ['no-evidence-arg', ['npm test', 'npm run tdd:quality'], /without an evidence file/],
291
+ ['self-test-alias', ['npm test', 'npm run test:tdd-quality'], /repository self-test/],
292
+ ['escaping-evidence', ['npm test', 'npm run tdd:quality -- ../evidence.json'], /outside the workspace/],
293
+ ['missing-output-report', ['npm test', 'npm run tdd:quality -- evidence.json --output reports/quality.json'], /quality report reports\/quality\.json was not retained/],
294
+ ['last-run-counts', ['npm run tdd:quality -- evidence.json', 'npm run tdd:quality -- failing-evidence.json'], /FAIL/],
295
+ ];
296
+ for (const [name, commands, reason] of gateFailures) {
297
+ const graded = gradeCommands(name, commands);
298
+ assert.strictEqual(graded.passed, false, `${name} must not satisfy the quality gate`);
299
+ assert.strictEqual(graded.results[0].status, 'fail', `${name} must be a definitive fail`);
300
+ assert.match(graded.results[0].reason, reason, `${name} should explain why`);
301
+ }
302
+ // OpenCode reports state.status "completed" even when the gate exits 1.
303
+ const opencodeExit1 = path.join(gateDir, 'opencode-exit1.jsonl');
304
+ fs.writeFileSync(opencodeExit1, [
305
+ { type: 'tool_use', part: { type: 'tool', tool: 'bash', callID: 'gate', state: { status: 'completed', input: { command: 'npm run tdd:quality -- failing-evidence.json' }, output: 'TDD Quality Gate: FAIL\nexit code 1' } } },
306
+ { type: 'text', part: { type: 'text', text: 'Quality gate done.' } },
307
+ ].map(event => JSON.stringify(event)).join('\n'));
308
+ assert.strictEqual(grade({ expectations: [qualityGate] }, gateDir, opencodeExit1).passed, false, 'completed OpenCode event with exit 1 must fail');
309
+ // Fixing evidence and re-running is allowed: the last completed run counts.
310
+ assert.strictEqual(gradeCommands('rerun-after-fix', ['npm run tdd:quality -- failing-evidence.json', 'npm run tdd:quality -- evidence.json']).passed, true, 'a passing re-run after a failing run should satisfy the gate');
311
+ // Observed command outcome: the transcript must show the gate's own success
312
+ // line and no error or non-zero exit before the evidence is trusted.
313
+ const { evaluateEvidence } = require('../tdd/scripts/quality-gate.js');
314
+ const observedGate = 'npm run tdd:quality -- evidence.json';
315
+ const observedFailures = [
316
+ ['failed-command-pass-evidence', { command: observedGate, output: 'npm ERR! Missing script: "tdd:quality"', exit: 1 }, /quality gate command failed/],
317
+ ['nonzero-exit-no-error-text', { command: observedGate, output: 'TDD Quality Gate: PASS', exit: 1 }, /exited with code 1/],
318
+ ['gate-printed-fail-evidence-now-pass', { command: observedGate, output: 'TDD Quality Gate: FAIL' }, /printed TDD Quality Gate: FAIL/],
319
+ ];
320
+ for (const [name, command, reason] of observedFailures) {
321
+ const graded = gradeCommands(name, [command]);
322
+ assert.strictEqual(graded.passed, false, `${name} must not satisfy the quality gate`);
323
+ assert.strictEqual(graded.results[0].status, 'fail', `${name} must be a definitive fail`);
324
+ assert.match(graded.results[0].reason, reason, `${name} should explain why`);
325
+ }
326
+ const unobservable = gradeCommands('unobservable-outcome', [{ command: observedGate, output: '' }]);
327
+ assert.strictEqual(unobservable.results[0].status, 'inconclusive', 'a gate run with no observable output or exit code must be inconclusive');
328
+ assert.strictEqual(unobservable.passed, false, 'an inconclusive gate run must not pass');
329
+ assert.match(unobservable.results[0].reason, /not observable in the transcript/, 'inconclusive reason should say the outcome is not observable');
330
+ // Definitive workspace failures win over an unobservable outcome.
331
+ const missingNoOutput = gradeCommands('missing-evidence-no-output', [{ command: 'npm run tdd:quality -- nope.json', output: '' }]);
332
+ assert.strictEqual(missingNoOutput.results[0].status, 'fail', 'missing evidence must be a definitive fail even with no observable output');
333
+ assert.match(missingNoOutput.results[0].reason, /not retained/, 'missing evidence should be named');
334
+ // Only the error signal is line-anchored: prose mentioning npm errors passes,
335
+ // while a real npm error line still fails.
336
+ assert.strictEqual(gradeCommands('prose-npm-error', [{ command: observedGate, output: 'TDD Quality Gate: PASS\n- note: avoid npm error handling', exit: 0 }]).passed, true, 'prose mentioning npm error must not fail a passing gate');
337
+ const npmErrorLine = gradeCommands('npm-error-line', [{ command: observedGate, output: 'npm error Missing script: "tdd:quality"' }]);
338
+ assert.strictEqual(npmErrorLine.results[0].status, 'fail', 'an npm error line must be a definitive fail');
339
+ assert.match(npmErrorLine.results[0].reason, /npm error/, 'npm error line should be named');
340
+ // npm ERR!/error lines after a PASS line must fail even without exit metadata;
341
+ // the boundary after the token must match whitespace or end of line.
342
+ const npmTrailer = (name, output) => gradeCommands(name, [{ command: observedGate, output }]);
343
+ for (const [name, output] of [
344
+ ['npm-err-bang-eacces', 'TDD Quality Gate: PASS\nnpm ERR! code EACCES'],
345
+ ['npm-err-bang-eresolve', 'TDD Quality Gate: PASS\nnpm ERR! code ERESOLVE'],
346
+ ['npm-error-elifecycle', 'TDD Quality Gate: PASS\nnpm error code ELIFECYCLE'],
347
+ ['npm-err-bang-eol', 'TDD Quality Gate: PASS\nnpm ERR!'],
348
+ ]) {
349
+ const graded = npmTrailer(name, output);
350
+ assert.strictEqual(graded.results[0].status, 'fail', `${name} must be a definitive fail`);
351
+ assert.strictEqual(graded.passed, false, `${name} must not satisfy the quality gate`);
352
+ assert.match(graded.results[0].reason, /npm error/i, `${name} should name the npm error`);
353
+ }
354
+ assert.strictEqual(npmTrailer('npm-errors-word', 'TDD Quality Gate: PASS\nnpm ERRORS are documented').passed, true, 'a word that only starts with ERR must not be an npm error');
355
+ const { GATE_COMMAND_ERROR_RE } = require('../behavioral-evals/run');
356
+ assert.strictEqual(GATE_COMMAND_ERROR_RE.test('npm ERR! code EACCES'), true, 'npm ERR! line must match the error regex');
357
+ assert.strictEqual(GATE_COMMAND_ERROR_RE.test('npm ERRORS'), false, 'npm ERRORS must not match the error regex');
358
+
359
+ // Claude stream-json: is_error on the tool_result is the host's failure
360
+ // signal; the call is recorded as failed, so it never counts as executed.
361
+ const claudeGate = (name, toolResult) => {
362
+ const transcriptPath = path.join(gateDir, `${name}.jsonl`);
363
+ fs.writeFileSync(transcriptPath, [
364
+ { type: 'assistant', message: { content: [{ type: 'tool_use', id: 'bash-1', name: 'Bash', input: { command: observedGate } }] } },
365
+ { type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'bash-1', ...toolResult }] } },
366
+ { type: 'result', subtype: 'success', result: 'done' },
367
+ ].map(event => JSON.stringify(event)).join('\n'));
368
+ return grade({ expectations: [qualityGate] }, gateDir, transcriptPath);
369
+ };
370
+ const claudeError = claudeGate('claude-error', { is_error: true, content: 'npm ERR! Missing script' });
371
+ assert.strictEqual(claudeError.passed, false, 'Claude tool_result with is_error must not satisfy the gate');
372
+ assert.strictEqual(claudeError.results[0].status, 'fail', 'Claude tool_result error must be a definitive fail');
373
+ assert.match(claudeError.results[0].reason, /last quality gate run \(bash-1\) failed/, 'Claude errored gate call should be the failing last run');
374
+ assert.strictEqual(claudeGate('claude-success', { is_error: false, content: [{ type: 'text', text: 'TDD Quality Gate: PASS' }] }).passed, true, 'Claude tool_result carrying the gate success line should pass');
375
+ // Runs are graded in transcript order: the last matching attempt decides, so an
376
+ // earlier PASS never hides a later failed or unresolved run.
377
+ const claudeRuns = (name, runs) => {
378
+ const transcriptPath = path.join(gateDir, `${name}.jsonl`);
379
+ const events = [];
380
+ runs.forEach(({ command, result }, index) => {
381
+ const id = `${name}-bash-${index}`;
382
+ events.push({ type: 'assistant', message: { content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] } });
383
+ if (result) events.push({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: id, ...result }] } });
384
+ });
385
+ events.push({ type: 'assistant', message: { content: [{ type: 'text', text: 'done' }] } });
386
+ events.push({ type: 'result', subtype: 'success', result: 'done' });
387
+ fs.writeFileSync(transcriptPath, events.map(event => JSON.stringify(event)).join('\n'));
388
+ return grade({ expectations: [qualityGate] }, gateDir, transcriptPath);
389
+ };
390
+ const gatePass = { is_error: false, content: [{ type: 'text', text: 'TDD Quality Gate: PASS' }] };
391
+ const gateFail = { is_error: true, content: 'TDD Quality Gate: FAIL' };
392
+ const lastRunError = claudeRuns('claude-pass-then-error', [
393
+ { command: observedGate, result: gatePass },
394
+ { command: observedGate, result: gateFail },
395
+ ]);
396
+ assert.strictEqual(lastRunError.results[0].status, 'fail', 'a failed last run must be a definitive fail even after an earlier PASS');
397
+ assert.strictEqual(lastRunError.passed, false, 'an earlier PASS must not satisfy the gate after a failed last run');
398
+ assert.match(lastRunError.results[0].reason, /last quality gate run/, 'the failed last run should be named');
399
+ const lastRunUnresolved = claudeRuns('claude-pass-then-unresolved', [
400
+ { command: observedGate, result: gatePass },
401
+ { command: observedGate },
402
+ ]);
403
+ assert.strictEqual(lastRunUnresolved.results[0].status, 'inconclusive', 'an unresolved last run must be inconclusive, not a pass');
404
+ assert.strictEqual(lastRunUnresolved.passed, false, 'an unresolved last run must not pass the gate');
405
+ assert.match(lastRunUnresolved.results[0].reason, /no observable result/, 'the unresolved last run should be named');
406
+ const otherEvidenceError = claudeRuns('claude-pass-then-other-evidence', [
407
+ { command: observedGate, result: gatePass },
408
+ { command: 'node tdd/scripts/quality-gate.js failing-evidence.json', result: gateFail },
409
+ ]);
410
+ assert.strictEqual(otherEvidenceError.results[0].status, 'fail', 'a failed run against other evidence must fail the gate');
411
+ assert.match(otherEvidenceError.results[0].reason, /last quality gate run/, 'the failed last run against other evidence should be named');
412
+ assert.strictEqual(claudeRuns('claude-fail-then-retry', [
413
+ { command: observedGate, result: gateFail },
414
+ { command: observedGate, result: gatePass },
415
+ ]).passed, true, 'a failed run followed by a successful retry must pass');
416
+ // Retained --output report: a full report must be present and must equal the
417
+ // fresh re-evaluation over every top-level key.
418
+ const realReport = evaluateEvidence(JSON.parse(PASS_EVIDENCE));
419
+ assert.strictEqual(realReport.result, 'PASS', 'the pass fixture must re-evaluate to PASS');
420
+ fs.mkdirSync(path.join(gateDir, 'reports'), { recursive: true });
421
+ const reportPath = path.join(gateDir, 'reports', 'quality.json');
422
+ fs.writeFileSync(reportPath, JSON.stringify({ result: 'PASS' }));
423
+ const markerOnly = gradeCommands('marker-output-report', ['npm run tdd:quality -- evidence.json --output reports/quality.json']);
424
+ assert.strictEqual(markerOnly.passed, false, 'a {"result":"PASS"} marker must not satisfy a retained report');
425
+ assert.match(markerOnly.results[0].reason, /missing required keys: .*requirements/, 'a marker-only retained report should name the missing keys');
426
+ fs.writeFileSync(reportPath, JSON.stringify(realReport, null, 2));
427
+ assert.strictEqual(gradeCommands('retained-output-report', ['npm run tdd:quality -- evidence.json --output reports/quality.json']).passed, true, 'retained --output report equal to the re-evaluation should pass');
428
+ fs.writeFileSync(reportPath, JSON.stringify({ ...realReport, score: realReport.score - 1 }, null, 2));
429
+ const staleScore = gradeCommands('stale-output-report', ['npm run tdd:quality -- evidence.json --output reports/quality.json']);
430
+ assert.strictEqual(staleScore.passed, false, 'a retained report with a stale score must fail');
431
+ assert.match(staleScore.results[0].reason, /differs from re-evaluation at "score"/, 'a stale retained report should name the differing key');
432
+ // Without --output the gate prints its report to stdout; that printed report
433
+ // must equal the fresh re-evaluation of the evidence the command names.
434
+ const printedText = report => `TDD Quality Gate: PASS\n${JSON.stringify(report, null, 2)}`;
435
+ assert.strictEqual(gradeCommands('stdout-report-equal', [{ command: observedGate, output: printedText(realReport) }]).passed, true, 'a printed report equal to the re-evaluation should pass');
436
+ const otherEvidence = JSON.parse(PASS_EVIDENCE);
437
+ otherEvidence.requirements[0].requirementId = 'REQ-OTHER';
438
+ fs.writeFileSync(path.join(gateDir, 'other-evidence.json'), JSON.stringify(otherEvidence));
439
+ const otherReport = evaluateEvidence(otherEvidence);
440
+ const stdoutMismatch = gradeCommands('stdout-report-other', [{ command: observedGate, output: printedText(otherReport) }]);
441
+ assert.strictEqual(stdoutMismatch.passed, false, 'a printed report from different evidence must fail');
442
+ assert.match(stdoutMismatch.results[0].reason, /gate output report differs from re-evaluation at "[a-zA-Z]+"/, 'a mismatched printed report should name the differing key');
443
+ } finally {
444
+ fs.rmSync(gateDir, { recursive: true, force: true });
445
+ }
446
+
447
+ console.log(`Behavioral case validation passed (${fs.readdirSync(casesDir).filter(name => name.endsWith('.yaml')).length} cases + negative controls + ordered execution evidence + json-only final output + tdd quality gate).`);
@@ -4,7 +4,7 @@ description: "Independently review a PR, diff or finished implementation for cor
4
4
  license: Apache-2.0
5
5
  metadata:
6
6
  author: Miya Daniel
7
- version: 0.40.0
7
+ version: 0.47.5
8
8
  ---
9
9
 
10
10
  # Code Review
@@ -19,6 +19,15 @@ Use this checklist for the applicable risks; do not force irrelevant categories.
19
19
  - [ ] Follow each important diagnostic/fact through every consumer and ancestor. Check whether required facts are computed before derived authority, indexes, or output.
20
20
  - [ ] Compare new rules with previously accepted rules and controls. Separate display severity from safety prerequisites.
21
21
 
22
+ ## Check evidence a gate trusts
23
+
24
+ Apply these when the change grades, gates or labels evidence. Authors SHOULD run them as a self-review before push.
25
+
26
+ - [ ] **Claimed vs observed.** Separate what the subject reports about itself (a tool's `completed` status, a result marker, an agent-supplied expected value, a status label) from what the checker observes independently (exit code, output, a recomputed result, the quoted source). A self-report alone never passes; when nothing independent is observable, the result is inconclusive.
27
+ - [ ] **Select, then judge.** When the last, first or any item of a sequence decides (last run, final message, latest attempt), select from every attempt in order before filtering by outcome. Test both orders: failure then success, and success then failure or no result.
28
+ - [ ] **One witness per rule.** Give each detection rule a negative case that triggers only that rule; a case that also trips an exit code or another pattern cannot show the rule works. Probe positive cases at structural boundaries (inside a token, string or quote), not only at safe separators.
29
+ - [ ] **Comparable subjects, honest labels.** A comparison or conflict needs both sides to describe the same target. A status name claims only what was checked: a consistent quote is not a validated diagnosis.
30
+
22
31
  ## Check tests and output
23
32
 
24
33
  - [ ] Ask whether each test still passes if the behavior is disabled, bypassed, or broken (mutation thought experiment). Confirm the test is included in CI.
@@ -406,7 +406,9 @@ cannot replace that baseline with the latest HEAD or trust a persisted PASS.
406
406
  and governed cases exercise actual diffs. Fixtures without `baseFiles` remain non-Git, so their
407
407
  baseline is unavailable and credit is withheld. Fixture maps alone are not runtime authority.
408
408
 
409
- - **Accepted S1 residual (gitignored paths):** ignored, untracked candidate content is outside the non-ignored inventory. S1 grants no positive credit either way; the S2 producer must close this gap or explicitly re-accept it.
409
+ - **F4 inventory coverage (S2 producer):** ignored and untracked paths remain in the candidate inventory, including new files and rename destinations, except for the bounded dependency-artifact policy below. With no policy, there are no new exclusions.
410
+ - **Dependency-artifact policy:** the original project config `.agents/harness-everything/project-docs.json` may set `inventory.dependencyArtifactRoots` to any of `node_modules` and `.husky/_` (each is optional). These exact roots are pinned from baseline B; the candidate field must match B. Candidate-only config, host config, `.gitignore`, globs, aliases and other paths do not authorize pruning.
411
+ - Before pruning, baseline and current-index paths, config sources, governed/legacy selectors, derived sources/outputs, and explicit task inputs are checked for overlap. Any overlap, ambiguous selector, unsafe root (file, symlink, alias, or outside the workspace), or retained-path symlink makes capture unavailable. Package files and `.bin` symlinks inside an approved root are not traversed; lockfiles, other paths, and ignored content outside the roots remain covered. Exclusion means installed bytes are outside this task's identity and ordinary code/frontmatter facts; it does not attest dependency contents or execution behavior.
410
412
 
411
413
  ### Refusal and completion contract
412
414
 
@@ -4,7 +4,7 @@ description: "Route software/project work through Harness: classify tier/topolog
4
4
  license: Apache-2.0
5
5
  metadata:
6
6
  author: Miya Daniel
7
- version: 0.47.3
7
+ version: 0.48.0
8
8
  ---
9
9
 
10
10
  # Harness Everything
@@ -1701,6 +1701,15 @@ function parseInventoryDoc(relativePath, bytes) {
1701
1701
  };
1702
1702
  }
1703
1703
 
1704
+ function convertWorkspaceFiles(root, inventory) {
1705
+ const files = (Array.isArray(inventory) ? inventory : []).map(item => ({
1706
+ relative: item.path,
1707
+ bytes: Buffer.from(item.bytes)
1708
+ }));
1709
+ const rules = readWorkingTreeRules(root, files.filter(item => item.bytes.includes(0x0d)).map(item => item.relative));
1710
+ return files.map(item => ({ path: item.relative, bytes: workingStoredBytes(root, item.relative, item.bytes, rules) }));
1711
+ }
1712
+
1704
1713
  function scanWorkspaceFiles(root, scope = null) {
1705
1714
  if (!root) return [];
1706
1715
  const workspace = path.resolve(root);
@@ -1728,8 +1737,7 @@ function scanWorkspaceFiles(root, scope = null) {
1728
1737
  }
1729
1738
  }
1730
1739
  walk(workspace, '');
1731
- const rules = readWorkingTreeRules(workspace, files.filter(item => item.bytes.includes(0x0d)).map(item => item.relative));
1732
- return files.map(item => ({ path: item.relative, bytes: workingStoredBytes(workspace, item.relative, item.bytes, rules) }));
1740
+ return convertWorkspaceFiles(workspace, files.map(item => ({ path: item.relative, bytes: item.bytes })));
1733
1741
  }
1734
1742
 
1735
1743
  function scanWorkspaceInventory(root) {
@@ -2785,6 +2793,6 @@ function main(argv = process.argv.slice(2)) {
2785
2793
  return analysis.diagnostics.some(item => item.severity === 'error') ? 1 : 0;
2786
2794
  }
2787
2795
 
2788
- module.exports = { readGitBaseInventory, scanWorkspaceFiles, parseFrontmatter, parseRequirementBlocks, collectDocs, documentCorpusPaths, analyzeDocs, resolveRef, lintDocs, buildIndex, buildIndexSnapshot, configSourceInventory, diffRevisions, buildGitDocumentInventory };
2796
+ module.exports = { readGitBaseInventory, scanWorkspaceFiles, convertWorkspaceFiles, parseFrontmatter, parseRequirementBlocks, collectDocs, documentCorpusPaths, analyzeDocs, resolveRef, lintDocs, buildIndex, buildIndexSnapshot, configSourceInventory, diffRevisions, buildGitDocumentInventory };
2789
2797
 
2790
2798
  if (require.main === module) process.exitCode = main();
@@ -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');
@@ -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)