@namewta/speculo 2.0.1 → 2.0.3

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 (95) hide show
  1. package/README.md +42 -29
  2. package/dist/src/agent-skills.d.ts +43 -17
  3. package/dist/src/agent-skills.js +95 -28
  4. package/dist/src/agent-skills.js.map +1 -1
  5. package/dist/src/cli.js +43 -27
  6. package/dist/src/cli.js.map +1 -1
  7. package/dist/src/index.d.ts +20 -6
  8. package/dist/src/index.js +36 -11
  9. package/dist/src/index.js.map +1 -1
  10. package/dist/src/install-ui.d.ts +61 -0
  11. package/dist/src/install-ui.js +268 -0
  12. package/dist/src/install-ui.js.map +1 -0
  13. package/dist/src/interaction.d.ts +5 -0
  14. package/dist/src/interaction.js +10 -0
  15. package/dist/src/interaction.js.map +1 -0
  16. package/dist/src/transaction.d.ts +2 -0
  17. package/dist/src/transaction.js +16 -0
  18. package/dist/src/transaction.js.map +1 -1
  19. package/dist/src/workflows.js +2 -2
  20. package/dist/src/workflows.js.map +1 -1
  21. package/package.json +2 -2
  22. package/template/canonical/canonical-specdev-goal-plan.md +1747 -662
  23. package/template/canonical/canonical-specdev-grill-with-docs.md +1065 -233
  24. package/template/canonical/canonical-specdev-spec.md +1066 -278
  25. package/template/canonical/canonical-specdev-tickets.md +926 -184
  26. package/template/canonical/canonical-specdev-wayfinder.md +1060 -222
  27. package/template/commands/handoff/COMMAND.md +10 -0
  28. package/template/references/coordination/coordination-contract.md +12 -10
  29. package/template/references/coordination/modes/cli.md +6 -6
  30. package/template/references/coordination/modes/herdr.md +18 -3
  31. package/template/references/coordination/modes/orca.md +32 -9
  32. package/template/references/coordination/result-and-recovery.md +7 -4
  33. package/template/references/coordination/session-files.md +93 -0
  34. package/template/references/coordination/task-brief.md +10 -8
  35. package/template/workflows/specdev/SKILL.md +2 -2
  36. package/template/workflows/specdev/common/rules/adr-format.md +5 -11
  37. package/template/workflows/specdev/common/rules/artifact-contract.md +2 -2
  38. package/template/workflows/specdev/common/rules/change-completion.md +7 -2
  39. package/template/workflows/specdev/common/rules/domain-runtime.md +2 -2
  40. package/template/workflows/specdev/common/rules/evidence-and-verification.md +2 -2
  41. package/template/workflows/specdev/common/rules/execution-policy.md +28 -0
  42. package/template/workflows/specdev/common/rules/operating-governance.md +2 -2
  43. package/template/workflows/specdev/common/rules/path-ownership.md +3 -3
  44. package/template/workflows/specdev/common/rules/path-reference-contract.md +2 -0
  45. package/template/workflows/specdev/common/rules/planning-principles.md +1 -1
  46. package/template/workflows/specdev/common/rules/readiness-and-depth.md +5 -5
  47. package/template/workflows/specdev/common/rules/workflow-routing.md +3 -3
  48. package/template/workflows/specdev/common/rules/workflow-state-and-lifecycle.md +2 -0
  49. package/template/workflows/specdev/common/skills/code-review/SKILL.md +12 -7
  50. package/template/workflows/specdev/common/skills/code-review/references/result-reuse.md +3 -3
  51. package/template/workflows/specdev/common/skills/code-review/references/review-evidence.md +44 -0
  52. package/template/workflows/specdev/common/skills/code-review/references/reviewer-contracts.md +17 -5
  53. package/template/workflows/specdev/common/skills/code-review/references/working-tree-review.md +15 -0
  54. package/template/workflows/specdev/common/skills/dev-worktree/SKILL.md +11 -11
  55. package/template/workflows/specdev/common/skills/dev-worktree/references/create.md +20 -12
  56. package/template/workflows/specdev/common/skills/dev-worktree/references/finalize.md +14 -12
  57. package/template/workflows/specdev/common/tools/README.md +9 -1
  58. package/template/workflows/specdev/common/tools/change-artifacts.mjs +8 -11
  59. package/template/workflows/specdev/common/tools/plan-contract.mjs +13 -6
  60. package/template/workflows/specdev/common/tools/review-evidence.mjs +117 -0
  61. package/template/workflows/specdev/common/tools/review-scope.mjs +90 -5
  62. package/template/workflows/specdev/common/tools/tests/engineering.test.mjs +2 -2
  63. package/template/workflows/specdev/common/tools/tests/review-evidence.test.mjs +74 -0
  64. package/template/workflows/specdev/common/tools/tests/review-fixture.mjs +31 -0
  65. package/template/workflows/specdev/common/tools/tests/review-working-tree.test.mjs +59 -0
  66. package/template/workflows/specdev/common/tools/validate-specdev.mjs +210 -114
  67. package/template/workflows/specdev/manifest.json +4 -6
  68. package/template/workflows/specdev/specdev-c-code-review/SKILL.md +6 -6
  69. package/template/workflows/specdev/specdev-c-code-review/assets/code-review-template.md +5 -1
  70. package/template/workflows/specdev/specdev-c-code-review/references/source-files-review.md +2 -2
  71. package/template/workflows/specdev/specdev-i-implement/SKILL.md +6 -6
  72. package/template/workflows/specdev/specdev-i-implement/assets/evidence-template.md +16 -10
  73. package/template/workflows/specdev/specdev-i-implement/references/execution-preflight.md +8 -4
  74. package/template/workflows/specdev/specdev-i-implement/references/implementation-procedure.md +22 -18
  75. package/template/workflows/specdev/specdev-i-init-setup/SKILL.md +1 -1
  76. package/template/workflows/specdev/specdev-l-learn/SKILL.md +35 -0
  77. package/template/workflows/specdev/specdev-l-learn/references/learning-change.md +23 -0
  78. package/template/workflows/specdev/specdev-l-learn/references/lesson-format.md +61 -0
  79. package/template/workflows/specdev/specdev-p-goal-plan/SKILL.md +3 -3
  80. package/template/workflows/specdev/specdev-p-goal-plan/assets/goal-plan-template.md +4 -0
  81. package/template/workflows/specdev/specdev-p-goal-plan/assets/lightweight-goal-plan-template.md +2 -0
  82. package/template/workflows/specdev/specdev-p-goal-plan/references/completion-control.md +3 -0
  83. package/template/workflows/specdev/specdev-p-goal-plan/references/orchestration-protocol.md +1 -1
  84. package/template/workflows/specdev/specdev-p-goal-plan/references/planning-modes.md +2 -2
  85. package/template/workflows/specdev/specdev-p-goal-plan/references/single-change-plan.md +2 -2
  86. package/template/workflows/specdev/specdev-r-retro/SKILL.md +1 -1
  87. package/template/workflows/specdev/specdev-s-spec/SKILL.md +3 -3
  88. package/template/workflows/specdev/specdev-s-spec/assets/spec-template.md +15 -65
  89. package/template/workflows/specdev/specdev-t-tickets/SKILL.md +1 -1
  90. package/template/workflows/specdev/specdev-t-tickets/assets/ticket-template.md +19 -103
  91. package/template/workflows/specdev/specdev-t-tickets/assets/tickets-map-template.md +3 -1
  92. package/template/workflows/specdev/specdev-t-tickets/references/planning-procedure.md +5 -19
  93. package/template/workflows/specdev/specdev-t-tickets/references/ticket-readiness.md +5 -5
  94. package/template/workflows/specdev/specdev-l-learn-change/SKILL.md +0 -33
  95. package/template/workflows/specdev/specdev-l-learn-change/references/lesson-format.md +0 -51
