@namewta/speculo 2.0.2 → 2.0.4

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 (55) hide show
  1. package/README.md +2 -0
  2. package/dist/src/agent-skills.js +2 -2
  3. package/dist/src/agent-skills.js.map +1 -1
  4. package/package.json +1 -1
  5. package/template/canonical/canonical-specdev-goal-plan.md +1636 -611
  6. package/template/canonical/canonical-specdev-grill-with-docs.md +237 -39
  7. package/template/canonical/canonical-specdev-spec.md +249 -95
  8. package/template/canonical/canonical-specdev-tickets.md +261 -153
  9. package/template/canonical/canonical-specdev-wayfinder.md +232 -28
  10. package/template/commands/git-history-squash/scripts/git-history-squash.mjs +4 -4
  11. package/template/commands/handoff/COMMAND.md +10 -0
  12. package/template/references/coordination/coordination-contract.md +5 -3
  13. package/template/references/coordination/modes/cli.md +2 -2
  14. package/template/references/coordination/modes/herdr.md +2 -2
  15. package/template/references/coordination/modes/orca.md +32 -9
  16. package/template/references/coordination/result-and-recovery.md +6 -3
  17. package/template/references/coordination/session-files.md +93 -0
  18. package/template/references/coordination/task-brief.md +6 -4
  19. package/template/workflows/specdev/common/rules/adr-format.md +5 -11
  20. package/template/workflows/specdev/common/rules/change-completion.md +3 -0
  21. package/template/workflows/specdev/common/rules/path-reference-contract.md +2 -0
  22. package/template/workflows/specdev/common/rules/planning-principles.md +1 -1
  23. package/template/workflows/specdev/common/rules/readiness-and-depth.md +5 -5
  24. package/template/workflows/specdev/common/rules/workflow-state-and-lifecycle.md +2 -0
  25. package/template/workflows/specdev/common/skills/code-review/SKILL.md +11 -8
  26. package/template/workflows/specdev/common/skills/code-review/references/result-reuse.md +3 -3
  27. package/template/workflows/specdev/common/skills/code-review/references/review-evidence.md +44 -0
  28. package/template/workflows/specdev/common/skills/code-review/references/reviewer-contracts.md +17 -5
  29. package/template/workflows/specdev/common/skills/code-review/references/working-tree-review.md +15 -0
  30. package/template/workflows/specdev/common/skills/dev-worktree/references/finalize.md +2 -0
  31. package/template/workflows/specdev/common/tools/README.md +8 -0
  32. package/template/workflows/specdev/common/tools/change-artifacts.mjs +3 -0
  33. package/template/workflows/specdev/common/tools/plan-contract.mjs +2 -3
  34. package/template/workflows/specdev/common/tools/review-evidence.mjs +117 -0
  35. package/template/workflows/specdev/common/tools/review-scope.mjs +92 -5
  36. package/template/workflows/specdev/common/tools/tests/review-evidence.test.mjs +74 -0
  37. package/template/workflows/specdev/common/tools/tests/review-fixture.mjs +31 -0
  38. package/template/workflows/specdev/common/tools/tests/review-working-tree.test.mjs +60 -0
  39. package/template/workflows/specdev/common/tools/validate-specdev.mjs +79 -37
  40. package/template/workflows/specdev/specdev-c-code-review/SKILL.md +4 -4
  41. package/template/workflows/specdev/specdev-c-code-review/assets/code-review-template.md +5 -1
  42. package/template/workflows/specdev/specdev-c-code-review/references/source-files-review.md +2 -2
  43. package/template/workflows/specdev/specdev-i-implement/SKILL.md +1 -1
  44. package/template/workflows/specdev/specdev-i-implement/assets/evidence-template.md +9 -4
  45. package/template/workflows/specdev/specdev-i-implement/references/implementation-procedure.md +6 -2
  46. package/template/workflows/specdev/specdev-p-goal-plan/SKILL.md +1 -1
  47. package/template/workflows/specdev/specdev-p-goal-plan/assets/goal-plan-template.md +2 -0
  48. package/template/workflows/specdev/specdev-p-goal-plan/assets/lightweight-goal-plan-template.md +2 -0
  49. package/template/workflows/specdev/specdev-p-goal-plan/references/completion-control.md +3 -0
  50. package/template/workflows/specdev/specdev-s-spec/SKILL.md +2 -2
  51. package/template/workflows/specdev/specdev-s-spec/assets/spec-template.md +15 -65
  52. package/template/workflows/specdev/specdev-t-tickets/assets/ticket-template.md +19 -103
  53. package/template/workflows/specdev/specdev-t-tickets/assets/tickets-map-template.md +2 -0
  54. package/template/workflows/specdev/specdev-t-tickets/references/planning-procedure.md +5 -19
  55. package/template/workflows/specdev/specdev-t-tickets/references/ticket-readiness.md +4 -4
@@ -0,0 +1,117 @@
1
+ /** Read-only checks of review records. Tool receipts still require Lead verification. */
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync } from 'node:fs';
4
+ import { relative, resolve } from 'node:path';
5
+ import { markdownBodyLines, readArtifactSectionSync, readUtf8ArtifactSync, safeWorkPathSync } from '../../../../lib/speculo-runtime.mjs';
6
+ import { resolveReviewScope } from './review-scope.mjs';
7
+
8
+ const hash = value => createHash('sha256').update(value).digest('hex');
9
+ const real = value => typeof value === 'string' && value.trim() && !/^(?:<[^>]*>|unknown|unassigned|pending|todo|tbd|\.\.\.)$/i.test(value.trim());
10
+ const identity = value => real(value) && /^[a-z][a-z0-9-]*:\S+$/i.test(value) && real(value.slice(value.indexOf(':')+1));
11
+ function fields(text) {
12
+ const result = {};
13
+ for (const {text:line} of markdownBodyLines(text)) {
14
+ const match = /^([A-Za-z][A-Za-z0-9-]*):\s*(.*)$/.exec(line);
15
+ if (!match) continue;
16
+ if (Object.hasOwn(result, match[1])) throw new Error(`duplicate ${match[1]}`);
17
+ result[match[1]] = match[2].trim();
18
+ }
19
+ return result;
20
+ }
21
+ function section(text, heading) {
22
+ const lines = markdownBodyLines(text), matches = lines.filter(line => line.text === `## ${heading}`);
23
+ if (matches.length !== 1) throw new Error(`expected one ${heading} section`);
24
+ const start = matches[0].line, end = lines.find(line => line.line > start && /^#{1,2} /.test(line.text))?.line;
25
+ return text.split(/\r?\n/).slice(start, end ? end-1 : undefined).join('\n');
26
+ }
27
+ function readLocator(root, locator) {
28
+ if (!real(locator)) throw new Error('missing original evidence locator');
29
+ const parts = locator.split('#');
30
+ if (parts.length > 2 || parts.length === 2 && !parts[1]) throw new Error('invalid evidence locator');
31
+ const path = safeWorkPathSync(root, parts[0] || 'README.md', {mustExist:true, type:'file'});
32
+ return {path, text:parts.length === 2 ? readArtifactSectionSync(path, parts[1]).text : readUtf8ArtifactSync(path)};
33
+ }
34
+ export function reviewInputDigest(input) { return hash(JSON.stringify(input)); }
35
+
36
+ /** Sources use project-relative locators; an anchor avoids a README hashing itself. */
37
+ export function validateIndependentReview(root, artifact, {repoRoot=null, requirePassed=true, expectedHead=null, expectedBase=null, expectedResults=null, requiredImplementers=[], requiredSpecifications=[], checkCurrent=false, requireDelivery=false, allowSourceFiles=false} = {}) {
38
+ const errors = [], label = `independent-review: ${artifact}`;
39
+ try {
40
+ const path = safeWorkPathSync(root, artifact, {mustExist:true, type:'file'});
41
+ const text = readUtf8ArtifactSync(path), body = section(text,'Independent Review'), data = fields(body);
42
+ const implementers = (data.Implementers ?? '').split(',').map(x=>x.trim()).filter(Boolean);
43
+ if (!implementers.length || implementers.some(id=>!identity(id)) || new Set(implementers).size !== implementers.length) throw new Error('Implementers must contain actual namespaced agent/session IDs');
44
+ if (requiredImplementers.some(id=>!implementers.includes(id))) throw new Error('implementation owner missing from Implementers');
45
+ if (!identity(data['Verified-By'])) throw new Error('missing actual Verified-By Lead identity');
46
+ if (!['native','herdr','orca','cli'].includes(data.Mode)) throw new Error('invalid review Mode');
47
+ const inputSource = readLocator(root,data.Input);
48
+ const blocks = [...inputSource.text.matchAll(/```json\s*\n([\s\S]*?)\n```/g)];
49
+ if (blocks.length !== 1) throw new Error('Input must reference one frozen review JSON object');
50
+ const input = JSON.parse(blocks[0][1]), scope = input.scope;
51
+ if (!scope || !Array.isArray(input.sources) || !input.sources.length) throw new Error('Input needs scope and actual standards/specification/instruction sources');
52
+ if (!repoRoot && checkCurrent) throw new Error('current input checks require explicit project root');
53
+ const sourceKinds = new Set();
54
+ for (const source of input.sources) {
55
+ if (!['standards','specification','instructions'].includes(source.kind) || !real(source.locator) || !/^[a-f0-9]{64}$/.test(source.sha256 ?? '')) throw new Error('invalid review source');
56
+ sourceKinds.add(source.kind);
57
+ if (repoRoot && checkCurrent && hash(readLocator(repoRoot,source.locator).text) !== source.sha256) throw new Error(`source drift: ${source.locator}`);
58
+ }
59
+ if (!sourceKinds.has('standards') || requirePassed && !sourceKinds.has('specification')) throw new Error('missing required review sources');
60
+ if (requiredSpecifications.some(locator=>!input.sources.some(source=>source.kind === 'specification' && source.locator === locator))) throw new Error('review omits the authoritative specification/acceptance source');
61
+ const digest = reviewInputDigest(input);
62
+ const rows = markdownBodyLines(body).filter(({text:line})=>/^\|\s*(standards|specification)\s*\|/.test(line)).map(({text:line})=>line.split('|').slice(1,-1).map(x=>x.trim()));
63
+ if (rows.length !== 2 || new Set(rows.map(row=>row[0])).size !== 2) throw new Error('expected exactly one result per axis');
64
+ for (const [axis,reviewer,result,locator,...extra] of rows) {
65
+ if (extra.length || !identity(reviewer)) throw new Error('invalid reviewer row');
66
+ if (implementers.includes(reviewer)) throw new Error('reviewer participated in implementation');
67
+ if (!['pass','request-changes','skipped'].includes(result) || requirePassed && result !== 'pass' || axis === 'standards' && result === 'skipped') throw new Error(`${axis} has not passed`);
68
+ if (expectedResults && expectedResults[axis] !== result) throw new Error(`${axis} result differs from the owning report`);
69
+ const report = readLocator(root,locator), reportFields = fields(report.text);
70
+ if (report.path === path && !locator.includes('#')) throw new Error('report must reference original output, not its summary');
71
+ if (reportFields.Reviewer !== reviewer || reportFields.Axis !== axis || reportFields.Result !== result || reportFields['Input-SHA256'] !== digest || !real(reportFields.Observed)) throw new Error(`${axis} original report does not match the review record`);
72
+ }
73
+ const receipt = fields(readLocator(root,data.Verification).text);
74
+ const reviewers = (receipt.Reviewers ?? '').split(',').map(x=>x.trim());
75
+ if (receipt['Verified-By'] !== data['Verified-By'] || receipt.Mode !== data.Mode || receipt.Result !== 'verified' || receipt['Input-SHA256'] !== digest || !real(receipt.Observed) || rows.some(row=>!reviewers.includes(row[1]))) throw new Error('missing matching Lead verification of actual dispatch/return receipts');
76
+ if (scope.mode === 'source-files' && !allowSourceFiles) throw new Error('source-files cannot substitute implementation change review');
77
+ if (!['baseline','range','commit','pr','source-files','working-tree'].includes(scope.mode)) throw new Error('invalid frozen review scope');
78
+ if (scope.outcome === 'no-diff') throw new Error('no-diff is not an independent approval');
79
+ if (expectedHead && (scope.mode === 'working-tree' ? scope.observed_head : scope.head_sha) !== expectedHead) throw new Error('review does not cover the expected checkpoint');
80
+ if (expectedBase && scope.base_sha !== expectedBase) throw new Error('review does not cover the implementation baseline');
81
+ if (scope.mode === 'working-tree' && scope.excluded_paths?.length && !real(input.exclusions)) throw new Error('excluded changes require an explicit scope/ownership rationale');
82
+ if (checkCurrent) {
83
+ const options = {repo:repoRoot,mode:scope.mode};
84
+ if (scope.mode === 'working-tree') { options.base=scope.base_sha; if(scope.selection === 'explicit-files') options.files=scope.scope_paths; }
85
+ else if (scope.mode === 'source-files') options.files=scope.source_snapshot?.map(file=>file.path);
86
+ else if (scope.mode === 'commit') { options.commit=scope.head_sha; if(scope.parent) options.parent=scope.parent; }
87
+ else { options.base=scope.base_sha; options.head=scope.head_sha; }
88
+ const current = resolveReviewScope(options);
89
+ // Re-read all scope metadata, not just the HEAD; dirty files are not commits.
90
+ const comparable = value => {
91
+ if (value.mode !== 'working-tree' || value.selection !== 'explicit-files') return value;
92
+ // New report files outside an explicit review scope are expected. Their
93
+ // existence must not create a self-referential evidence digest.
94
+ const {excluded_paths, inventory_sha256, input_sha256, ...selected} = value;
95
+ return selected;
96
+ };
97
+ if (JSON.stringify(comparable(current)) !== JSON.stringify(comparable(scope))) throw new Error('frozen review input drifted; run independent review again');
98
+ if (requireDelivery && scope.mode !== 'working-tree' && scope.mode !== 'source-files') {
99
+ // A commit report certifies that tree, not later local edits. Markdown
100
+ // bookkeeping in the owning change may advance after the code review;
101
+ // authoritative specification sources are still hashed above.
102
+ const live = resolveReviewScope({repo:repoRoot,mode:'working-tree',base:scope.head_sha});
103
+ const recordPath = file => {
104
+ const local = relative(resolve(root),resolve(repoRoot,file)).split('\\').join('/');
105
+ return /^(?:README\.md|goal-plan\.md|tickets-map\.md|implementation-(?:plan|map)\.md|(?:evidence|reviews|ticket)\/[^/]+\.md)$/.test(local);
106
+ };
107
+ const dirty = live.changed_paths.filter(file=>!recordPath(file));
108
+ if (dirty.length) throw new Error(`unreviewed working-tree delivery changes: ${dirty.join(', ')}; freeze and review the actual working-tree scope`);
109
+ }
110
+ }
111
+ } catch(error) { errors.push(`${label}: ${error.message}`); }
112
+ return errors;
113
+ }
114
+
115
+ export function hasIndependentReview(path) {
116
+ return existsSync(path) && markdownBodyLines(readUtf8ArtifactSync(path)).some(line=>line.text === '## Independent Review');
117
+ }
@@ -1,10 +1,96 @@
1
1
  /** Read-only review selection. Freezes local files or Git refs; never fetches or writes state. */
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { createHash } from 'node:crypto';
4
- import { closeSync, constants, fstatSync, openSync, readFileSync, realpathSync, statSync } from 'node:fs';
5
- import { isAbsolute, relative, resolve, sep } from 'node:path';
4
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readlinkSync, realpathSync, statSync } from 'node:fs';
5
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
6
6
 