@@ -123,9 +123,8 @@ export function validateTicketPlan(artifact, { repoRoot = null, requirePlan = fa
123
123
  if (meta.plan_contract_version !== 1) errors.push('unsupported plan_contract_version');
124
124
  errors.push(...validateBindings(meta, repoRoot));
125
125
  if (!strings(meta.resource_claims) || new Set(meta.resource_claims).size !== meta.resource_claims.length) errors.push('resource_claims must be a unique string array');
126
- if (meta.ready === true || ['in_progress','review','done'].includes(meta.status)) {
127
- for (const heading of ['## 11. SKILL 调用计划', '## 12. 停止、检查点与交付']) if (!body.includes(heading)) errors.push(`ready Plan Ticket missing '${heading}'`);
128
- }
126
+ // Skill invocation is enforced by the structured bindings and real completion
127
+ // records below, not by repeating generic instructions in every Ticket body.
129
128
  if (checkEvidence && meta.status === 'done' && meta.skill_bindings?.some(binding => binding?.required === true)) {
130
129
  try {
131
130
  const evidencePath = join(dirname(dirname(path)), 'evidence', `${meta.id}.md`);
@@ -262,13 +261,13 @@ export function validateInitiative(directory) {
262
261
  return errors;
263
262
  }
264
263
 
265
- /** Read actual per-Ticket integration records; never creates empty execution slots. */
264
+ /** Read actual Ticket or Direct Spec records; never creates empty execution slots. */
266
265
  export function readChangeExecution(changeRoot) {
267
266
  const root = join(changeRoot, 'evidence');
268
267
  if (!existsSync(root)) return [];
269
268
  if (lstatSync(root).isSymbolicLink() || !lstatSync(root).isDirectory()) throw new Error('Evidence must be a real directory');
270
269
  const records = [];
271
- for (const file of readdirSync(root).filter(name => /^T-\d{2,}\.md$/.test(name)).sort()) {
270
+ for (const file of readdirSync(root).filter(name => /^T-\d{2,}\.md$/.test(name) || name === 'direct-spec.md').sort()) {
272
271
  const path = join(root, file);
273
272
  if (lstatSync(path).isSymbolicLink() || !lstatSync(path).isFile()) throw new Error(`${path}: Evidence must be a regular file`);
274
273
  const text = readFileSync(path, 'utf8').replaceAll('\r\n', '\n');
@@ -276,9 +275,17 @@ export function readChangeExecution(changeRoot) {
276
275
  if (!sections.length) continue;
277
276
  if (sections.length !== 1) throw new Error(`${path}: duplicate Workspace Execution Record`);
278
277
  const record = readEvidenceValue(text,'## Workspace Execution Record');
279
- if (!plainObject(record) || record.ticket_id !== file.slice(0, -3)) throw new Error(`${path}: execution record identity differs from Evidence Ticket`);
278
+ const direct = file === 'direct-spec.md';
279
+ if (!plainObject(record) || (direct
280
+ ? record.purpose !== 'direct-spec' || record.change !== basename(changeRoot) || 'ticket_id' in record
281
+ : record.ticket_id !== file.slice(0, -3) || 'purpose' in record || 'change' in record)) {
282
+ throw new Error(`${path}: execution record identity differs from Evidence ${direct ? 'Direct Spec/change' : 'Ticket'}`);
283
+ }
280
284
  records.push(record);
281
285
  }
286
+ if (records.some(record => record.purpose === 'direct-spec') && records.some(record => record.ticket_id)) {
287
+ throw new Error('Direct Spec and Ticket execution records cannot coexist; reconcile the original execution before changing modes');
288
+ }
282
289
  return records;
283
290
  }
284
291
 
@@ -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,94 @@
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
+ const root = realpathSync(resolve(options.repo));
47
+ if (realpathSync(git(root, ['rev-parse','--show-toplevel']).trim()) !== root) throw new Error('review-scope: working-tree requires the actual project Git root');
48
+ const base = commit(root, options.base, 'base');
49
+ const files = options.files?.map(portableFile);
50
+ if (files && (!files.length || new Set(files).size !== files.length)) throw new Error('review-scope: empty or duplicate working-tree scope');
51
+ const capture = () => {
52
+ if (git(root, ['ls-files','--unmerged','-z'])) throw new Error('review-scope: unresolved index conflicts');
53
+ const head = commit(root, 'HEAD', 'HEAD');
54
+ const index = git(root, ['ls-files','--stage','-z']);
55
+ const inventory = git(root, ['status','--porcelain=v1','-z','--untracked-files=all']);
56
+ const baseEntries = new Map(git(root, ['ls-tree','-r','-z','--full-tree',base]).split('\0').filter(Boolean).map(row => {
57
+ const tab = row.indexOf('\t'), [mode,type,oid] = row.slice(0,tab).split(' ');
58
+ return [row.slice(tab+1), {mode,type,oid}];
59
+ }));
60
+ const diffArgs = ['diff','--no-color','--no-ext-diff','--no-textconv','--binary','--no-renames',base,'--'];
61
+ const changed = [...new Set([
62
+ ...git(root, ['diff','--no-ext-diff','--no-textconv','--no-renames','--name-only','-z',base,'--']).split('\0'),
63
+ ...git(root, ['ls-files','--others','--exclude-standard','-z']).split('\0'),
64
+ ].filter(Boolean))].sort();
65
+ const selected = files ? [...files].sort() : changed;
66
+ const snapshot = selected.map(path => {
67
+ portableFile(path);
68
+ const entry = baseEntries.get(path);
69
+ if (entry && entry.type !== 'blob') throw new Error('review-scope: submodules require separate review; working-tree snapshot is unverified');
70
+ const before = entry ? {mode:entry.mode, oid:entry.oid, sha256:digest(git(root, ['cat-file','blob',entry.oid], null))} : null;
71
+ const after = workingFile(root, path);
72
+ if (!before && !after) throw new Error(`review-scope: missing or unsupported working-tree path ${path}`);
73
+ 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';
74
+ return {path, change, before, after};
75
+ });
76
+ const args = [...diffArgs, ...selected.map(path => `:(literal)${path}`)];
77
+ const scope = {
78
+ version:1, mode:'working-tree', review_mode:'working-tree',
79
+ outcome:snapshot.some(file => file.change !== 'unchanged') ? 'changes' : 'no-diff',
80
+ base_sha:base, observed_head:head, selection:files ? 'explicit-files' : 'all-changes', scope_paths:selected,
81
+ changed_paths:snapshot.filter(file => file.change !== 'unchanged').map(file => file.path),
82
+ excluded_paths:changed.filter(path => !selected.includes(path)),
83
+ working_snapshot:snapshot, index_sha256:digest(index), inventory_sha256:digest(inventory),
84
+ tracked_diff_sha256:digest(git(root,args,null)), tracked_diff_command:['git',...args],
85
+ };
86
+ return {...scope, input_sha256:digest(JSON.stringify(scope))};
87
+ };
88
+ const first = capture(), second = capture();
89
+ if (first.input_sha256 !== second.input_sha256) throw new Error('review-scope: working-tree changed while freezing inputs; stop writers and retry');
90
+ return first;
91
+ }
8
92
  function contained(root, path) {
9
93
  const rel = relative(root, path);
10
94
  if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
@@ -65,10 +149,11 @@ function commit(repo, ref, field) {
65
149
  export function resolveReviewScope(options) {
66
150
  const { repo, mode } = options;
67
151
  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');
152
+ throw new Error('review-scope: --repo and --mode baseline|range|commit|pr|source-files|working-tree are required');
69
153
  }
70
154
  if (mode === 'source-files') return sourceFiles(options);
71
- if (options.files !== undefined) throw new Error('review-scope: --file requires source-files mode');
155
+ if (mode === 'working-tree') return workingTree(options);
156
+ if (options.files !== undefined) throw new Error('review-scope: --file requires source-files or working-tree mode');
72
157
  git(repo, ['rev-parse', '--git-dir']);
73
158
  let base, head;
74
159
  let selectedParent = null;
@@ -223,10 +223,10 @@ test('range comparison and merge-base comparison have distinct semantics', t =>
223
223
  const range = resolveReviewScope({ repo: r.root, mode: 'range', base: r.c, head: side });
224
224
  assert.deepEqual(baseline.changed_paths, ['side.txt']); assert.deepEqual(range.changed_paths, ['b.txt', 'c.txt', 'side.txt']);
225
225
  });
226
- test('fifteen Work IDs and persisted schema versions remain unchanged', () => {
226
+ test('fifteen Work IDs match the current registry and persisted schema versions remain unchanged', () => {
227
227
  const entries = readdirSync(workflow).filter(name => /^specdev-[a-z]-/.test(name)); assert.equal(entries.length, 15);
228
228
  const ids = entries.map(name => /speculo-id: (.+)$/m.exec(readFileSync(join(workflow, name, 'SKILL.md'), 'utf8'))?.[1]);
229
- assert.deepEqual(ids.sort(), ['archive-and-consolidate','code-review','diagnose-bugs','goal-plan','grill-with-docs','implement','init-setup','learn-change','prototype','retro','review-architecture','spec','tickets','triage','wayfinder'].map(id => 'specdev/' + id).sort());
229
+ assert.deepEqual(ids.sort(), ['archive-and-consolidate','code-review','diagnose-bugs','goal-plan','grill-with-docs','implement','init-setup','learn','prototype','retro','review-architecture','spec','tickets','triage','wayfinder'].map(id => 'specdev/' + id).sort());
230
230
  for (const [file, version] of [['config', 7], ['status', 5], ['change-status', 6], ['goal-plan', 7]]) {
231
231
  const schema = JSON.parse(readFileSync(join(workflow, 'common/schemas', `${file}.schema.json`), 'utf8'));
232
232
  assert.equal(schema.properties.schema_version.const, version);
@@ -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,59 @@
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
+ for(const path of ['one.txt','removed.txt','renamed.txt']) writeFileSync(join(root,path),'original\n');
15
+ writeFileSync(join(root,'.gitignore'),'ignored/\n');git('add','.');git('-c','commit.gpgsign=false','commit','-m','base');
16
+ const base=git('rev-parse','HEAD');
17
+ return {root,git,base,scope:(extra={})=>resolveReviewScope({repo:root,mode:'working-tree',base,...extra})};
18
+ }
19
+ test('working-tree freezes staged, unstaged, added, deleted, renamed and mode changes without writes',t=>{
20
+ const f=fixture(t);
21
+ writeFileSync(join(f.root,'one.txt'),'staged\n');f.git('add','one.txt');writeFileSync(join(f.root,'one.txt'),'final\n');
22
+ rmSync(join(f.root,'removed.txt'));renameSync(join(f.root,'renamed.txt'),join(f.root,'new-name.txt'));
23
+ writeFileSync(join(f.root,'new.bin'),Buffer.from([0,255,7]));chmodSync(join(f.root,'one.txt'),0o755);
24
+ mkdirSync(join(f.root,'ignored'));writeFileSync(join(f.root,'ignored/not-selected'),'ignored');
25
+ const before=readFileSync(join(f.root,'.git/index')), status=f.git('status','--porcelain');
26
+ const scope=f.scope();
27
+ assert.equal(scope.outcome,'changes');assert.equal(scope.observed_head,f.base);assert.equal(scope.head_sha,undefined);
28
+ assert.deepEqual(scope.changed_paths,['new-name.txt','new.bin','one.txt','removed.txt','renamed.txt']);
29
+ assert.equal(scope.working_snapshot.find(x=>x.path==='one.txt').after.mode,'100755');
30
+ assert.equal(scope.working_snapshot.find(x=>x.path==='removed.txt').after,null);
31
+ assert.equal(scope.working_snapshot.find(x=>x.path==='new.bin').after.bytes,3);
32
+ assert.deepEqual(f.scope(),scope);assert.equal(f.git('status','--porcelain'),status);assert.deepEqual(readFileSync(join(f.root,'.git/index')),before);
33
+ writeFileSync(join(f.root,'new.bin'),Buffer.from([0,255,8]));assert.notEqual(f.scope().input_sha256,scope.input_sha256);
34
+ });
35
+ test('explicit scopes are literal, expose exclusions and retain deleted files',t=>{
36
+ const f=fixture(t), path='literal [*] $(touch unsafe).txt';
37
+ writeFileSync(join(f.root,path),'literal');rmSync(join(f.root,'removed.txt'));writeFileSync(join(f.root,'one.txt'),'changed');
38
+ const scope=f.scope({files:[path,'removed.txt']});
39
+ assert.deepEqual(scope.changed_paths,[path,'removed.txt']);assert.deepEqual(scope.excluded_paths,['one.txt']);
40
+ assert.equal(scope.selection,'explicit-files');
41
+ for(const files of [[],['../outside'],['.git/config'],['one.txt','one.txt'],['missing']]) assert.throws(()=>f.scope({files}));
42
+ for(const extra of [{head:'HEAD'},{commit:'HEAD'},{parent:1}]) assert.throws(()=>f.scope(extra));
43
+ assert.throws(()=>f.scope({base:'missing'}));
44
+ });
45
+ test('links are read as links and directory links never disclose outside bytes',t=>{
46
+ const f=fixture(t), outside=mkdtempSync(join(tmpdir(),'review-outside-'));
47
+ t.after(()=>rmSync(outside,{recursive:true,force:true}));writeFileSync(join(outside,'secret'),'sensitive');
48
+ symlinkSync(join(outside,'secret'),join(f.root,'link'));
49
+ const scope=f.scope({files:['link']});assert.equal(scope.working_snapshot[0].after.kind,'symlink');
50
+ symlinkSync(outside,join(f.root,'directory'));
51
+ assert.throws(()=>f.scope({files:['directory/secret']}),/symlink directory/);
52
+ });
53
+ test('unmerged index and borrowed parent repository are rejected',t=>{
54
+ const f=fixture(t);f.git('checkout','-b','other');writeFileSync(join(f.root,'one.txt'),'other\n');f.git('commit','-am','other');
55
+ f.git('checkout','main');writeFileSync(join(f.root,'one.txt'),'main\n');f.git('commit','-am','main');
56
+ assert.throws(()=>f.git('merge','other'));assert.throws(()=>f.scope(),/unresolved index/);
57
+ f.git('merge','--abort');mkdirSync(join(f.root,'nested'));
58
+ assert.throws(()=>resolveReviewScope({repo:join(f.root,'nested'),mode:'working-tree',base:f.base}),/actual project Git root/);
59
+ });