7
- const modes = new Set(['baseline', 'range', 'commit', 'pr', 'source-files']);
7
+ const modes = new Set(['baseline', 'range', 'commit', 'pr', 'source-files', 'working-tree']);
8
+ const digest = bytes => createHash('sha256').update(bytes).digest('hex');
9
+ function portableFile(path) {
10
+ if (typeof path !== 'string' || !path || isAbsolute(path) || /[\\:\x00-\x1f]/.test(path) || path.split('/').some(part => !part || part === '.' || part === '..' || part.toLowerCase() === '.git')) {
11
+ throw new Error('review-scope: file must be an explicit portable project-relative path outside .git');
12
+ }
13
+ return path;
14
+ }
15
+
16
+ // Read a link itself, never its target. Directory links are rejected before any
17
+ // file read; a file replaced by a directory is a deletion plus added children.
18
+ function workingFile(root, path) {
19
+ portableFile(path);
20
+ const requested = resolve(root, path);
21
+ for (let parent = dirname(requested); parent !== root; parent = dirname(parent)) {
22
+ try { if (lstatSync(parent).isSymbolicLink()) throw new Error('review-scope: symlink directory in working-tree scope'); }
23
+ catch (error) { if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error; }
24
+ }
25
+ let info;
26
+ try { info = lstatSync(requested); }
27
+ catch (error) { if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null; throw error; }
28
+ if (info.isDirectory()) return null;
29
+ if (info.isSymbolicLink()) {
30
+ const bytes = readlinkSync(requested, {encoding:'buffer'});
31
+ return {kind:'symlink', mode:'120000', sha256:digest(bytes), bytes:bytes.length};
32
+ }
33
+ if (!info.isFile()) throw new Error('review-scope: unsupported working-tree file type');
34
+ const fd = openSync(requested, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0));
35
+ try {
36
+ const before = fstatSync(fd, {bigint:true});
37
+ if (!before.isFile()) throw new Error('review-scope: working-tree file type changed');
38
+ const bytes = readFileSync(fd), after = fstatSync(fd, {bigint:true}), current = lstatSync(requested, {bigint:true});
39
+ if (!['dev','ino','size','mode','mtimeNs','ctimeNs'].every(key => before[key] === after[key] && after[key] === current[key])) throw new Error('review-scope: working-tree changed while freezing inputs');
40
+ return {kind:'file', mode:before.mode & 0o111n ? '100755' : '100644', sha256:digest(bytes), bytes:bytes.length};
41
+ } finally { closeSync(fd); }
42
+ }
43
+
44
+ function workingTree(options) {
45
+ if (['head','commit','parent'].some(key => options[key] !== undefined)) throw new Error('review-scope: working-tree accepts base, not head/commit/parent');
46
+ // Native realpath expands Windows short names; relative also handles drive
47
+ // casing without admitting a nested directory that borrows its parent's Git.
48
+ const root = realpathSync.native(resolve(options.repo));
49
+ if (relative(root,realpathSync.native(git(root, ['rev-parse','--show-toplevel']).trim())) !== '') throw new Error('review-scope: working-tree requires the actual project Git root');
50
+ const base = commit(root, options.base, 'base');
51
+ const files = options.files?.map(portableFile);
52
+ if (files && (!files.length || new Set(files).size !== files.length)) throw new Error('review-scope: empty or duplicate working-tree scope');
53
+ const capture = () => {
54
+ if (git(root, ['ls-files','--unmerged','-z'])) throw new Error('review-scope: unresolved index conflicts');
55
+ const head = commit(root, 'HEAD', 'HEAD');
56
+ const index = git(root, ['ls-files','--stage','-z']);
57
+ const inventory = git(root, ['status','--porcelain=v1','-z','--untracked-files=all']);
58
+ const baseEntries = new Map(git(root, ['ls-tree','-r','-z','--full-tree',base]).split('\0').filter(Boolean).map(row => {
59
+ const tab = row.indexOf('\t'), [mode,type,oid] = row.slice(0,tab).split(' ');
60
+ return [row.slice(tab+1), {mode,type,oid}];
61
+ }));
62
+ const diffArgs = ['diff','--no-color','--no-ext-diff','--no-textconv','--binary','--no-renames',base,'--'];
63
+ const changed = [...new Set([
64
+ ...git(root, ['diff','--no-ext-diff','--no-textconv','--no-renames','--name-only','-z',base,'--']).split('\0'),
65
+ ...git(root, ['ls-files','--others','--exclude-standard','-z']).split('\0'),
66
+ ].filter(Boolean))].sort();
67
+ const selected = files ? [...files].sort() : changed;
68
+ const snapshot = selected.map(path => {
69
+ portableFile(path);
70
+ const entry = baseEntries.get(path);
71
+ if (entry && entry.type !== 'blob') throw new Error('review-scope: submodules require separate review; working-tree snapshot is unverified');
72
+ const before = entry ? {mode:entry.mode, oid:entry.oid, sha256:digest(git(root, ['cat-file','blob',entry.oid], null))} : null;
73
+ const after = workingFile(root, path);
74
+ if (!before && !after) throw new Error(`review-scope: missing or unsupported working-tree path ${path}`);
75
+ const change = !before ? 'added' : !after ? 'deleted' : before.mode === after.mode && before.sha256 === after.sha256 ? 'unchanged' : before.mode.slice(0,3) !== after.mode.slice(0,3) ? 'type-changed' : 'modified';
76
+ return {path, change, before, after};
77
+ });
78
+ const args = [...diffArgs, ...selected.map(path => `:(literal)${path}`)];
79
+ const scope = {
80
+ version:1, mode:'working-tree', review_mode:'working-tree',
81
+ outcome:snapshot.some(file => file.change !== 'unchanged') ? 'changes' : 'no-diff',
82
+ base_sha:base, observed_head:head, selection:files ? 'explicit-files' : 'all-changes', scope_paths:selected,
83
+ changed_paths:snapshot.filter(file => file.change !== 'unchanged').map(file => file.path),
84
+ excluded_paths:changed.filter(path => !selected.includes(path)),
85
+ working_snapshot:snapshot, index_sha256:digest(index), inventory_sha256:digest(inventory),
86
+ tracked_diff_sha256:digest(git(root,args,null)), tracked_diff_command:['git',...args],
87
+ };
88
+ return {...scope, input_sha256:digest(JSON.stringify(scope))};
89
+ };
90
+ const first = capture(), second = capture();
91
+ if (first.input_sha256 !== second.input_sha256) throw new Error('review-scope: working-tree changed while freezing inputs; stop writers and retry');
92
+ return first;
93
+ }
8
94
  function contained(root, path) {
9
95
  const rel = relative(root, path);
10
96
  if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
@@ -65,10 +151,11 @@ function commit(repo, ref, field) {
65
151
  export function resolveReviewScope(options) {
66
152
  const { repo, mode } = options;
67
153
  if (typeof repo !== 'string' || !repo || !modes.has(mode)) {
68
- throw new Error('review-scope: --repo and --mode baseline|range|commit|pr|source-files are required');
154
+ throw new Error('review-scope: --repo and --mode baseline|range|commit|pr|source-files|working-tree are required');
69
155
  }
70
156
  if (mode === 'source-files') return sourceFiles(options);
71
- if (options.files !== undefined) throw new Error('review-scope: --file requires source-files mode');
157
+ if (mode === 'working-tree') return workingTree(options);
158
+ if (options.files !== undefined) throw new Error('review-scope: --file requires source-files or working-tree mode');
72
159
  git(repo, ['rev-parse', '--git-dir']);
73
160
  let base, head;
74
161
  let selectedParent = null;
@@ -0,0 +1,74 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {execFileSync} from 'node:child_process';
4
+ import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,rmSync} from 'node:fs';
5
+ import {tmpdir} from 'node:os';
6
+ import {join} from 'node:path';
7
+ import {validateIndependentReview} from '../review-evidence.mjs';
8
+ import {appendReviewFixture} from './review-fixture.mjs';
9
+ function fixture(t) {
10
+ const repo=mkdtempSync(join(tmpdir(),'review-evidence-')),root=join(repo,'data/change');
11
+ t.after(()=>rmSync(repo,{recursive:true,force:true}));mkdirSync(root,{recursive:true});
12
+ const git=(...args)=>execFileSync('git',['-C',repo,...args],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
13
+ git('init','-b','main');git('config','user.name','Fixture');git('config','user.email','test@example.invalid');
14
+ writeFileSync(join(repo,'.gitignore'),'data/\n');writeFileSync(join(repo,'value.txt'),'before');git('add','.');git('-c','commit.gpgsign=false','commit','-m','base');
15
+ const base=git('rev-parse','HEAD');writeFileSync(join(repo,'value.txt'),'after');
16
+ const save=(options={})=>appendReviewFixture({repo,root,base,...options});
17
+ const validate=(options={})=>validateIndependentReview(root,'evidence/direct-spec.md',{repoRoot:repo,checkCurrent:true,expectedHead:git('rev-parse','HEAD'),...options});
18
+ return {repo,root,git,base,save,validate};
19
+ }
20
+ test('one actual independent reviewer may pass both axes in native or explicitly selected modes',t=>{
21
+ const f=fixture(t);
22
+ for(const mode of ['native','herdr','orca','cli']) {const r=f.save({mode});const before=readFileSync(r.path);assert.deepEqual(f.validate(),[]);assert.deepEqual(readFileSync(r.path),before);}
23
+ });
24
+ test('self review, unknown identity, mismatched original output and missing dispatch cannot certify completion',t=>{
25
+ const f=fixture(t),r=f.save();
26
+ for(const text of [
27
+ r.text.replace('Implementers: native:implementer','Implementers: native:reviewer'),
28
+ r.text.replaceAll('native:reviewer','unknown'),
29
+ r.text.replace('Result: verified','Result: pending'),
30
+ r.text.replace('Mode: native','Mode: herdr'),
31
+ r.text.replace('Axis: standards','Axis: specification'),
32
+ r.text.replace('| standards | native:reviewer | pass |','| standards | native:reviewer | skipped |'),
33
+ r.text.replace('Verification: evidence/direct-spec.md#review-receipt','Verification: evidence/direct-spec.md#review-input'),
34
+ r.text.replace('## Independent Review','## Self Review'),
35
+ ]) {writeFileSync(r.path,text);assert.ok(f.validate().length,text);}
36
+ writeFileSync(r.path,r.text);assert.ok(f.validate({requiredImplementers:['native:merge-fixer']}).length);
37
+ });
38
+ test('changed code, sources and final HEAD invalidate review, unrelated reports do not',t=>{
39
+ const f=fixture(t),r=f.save();assert.deepEqual(f.validate(),[]);
40
+ writeFileSync(join(f.root,'another-report.md'),'New report outside code scope');assert.deepEqual(f.validate(),[]);
41
+ writeFileSync(join(f.repo,'value.txt'),'new fix');assert.match(f.validate().join('\n'),/drift/);
42
+ f.save();assert.deepEqual(f.validate(),[]);
43
+ writeFileSync(join(f.root,'evidence/fixture-standards.md'),'different standards');assert.match(f.validate().join('\n'),/source drift/);
44
+ f.save();f.git('add','value.txt');f.git('-c','commit.gpgsign=false','commit','-m','new checkpoint');assert.match(f.validate().join('\n'),/checkpoint/);
45
+ f.save();assert.deepEqual(f.validate(),[]);
46
+ });
47
+ test('valid old record is readable without reusing it for changed current inputs',t=>{
48
+ const f=fixture(t);f.save();writeFileSync(join(f.repo,'value.txt'),'later work');
49
+ assert.deepEqual(f.validate({checkCurrent:false}),[]);assert.ok(f.validate().length);
50
+ });
51
+ test('unsafe evidence paths, missing specification and empty change cannot fabricate approval',t=>{
52
+ const f=fixture(t),r=f.save();
53
+ writeFileSync(r.path,r.text.replace('Input: evidence/direct-spec.md#review-input','Input: ../outside'));
54
+ assert.ok(f.validate().length);
55
+ writeFileSync(r.path,r.text.replace('"kind": "specification"','"kind": "instructions"'));
56
+ assert.match(f.validate().join('\n'),/required review sources/);
57
+ writeFileSync(join(f.repo,'value.txt'),'before');f.save();assert.match(f.validate().join('\n'),/no-diff/);
58
+ });
59
+ test('owning results must match original axes and committed delivery rejects later dirty code',t=>{
60
+ const f=fixture(t);f.git('add','value.txt');f.git('-c','commit.gpgsign=false','commit','-m','reviewed implementation');
61
+ const head=f.git('rev-parse','HEAD'),r=f.save({head});
62
+ assert.deepEqual(f.validate({requireDelivery:true}),[]);
63
+ assert.match(f.validate({expectedResults:{standards:'request-changes',specification:'pass'}}).join('\n'),/differs from the owning report/);
64
+ writeFileSync(join(f.repo,'value.txt'),'unreviewed repair');
65
+ assert.deepEqual(f.validate(),[]); // C may inspect the immutable commit itself.
66
+ assert.match(f.validate({requireDelivery:true}).join('\n'),/unreviewed working-tree delivery/);
67
+ writeFileSync(join(f.repo,'value.txt'),'after');writeFileSync(join(f.repo,'new-feature.txt'),'unreviewed addition');
68
+ assert.match(f.validate({requireDelivery:true}).join('\n'),/new-feature/);
69
+ rmSync(join(f.repo,'new-feature.txt'));writeFileSync(join(f.root,'README.md'),'Outcome: completed\n');
70
+ assert.deepEqual(f.validate({requireDelivery:true}),[]);
71
+ const failed=r.text.replace('Axis: standards\nResult: pass','Axis: standards\nResult: request-changes').replace('| standards | native:reviewer | pass |','| standards | native:reviewer | request-changes |');
72
+ writeFileSync(r.path,failed);
73
+ assert.match(f.validate({requirePassed:false,expectedResults:{standards:'pass',specification:'pass'}}).join('\n'),/differs from the owning report/);
74
+ });
@@ -0,0 +1,31 @@
1
+ /** Synthetic tool receipts for disposable tests only; never runtime evidence. */
2
+ import {createHash} from 'node:crypto';
3
+ import {mkdirSync,readFileSync,writeFileSync,existsSync} from 'node:fs';
4
+ import {dirname,join,relative} from 'node:path';
5
+ import {resolveReviewScope} from '../review-scope.mjs';
6
+ import {reviewInputDigest} from '../review-evidence.mjs';
7
+ import {readArtifactSectionSync} from '../../../../../lib/speculo-runtime.mjs';
8
+ const hash=text=>createHash('sha256').update(text).digest('hex');
9
+ export function appendReviewFixture({repo,root,artifact='evidence/direct-spec.md',base,head=null,files=['value.txt'],mode='native',reviewer='native:reviewer',implementers=['native:implementer'],spec=null,sourceLocators=null}) {
10
+ const path=join(root,artifact);mkdirSync(dirname(path),{recursive:true});
11
+ let prior=existsSync(path)?readFileSync(path,'utf8'):'';
12
+ prior=prior.split('\n<!-- synthetic-independent-review -->')[0];
13
+ writeFileSync(path,prior);
14
+ let sources;
15
+ if(sourceLocators) sources=sourceLocators.map(({kind,locator})=>{
16
+ const [file,anchor]=locator.split('#');
17
+ return {kind,locator,sha256:hash(anchor?readArtifactSectionSync(join(repo,file),anchor).text:readFileSync(join(repo,file),'utf8'))};
18
+ });
19
+ else {
20
+ const standards=join(root,'evidence/fixture-standards.md');mkdirSync(dirname(standards),{recursive:true});writeFileSync(standards,'# Fixture standards\nPreserve existing behavior and test changes.\n');
21
+ if(!spec) {spec=join(root,'evidence/fixture-spec.md');writeFileSync(spec,'# Fixture Spec\nAC-001: deliver the accepted behavior.\n');}
22
+ sources=[['standards',standards],['specification',spec]].map(([kind,file])=>({kind,locator:relative(repo,file).split('\\').join('/'),sha256:hash(readFileSync(file,'utf8'))}));
23
+ }
24
+ const scope=resolveReviewScope(head?{repo,mode:'range',base,head}:{repo,mode:'working-tree',base,files});
25
+ const input={scope,sources,exclusions:'Fixture-only records and unrelated inputs are outside the implementation scope.'};
26
+ const digest=reviewInputDigest(input);
27
+ const report=axis=>`<a id="${axis}-output"></a>\n## Original ${axis} output\nReviewer: ${reviewer}\nAxis: ${axis}\nResult: pass\nInput-SHA256: ${digest}\nObserved: synthetic fixture reviewer inspected the fixed inputs; no blocking findings.\n`;
28
+ const text=`\n<!-- synthetic-independent-review -->\n<a id="review-input"></a>\n## Review Input\n\n\x60\x60\x60json\n${JSON.stringify(input,null,2)}\n\x60\x60\x60\n\n${report('standards')}\n${report('specification')}\n<a id="review-receipt"></a>\n## Original dispatch and verification\nVerified-By: native:lead\nMode: ${mode}\nReviewers: ${reviewer}\nInput-SHA256: ${digest}\nResult: verified\nObserved: synthetic fixture dispatch returned the separate reviewer handle and both original outputs.\n\n## Independent Review\nInput: ${artifact}#review-input\nImplementers: ${implementers.join(', ')}\nMode: ${mode}\nVerified-By: native:lead\nVerification: ${artifact}#review-receipt\n\n| Axis | Reviewer | Result | Report |\n|---|---|---|---|\n| standards | ${reviewer} | pass | ${artifact}#standards-output |\n| specification | ${reviewer} | pass | ${artifact}#specification-output |\n`;
29
+ writeFileSync(path,prior+text);
30
+ return {input,digest,path,text:prior+text};
31
+ }
@@ -0,0 +1,60 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {execFileSync} from 'node:child_process';
4
+ import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,rmSync,renameSync,chmodSync,symlinkSync} from 'node:fs';
5
+ import {tmpdir} from 'node:os';
6
+ import {join} from 'node:path';
7
+ import {resolveReviewScope} from '../review-scope.mjs';
8
+
9
+ function fixture(t) {
10
+ const root=mkdtempSync(join(tmpdir(),'review-working-'));
11
+ t.after(()=>rmSync(root,{recursive:true,force:true}));
12
+ const git=(...args)=>execFileSync('git',['-C',root,...args],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
13
+ git('init','-b','main');git('config','user.name','Fixture');git('config','user.email','test@example.invalid');
14
+ git('config','core.autocrlf','false');
15
+ for(const path of ['one.txt','removed.txt','renamed.txt']) writeFileSync(join(root,path),'original\n');
16
+ writeFileSync(join(root,'.gitignore'),'ignored/\n');git('add','.');git('-c','commit.gpgsign=false','commit','-m','base');
17
+ const base=git('rev-parse','HEAD');
18
+ return {root,git,base,scope:(extra={})=>resolveReviewScope({repo:root,mode:'working-tree',base,...extra})};
19
+ }
20
+ test('working-tree freezes staged, unstaged, added, deleted, renamed and mode changes without writes',t=>{
21
+ const f=fixture(t);
22
+ writeFileSync(join(f.root,'one.txt'),'staged\n');f.git('add','one.txt');writeFileSync(join(f.root,'one.txt'),'final\n');
23
+ rmSync(join(f.root,'removed.txt'));renameSync(join(f.root,'renamed.txt'),join(f.root,'new-name.txt'));
24
+ writeFileSync(join(f.root,'new.bin'),Buffer.from([0,255,7]));chmodSync(join(f.root,'one.txt'),0o755);
25
+ mkdirSync(join(f.root,'ignored'));writeFileSync(join(f.root,'ignored/not-selected'),'ignored');
26
+ const before=readFileSync(join(f.root,'.git/index')), status=f.git('status','--porcelain');
27
+ const scope=f.scope();
28
+ assert.equal(scope.outcome,'changes');assert.equal(scope.observed_head,f.base);assert.equal(scope.head_sha,undefined);
29
+ assert.deepEqual(scope.changed_paths,['new-name.txt','new.bin','one.txt','removed.txt','renamed.txt']);
30
+ assert.equal(scope.working_snapshot.find(x=>x.path==='one.txt').after.mode,process.platform === 'win32' ? '100644' : '100755');
31
+ assert.equal(scope.working_snapshot.find(x=>x.path==='removed.txt').after,null);
32
+ assert.equal(scope.working_snapshot.find(x=>x.path==='new.bin').after.bytes,3);
33
+ assert.deepEqual(f.scope(),scope);assert.equal(f.git('status','--porcelain'),status);assert.deepEqual(readFileSync(join(f.root,'.git/index')),before);
34
+ writeFileSync(join(f.root,'new.bin'),Buffer.from([0,255,8]));assert.notEqual(f.scope().input_sha256,scope.input_sha256);
35
+ });
36
+ test('explicit scopes are literal, expose exclusions and retain deleted files',t=>{
37
+ const f=fixture(t), path='literal [abc] $(touch unsafe).txt';
38
+ writeFileSync(join(f.root,path),'literal');rmSync(join(f.root,'removed.txt'));writeFileSync(join(f.root,'one.txt'),'changed');
39
+ const scope=f.scope({files:[path,'removed.txt']});
40
+ assert.deepEqual(scope.changed_paths,[path,'removed.txt']);assert.deepEqual(scope.excluded_paths,['one.txt']);
41
+ assert.equal(scope.selection,'explicit-files');
42
+ for(const files of [[],['../outside'],['.git/config'],['one.txt','one.txt'],['missing']]) assert.throws(()=>f.scope({files}));
43
+ for(const extra of [{head:'HEAD'},{commit:'HEAD'},{parent:1}]) assert.throws(()=>f.scope(extra));
44
+ assert.throws(()=>f.scope({base:'missing'}));
45
+ });
46
+ test('links are read as links and directory links never disclose outside bytes',t=>{
47
+ const f=fixture(t), outside=mkdtempSync(join(tmpdir(),'review-outside-'));
48
+ t.after(()=>rmSync(outside,{recursive:true,force:true}));writeFileSync(join(outside,'secret'),'sensitive');
49
+ symlinkSync(join(outside,'secret'),join(f.root,'link'));
50
+ const scope=f.scope({files:['link']});assert.equal(scope.working_snapshot[0].after.kind,'symlink');
51
+ symlinkSync(outside,join(f.root,'directory'),process.platform === 'win32' ? 'junction' : 'dir');
52
+ assert.throws(()=>f.scope({files:['directory/secret']}),/symlink directory/);
53
+ });
54
+ test('unmerged index and borrowed parent repository are rejected',t=>{
55
+ const f=fixture(t);f.git('checkout','-b','other');writeFileSync(join(f.root,'one.txt'),'other\n');f.git('commit','-am','other');
56
+ f.git('checkout','main');writeFileSync(join(f.root,'one.txt'),'main\n');f.git('commit','-am','main');
57
+ assert.throws(()=>f.git('merge','other'));assert.throws(()=>f.scope(),/unresolved index/);
58
+ f.git('merge','--abort');mkdirSync(join(f.root,'nested'));
59
+ assert.throws(()=>resolveReviewScope({repo:join(f.root,'nested'),mode:'working-tree',base:f.base}),/actual project Git root/);
60
+ });
@@ -31,6 +31,7 @@ import {
31
31
  import { validateChangeClosure, readChangeOwner } from "./change-artifacts.mjs";
32
32
  import { validateRecord, validateRecords } from "./delivery-artifacts.mjs";
33
33
  import { reviewScopeCLI } from "./review-scope.mjs";
34
+ import { hasIndependentReview, validateIndependentReview } from "./review-evidence.mjs";
34
35
  import { ENGINEERING_KINDS, validateEngineeringArtifacts, validateEngineeringPackage } from "./engineering-evidence.mjs";
35
36
 
36
37
  const DOMAIN_SCHEMA_VERSION = 3;
@@ -202,15 +203,6 @@ const REQUIRED_TICKET_KEYS = new Set([
202
203
  "shared_paths",
203
204
  "shared_path_owners",
204
205
  ]);
205
- const REQUIRED_READY_TICKET_SECTIONS = new Set([
206
- "## 1. 战略与来源",
207
- "## 2. 决策状态",
208
- "## 3. 范围边界",
209
- "## 4. 要构建什么",
210
- "## 7. 路径访问契约",
211
- "## 8. 验证矩阵",
212
- "## 10. 验收标准",
213
- ]);
214
206
  const REQUIRED_GOAL_PLAN_SECTIONS = new Set([
215
207
  "## 1. Outcome and Authority",
216
208
  "## 2. Execution Graph",
@@ -334,6 +326,20 @@ function sectionBody(body, heading) {
334
326
  return (next ? tail.slice(0, next.index) : tail).trim();
335
327
  }
336
328
 
329
+ // Accept the published long templates and the concise headings without making
330
+ // numbering, repeated instructions or a fixed count of implementation steps a gate.
331
+ function semanticSection(body, names) {
332
+ const lines = runtime.markdownBodyLines(body);
333
+ const matches = lines.filter(line => {
334
+ const match = /^(#{2,3})\s+(?:\d+\.\s*)?(.+)$/.exec(line.text);
335
+ return match && names.includes(match[2]);
336
+ });
337
+ if (matches.length !== 1) return "";
338
+ const start = matches[0], level = /^#+/.exec(start.text)[0].length;
339
+ const end = lines.find(line => line.line > start.line && /^#{1,3}\s/.test(line.text) && /^#+/.exec(line.text)[0].length <= level);
340
+ return body.split(/\r?\n/).slice(start.line, end ? end.line-1 : undefined).join("\n").trim();
341
+ }
342
+
337
343
  function stripPathTag(value) {
338
344
  const match = /^<Path>([^<]+)<\/Path>$/.exec(String(value).trim());
339
345
  return match ? match[1].trim() : null;
@@ -1460,12 +1466,13 @@ function validateDiagnosis(path, expectedChange, errors) {
1460
1466
  return { path, meta, body };
1461
1467
  }
1462
1468
 
1463
- function validateReviews(change, required, errors) {
1469
+ function validateReviews(change, required, errors, repoRoot = null) {
1464
1470
  const root = join(change, "reviews");
1465
1471
  const paths = isDirectory(root)
1466
1472
  ? readdirSync(root).filter((name) => /^CR-\d{3,}\.md$/.test(name)).sort().map((name) => join(root, name))
1467
1473
  : [];
1468
1474
  if (required && !paths.length) errors.push("review stage requires a CR-###.md report");
1475
+ const latest = [...paths].sort((a,b) => Number(basename(a).match(/\d+/)[0])-Number(basename(b).match(/\d+/)[0])).at(-1);
1469
1476
  for (const path of paths) {
1470
1477
  const { meta, body } = parseFrontmatter(path);
1471
1478
  if (meta.schema_version !== 1 || meta.artifact !== "code-review") {
@@ -1483,6 +1490,12 @@ function validateReviews(change, required, errors) {
1483
1490
  for (const heading of ["## Fixed Input", "## 标准", "## 规范", "## Summary"]) {
1484
1491
  if (!body.includes(heading)) errors.push(`${basename(path)}: missing '${heading}'`);
1485
1492
  }
1493
+ // Historical CRs remain readable. Only the current review or an explicitly
1494
+ // supplied new record is checked against the independent execution contract.
1495
+ if (required && path === latest || hasIndependentReview(path)) {
1496
+ errors.push(...validateIndependentReview(change, toPosix(relative(change,path)), {repoRoot,requirePassed:meta.status === 'approved',expectedResults:{standards:meta.standards_result,specification:meta.specification_result},expectedHead:meta.head,checkCurrent:required && path === latest}));
1497
+ if (meta.status === 'approved' && (meta.standards_result !== 'pass' || meta.specification_result !== 'pass')) errors.push(`${basename(path)}: approved requires both review axes to pass`);
1498
+ }
1486
1499
  }
1487
1500
  return paths;
1488
1501
  }
@@ -1692,18 +1705,12 @@ function validateSpec(path, errors, warnings) {
1692
1705
  const sources = requireList(meta, "sources", label, errors);
1693
1706
  if (!sources.length) errors.push(`${label}: sources must contain at least one source`);
1694
1707
  if (meta.ready_for_tickets === true) {
1695
- const unresolved = sectionBody(body, "### 未决问题");
1708
+ const unresolved = semanticSection(body, ["未决问题"]);
1696
1709
  if (unresolved && !/^无[。.]?$/.test(unresolved.trim())) {
1697
1710
  errors.push(`${label}: ready Spec has unresolved high-impact questions`);
1698
1711
  }
1699
- for (const heading of [
1700
- "## 1. 问题与目标",
1701
- "## 2. 解决方案与外部行为",
1702
- "## 4. 验收合同",
1703
- "## 5. 范围",
1704
- "## 9. 验证策略",
1705
- ]) {
1706
- if (!body.includes(heading)) errors.push(`${label}: ready Spec missing '${heading}'`);
1712
+ for (const heading of ["问题与目标", "解决方案与外部行为", "验收合同", "范围", "验证策略"]) {
1713
+ if (!semanticSection(body,[heading])) errors.push(`${label}: ready Spec missing '${heading}' content`);
1707
1714
  }
1708
1715
  }
1709
1716
  return { path, meta, body };
@@ -2181,32 +2188,22 @@ function validateTicket(path, errors) {
2181
2188
  if (!new Set(["ready", "in_progress", "review", "done"]).has(meta.status)) {
2182
2189
  errors.push(`${basename(path)}: ready=true conflicts with status=${meta.status}`);
2183
2190
  }
2184
- for (const heading of REQUIRED_READY_TICKET_SECTIONS) {
2185
- if (!body.includes(heading)) errors.push(`${basename(path)}: Ready Ticket missing '${heading}'`);
2191
+ for (const names of [["目标与来源","战略与来源"],["行为与范围","要构建什么"],["验收标准"],["验证方法","验证矩阵"]]) {
2192
+ if (!semanticSection(body,names)) errors.push(`${basename(path)}: Ready Ticket missing '${names[0]}' content`);
2186
2193
  }
2187
- const unresolved = sectionBody(body, "### 未决问题");
2194
+ const unresolved = semanticSection(body, ["未决问题"]);
2188
2195
  if (unresolved && !/^无[。.]?$/.test(unresolved.trim())) {
2189
2196
  errors.push(`${basename(path)}: Ready Ticket has unresolved questions`);
2190
2197
  }
2191
2198
  if (!lists.writable_paths.length) errors.push(`${basename(path)}: Ready implementation Ticket requires writable_paths`);
2192
- if (new Set(["standard", "deep"]).has(meta.planning_depth)) {
2193
- for (const heading of ["## 5. 实现契约", "## 6. 执行路线"]) {
2194
- if (!body.includes(heading)) {
2195
- errors.push(`${basename(path)}: ${meta.planning_depth} Ticket missing '${heading}'`);
2196
- }
2197
- }
2198
- }
2199
- if (meta.planning_depth === "deep" && !body.includes("## 9. 发布、迁移与恢复")) {
2200
- errors.push(`${basename(path)}: Deep Ticket missing release/migration/recovery`);
2201
- }
2202
- const verification = sectionBody(body, "## 8. 验证矩阵");
2199
+ const verification = semanticSection(body, ["验证方法","验证矩阵"]);
2203
2200
  if (!verification || !verification.includes("|")) {
2204
2201
  errors.push(`${basename(path)}: Ready Ticket has no usable verification matrix`);
2205
2202
  }
2206
2203
  if (!verification.includes("E2E disposition") || !/(current-workspace|source-worktree|parent-candidate|direct-parent)/.test(verification)) {
2207
2204
  errors.push(`${basename(path)}: Ready Ticket must define E2E disposition and an explicit execution environment`);
2208
2205
  }
2209
- const acceptance = sectionBody(body, "## 10. 验收标准").toLowerCase();
2206
+ const acceptance = semanticSection(body, ["验收标准"]).toLowerCase();
2210
2207
  if (!acceptance.includes("- [ ]") && !acceptance.includes("- [x]")) {
2211
2208
  errors.push(`${basename(path)}: Ready Ticket has no acceptance checklist`);
2212
2209
  }
@@ -3099,7 +3096,7 @@ function validateChange(change, stage = null, repoRoot = null, engineeringKind =
3099
3096
  if(archive?.archive_state === 'moved') warnings.push('archive moved and checked, but is not sealed; finish the original A8 receipt');
3100
3097
  return { errors, warnings, ...(archive ? {archive} : {}) };
3101
3098
  }
3102
- if (isParentImplementation) validateParentImplementation(change, changeStatus, stage, errors, warnings, repoRoot, options);
3099
+ const parentImplementation = isParentImplementation ? validateParentImplementation(change, changeStatus, stage, errors, warnings, repoRoot, options) : null;
3103
3100
  errors.push(...validateInitiative(change));
3104
3101
  if (isFile(join(change, "source-issue.md"))) {
3105
3102
  errors.push("obsolete source-issue.md is forbidden; use source.md without compatibility fallback");
@@ -3127,7 +3124,7 @@ function validateChange(change, stage = null, repoRoot = null, engineeringKind =
3127
3124
  if (isFile(diagnosisPath) || stage === "diagnosis") {
3128
3125
  validateDiagnosis(diagnosisPath, basename(change), errors);
3129
3126
  }
3130
- validateReviews(change, stage === "review", errors);
3127
+ validateReviews(change, stage === "review", errors, workspace.context.projectRoot);
3131
3128
  validatePrototypes(change, stage === "prototype", errors, prototypeId, new Set(["spec", "tickets", "goal-plan", "implement", "review", "complete"]).has(stage), workspace.context);
3132
3129
  if (new Set(["tickets", "goal-plan", "implement", "review", "complete"]).has(stage)) runDesignCheck("--handoff", change, errors, workspace.context);
3133
3130
  validateChangeLearning(change, stage === "learn", errors);
@@ -3266,6 +3263,43 @@ function validateChange(change, stage = null, repoRoot = null, engineeringKind =
3266
3263
 
3267
3264
  if (stage === "complete" && spec && !ticketMode && !isParentImplementation && !retroOnly) errors.push(...validateAcceptanceEvidence(change,"evidence/direct-spec.md",[...new Set(spec.body.match(/\bAC-\d+\b/g) ?? [])]));
3268
3265
 
3266
+ // New completion/archival consumes actual review evidence. Historical records
3267
+ // remain readable outside these gates; archived validation returned earlier.
3268
+ let nonImplementation = false;
3269
+ if (stage === 'complete' && !spec && !ticketMode && !simpleGoal && !goalPlan && !isParentImplementation && !changeStatus?.worktrees?.length && !isDirectory(join(change,'evidence'))) {
3270
+ try {
3271
+ const owner = readChangeOwner(workspace.context,basename(change));
3272
+ if (owner.work === 'specdev/learn' && isFile(join(change,'learning','index.md'))) {
3273
+ validateChangeLearning(change,true,errors);
3274
+ nonImplementation = true;
3275
+ } else if (owner.work === 'specdev/triage' && source && triage?.meta.ready_for_implementation === false) nonImplementation = true;
3276
+ } catch(error) { errors.push(error.message); }
3277
+ }
3278
+ if (stage === 'complete' && changeStatus?.location.kind === 'active' && changeStatus?.outcome !== 'cancelled' && !retroOnly && !nonImplementation) {
3279
+ const units = [...tickets.values()].filter(ticket => ticket.meta.status !== 'cancelled');
3280
+ const finalArtifact = isParentImplementation ? 'evidence/implementation-orchestration.md'
3281
+ : units.length > 1 ? 'evidence/final-review.md'
3282
+ : units.length === 1 ? `evidence/${units[0].meta.id}.md`
3283
+ : lightweight ? 'README.md' : 'evidence/direct-spec.md';
3284
+ try {
3285
+ const head = execFileSync('git',['-C',workspace.context.projectRoot,'rev-parse','--verify','HEAD^{commit}'],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
3286
+ const executions = [...(changeStatus.worktrees ?? []), ...[...(parentImplementation?.memberStatuses?.values() ?? [])].flatMap(status=>status.worktrees ?? [])];
3287
+ const owners = executions.flatMap(record => [record.implementation_owner, ...(record.integration?.conflict_paths?.length ? [record.integration_owner] : [])]).filter(value => typeof value === 'string' && /^[a-z][a-z0-9-]*:\S+$/i.test(value));
3288
+ const bases = [...new Set(executions.map(record=>record.base_sha).filter(value=>/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(value ?? '')))];
3289
+ const expectedBase = bases.length > 1 ? execFileSync('git',['-C',workspace.context.projectRoot,'merge-base','--octopus',...bases],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim() : bases[0] ?? null;
3290
+ const sourceLocator = path => {
3291
+ const snapshot = workspace.context.readOnlySnapshot;
3292
+ const original = snapshot ? join(snapshot.sourceContext.specdevRoot,relative(workspace.context.specdevRoot,path)) : path;
3293
+ return toPosix(relative(workspace.context.projectRoot,original));
3294
+ };
3295
+ const specLocator = sourceLocator(spec ? spec.path : join(change,'README.md'));
3296
+ const requiredSpecifications = isParentImplementation
3297
+ ? [sourceLocator(join(change,'implementation-plan.md')),sourceLocator(join(change,'implementation-map.md')),...new Set([...(parentImplementation?.ticketByTask?.values() ?? [])].map(unit=>sourceLocator(join(unit.memberRoot,'spec.md'))))]
3298
+ : spec ? [specLocator] : [`${specLocator}#scope`,`${specLocator}#acceptance`];
3299
+ errors.push(...validateIndependentReview(change,finalArtifact,{repoRoot:workspace.context.projectRoot,expectedHead:head,expectedBase,requiredImplementers:owners,requiredSpecifications,checkCurrent:true,requireDelivery:true}));
3300
+ } catch(error) { errors.push(`independent-review: final delivery checkpoint unavailable: ${error.message}`); }
3301
+ }
3302
+
3269
3303
  if (spec && ticketMode) {
3270
3304
  const declaredContracts = new Set(spec.body.match(/\bAC-\d+\b/g) ?? []);
3271
3305
  const coveredContracts = new Set(
@@ -3351,6 +3385,14 @@ function validateChange(change, stage = null, repoRoot = null, engineeringKind =
3351
3385
  continue;
3352
3386
  }
3353
3387
  const text = readText(evidence);
3388
+ if (stage === 'complete' && changeStatus?.location.kind === 'active' || hasIndependentReview(evidence)) {
3389
+ const execution = changeStatus?.worktrees?.find(entry => entry.ticket_id === ticketId);
3390
+ errors.push(...validateIndependentReview(change, `evidence/${ticketId}.md`, {
3391
+ repoRoot:workspace.context.projectRoot,
3392
+ expectedHead:execution?.integration?.result_sha ?? execution?.source_checkpoint ?? null,
3393
+ checkCurrent:false,
3394
+ }));
3395
+ }
3354
3396
  const currentWorkspace = changeStatus?.worktrees?.find(entry => entry.ticket_id === ticketId)?.workspace_ref === "current";
3355
3397
  const requiredHeadings = [
3356
3398
  "## 2. Lead Dispatch And Candidate Return",
@@ -3471,7 +3513,7 @@ function printResults(errors, warnings) {
3471
3513
  }
3472
3514
 
3473
3515
  function usage() {
3474
- console.error("Usage: node validate-specdev.mjs [--stage <stage>] [--sealed (archived only)] [--prototype-id UI-NNN|LOGIC-NNN] [--repo <project-root>] [--engineering <performance|documentation|incident>] <change-directory> | --review-scope --repo <project-root> --mode <baseline|range|commit|pr|source-files> [comparison options or repeated --file <relative-path>] | --self-check | --capture <capture.md> | --triage-run <TRI-NNN.md>");
3516
+ console.error("Usage: node validate-specdev.mjs [--stage <stage>] [--sealed (archived only)] [--prototype-id UI-NNN|LOGIC-NNN] [--repo <project-root>] [--engineering <performance|documentation|incident>] <change-directory> | --review-scope --repo <project-root> --mode <baseline|range|commit|pr|source-files|working-tree> [comparison options or repeated --file <relative-path>] | --self-check | --capture <capture.md> | --triage-run <TRI-NNN.md>");
3475
3517
  return 2;
3476
3518
  }
3477
3519