@namewta/speculo 2.0.3 → 2.0.5

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 (84) hide show
  1. package/package.json +1 -1
  2. package/template/canonical/canonical-specdev-goal-plan.md +2145 -1003
  3. package/template/canonical/canonical-specdev-grill-with-docs.md +1948 -506
  4. package/template/canonical/canonical-specdev-spec.md +2046 -604
  5. package/template/canonical/canonical-specdev-tickets.md +1978 -518
  6. package/template/canonical/canonical-specdev-wayfinder.md +2393 -951
  7. package/template/commands/git-history-squash/scripts/git-history-squash.mjs +4 -4
  8. package/template/references/coordination/coordination-contract.md +7 -5
  9. package/template/references/coordination/result-and-recovery.md +1 -1
  10. package/template/references/coordination/session-files.md +2 -2
  11. package/template/references/coordination/task-brief.md +4 -4
  12. package/template/references/retrospective/references/friction-taxonomy.md +1 -1
  13. package/template/references/writing-for-agents/references/project-skill-maintenance.md +29 -0
  14. package/template/references/writing-for-agents/writing-contract.md +2 -0
  15. package/template/workflows/specdev/SKILL.md +1 -1
  16. package/template/workflows/specdev/common/README.md +1 -1
  17. package/template/workflows/specdev/common/rules/artifact-contract.md +1 -1
  18. package/template/workflows/specdev/common/rules/change-completion.md +6 -6
  19. package/template/workflows/specdev/common/rules/domain-runtime.md +2 -2
  20. package/template/workflows/specdev/common/rules/evidence-and-verification.md +17 -34
  21. package/template/workflows/specdev/common/rules/execution-policy.md +24 -16
  22. package/template/workflows/specdev/common/rules/operating-governance.md +1 -1
  23. package/template/workflows/specdev/common/rules/parent-implementation-orchestration.md +4 -2
  24. package/template/workflows/specdev/common/rules/path-ownership.md +9 -7
  25. package/template/workflows/specdev/common/rules/workflow-routing.md +1 -1
  26. package/template/workflows/specdev/common/rules/workflow-state-and-lifecycle.md +6 -2
  27. package/template/workflows/specdev/common/schemas/execution-context.schema.json +67 -0
  28. package/template/workflows/specdev/common/schemas/execution-record.schema.json +154 -0
  29. package/template/workflows/specdev/common/schemas/goal-plan.schema.json +15 -55
  30. package/template/workflows/specdev/common/schemas/implementation-plan.schema.json +15 -55
  31. package/template/workflows/specdev/common/schemas/retro-approval.schema.json +44 -0
  32. package/template/workflows/specdev/common/schemas/retro-archive.schema.json +99 -0
  33. package/template/workflows/specdev/common/schemas/retro-execution.schema.json +112 -0
  34. package/template/workflows/specdev/common/schemas/retro-upgrade.schema.json +163 -0
  35. package/template/workflows/specdev/common/schemas/retrospective.schema.json +119 -14
  36. package/template/workflows/specdev/common/skills/code-review/references/review-evidence.md +2 -2
  37. package/template/workflows/specdev/common/skills/code-review/references/source-discovery.md +2 -0
  38. package/template/workflows/specdev/common/skills/code-review/references/working-tree-review.md +1 -1
  39. package/template/workflows/specdev/common/skills/dev-worktree/SKILL.md +15 -34
  40. package/template/workflows/specdev/common/skills/dev-worktree/references/create.md +27 -56
  41. package/template/workflows/specdev/common/skills/dev-worktree/references/finalize.md +10 -54
  42. package/template/workflows/specdev/common/tools/README.md +10 -0
  43. package/template/workflows/specdev/common/tools/delivery-artifacts.mjs +4 -4
  44. package/template/workflows/specdev/common/tools/execution-context.mjs +104 -0
  45. package/template/workflows/specdev/common/tools/plan-contract.mjs +3 -1
  46. package/template/workflows/specdev/common/tools/review-evidence.mjs +2 -2
  47. package/template/workflows/specdev/common/tools/review-scope.mjs +4 -2
  48. package/template/workflows/specdev/common/tools/tests/engineering.test.mjs +2 -2
  49. package/template/workflows/specdev/common/tools/tests/review-fixture.mjs +4 -4
  50. package/template/workflows/specdev/common/tools/tests/review-working-tree.test.mjs +4 -3
  51. package/template/workflows/specdev/common/tools/ticket-control.mjs +17 -12
  52. package/template/workflows/specdev/common/tools/validate-retro.mjs +331 -0
  53. package/template/workflows/specdev/common/tools/validate-specdev.mjs +135 -105
  54. package/template/workflows/specdev/specdev-a-archive-and-consolidate/SKILL.md +2 -1
  55. package/template/workflows/specdev/specdev-a-archive-and-consolidate/references/operation-contract.md +2 -0
  56. package/template/workflows/specdev/specdev-a-archive-and-consolidate/references/retro-archive.md +27 -0
  57. package/template/workflows/specdev/specdev-c-code-review/SKILL.md +1 -1
  58. package/template/workflows/specdev/specdev-i-implement/SKILL.md +3 -3
  59. package/template/workflows/specdev/specdev-i-implement/assets/evidence-template.md +37 -29
  60. package/template/workflows/specdev/specdev-i-implement/references/execution-preflight.md +16 -16
  61. package/template/workflows/specdev/specdev-i-implement/references/implementation-procedure.md +21 -37
  62. package/template/workflows/specdev/specdev-i-implement/references/merge-conflict-protocol.md +8 -18
  63. package/template/workflows/specdev/specdev-p-goal-plan/SKILL.md +3 -3
  64. package/template/workflows/specdev/specdev-p-goal-plan/assets/goal-plan-template.md +23 -26
  65. package/template/workflows/specdev/specdev-p-goal-plan/references/completion-control.md +11 -14
  66. package/template/workflows/specdev/specdev-p-goal-plan/references/lead-orchestration.md +12 -33
  67. package/template/workflows/specdev/specdev-p-goal-plan/references/map-control.md +1 -1
  68. package/template/workflows/specdev/specdev-p-goal-plan/references/multi-change-plan.md +11 -16
  69. package/template/workflows/specdev/specdev-p-goal-plan/references/multi-conflict-and-drift.md +2 -2
  70. package/template/workflows/specdev/specdev-p-goal-plan/references/multi-execution-loop.md +6 -6
  71. package/template/workflows/specdev/specdev-p-goal-plan/references/orchestration-protocol.md +13 -44
  72. package/template/workflows/specdev/specdev-p-goal-plan/references/planning-modes.md +18 -25
  73. package/template/workflows/specdev/specdev-p-goal-plan/references/single-change-plan.md +16 -16
  74. package/template/workflows/specdev/specdev-r-retro/SKILL.md +15 -11
  75. package/template/workflows/specdev/specdev-r-retro/assets/retro-template.md +26 -14
  76. package/template/workflows/specdev/specdev-r-retro/assets/upgrade-plan-template.md +41 -0
  77. package/template/workflows/specdev/specdev-r-retro/references/development-retrospective.md +42 -0
  78. package/template/workflows/specdev/specdev-r-retro/references/records.md +47 -0
  79. package/template/workflows/specdev/specdev-r-retro/references/skill-upgrade.md +13 -0
  80. package/template/workflows/specdev/specdev-t-tickets/SKILL.md +2 -0
  81. package/template/workflows/specdev/specdev-t-tickets/assets/ticket-template.md +1 -1
  82. package/template/workflows/specdev/specdev-t-tickets/assets/tickets-map-template.md +6 -6
  83. package/template/workflows/specdev/specdev-t-tickets/references/planning-procedure.md +2 -2
  84. package/template/workflows/specdev/specdev-t-tickets/references/ticket-readiness.md +3 -3
@@ -14,7 +14,7 @@ function check(value, rule, at, errors) {
14
14
  if (rule.type) {
15
15
  const types = Array.isArray(rule.type) ? rule.type : [rule.type];
16
16
  const t = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
17
- if (!types.includes(t)) { errors.push(at + ": invalid type"); return; }
17
+ if (!types.includes(t) && !(types.includes("integer") && Number.isInteger(value))) { errors.push(at + ": invalid type"); return; }
18
18
  }
19
19
  if (typeof value === "string") {
20
20
  if (rule.minLength && value.trim().length < rule.minLength) errors.push(at + ": empty");
@@ -35,7 +35,6 @@ function check(value, rule, at, errors) {
35
35
  }
36
36
  const specs = {
37
37
  "pull-request": ["pull-request.schema.json", ["Summary","Evidence","Before / After","Merge Risk","Authorization","Receipt","Recovery"]],
38
- retrospective: ["retrospective.schema.json", ["Scope","Evidence","Findings","Acceptance","Routes"]],
39
38
  "logic-prototype": ["logic-prototype.schema.json", ["Model","Scenarios","Evidence","Decisions","Handoff"]],
40
39
  "triage-run": ["triage-run.schema.json", ["Targets","Plan","Evidence","Authorization","Receipts","Recovery"]],
41
40
  };
@@ -54,7 +53,6 @@ export function validateRecord(path, kind, parse, expectedChange = null) {
54
53
  if (meta.status === "ready" && meta.verification !== "passed") errors.push(path + ": ready PR requires passed verification");
55
54
  if (["draft","ready"].includes(meta.status) && !new RegExp("^https://github\\.com/" + String(meta.repo).replaceAll(".","\\.") + "/pull/[1-9]\\d*$").test(meta.url ?? "")) errors.push(path + ": missing matching PR URL");
56
55
  }
57
- if (kind === "retrospective" && (meta.sources ?? []).some(s => typeof s !== "string" || !s.trim() || /(?:[A-Za-z]:[\\/]|\b(?:token|secret)=)/i.test(s))) errors.push(path + ": unsafe source");
58
56
  if (kind === "triage-run" && meta.status === "completed" && meta.mode !== "ci-security") {
59
57
  if (meta.npm_target === "unknown") errors.push(path + ": completed release target cannot be unknown");
60
58
  if (!meta.commit_sha) errors.push(path + ": completed release requires fixed commit");
@@ -83,7 +81,7 @@ export function validateRecord(path, kind, parse, expectedChange = null) {
83
81
  return {errors,meta,body};
84
82
  }
85
83
  export function validateRecords(change, kind, parse) {
86
- const folder = kind === "retrospective" ? "retro" : "pull-requests", root=join(change,folder);
84
+ const root=join(change,"pull-requests");
87
85
  const errors=[], records=[];
88
86
  if (!existsSync(root)) return {errors,records};
89
87
  if (lstatSync(root).isSymbolicLink() || !lstatSync(root).isDirectory()) return {errors:[root + ": unsafe directory"],records};
@@ -93,3 +91,5 @@ export function validateRecords(change, kind, parse) {
93
91
  }
94
92
  return {errors,records};
95
93
  }
94
+
95
+ export { check as checkSchema };
@@ -0,0 +1,104 @@
1
+ /** Change workspace and Ticket scheduling contracts. Inspection only; never creates worktrees or agents. */
2
+ import { readFileSync, realpathSync } from 'node:fs';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { basename, join, relative, resolve } from 'node:path';
5
+ import * as runtime from '../../../../lib/speculo-runtime.mjs';
6
+ import { validateIndependentReview } from './review-evidence.mjs';
7
+
8
+ export const EXECUTION_VERSION = 1;
9
+ export const EXECUTION_CONTEXT = 'README.md#execution-context';
10
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
11
+ const text = value => typeof value === 'string' && value.trim().length > 0;
12
+ const sha = value => /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(value ?? '');
13
+ const positive = value => Number.isInteger(value) && value > 0;
14
+ const git = (root, args) => execFileSync('git', ['-C', root, ...args], {encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
15
+
16
+ export function readExecutionContext(change) {
17
+ const path = runtime.safeWorkPathSync(change, 'README.md', {mustExist:true,type:'file'});
18
+ const source = readFileSync(path, 'utf8');
19
+ const headings = runtime.markdownBodyLines(source, path).filter(line => line.text === '## Execution Context');
20
+ if (!headings.length) return null;
21
+ if (headings.length !== 1) throw new Error('duplicate Execution Context');
22
+ const section = runtime.readArtifactSectionSync(path, 'execution-context').text;
23
+ const blocks = [...section.matchAll(/```json\s*\n([\s\S]*?)\n```/g)];
24
+ if (blocks.length !== 1) throw new Error('Execution Context requires one JSON object');
25
+ const value = JSON.parse(blocks[0][1]);
26
+ const required = ['schema_version','change','workspace_policy','workspace_ref','branch','base_sha','owner','ticket_execution'];
27
+ const allowed = [...required,'max_parallel_tickets','concurrent_implementation_limit','integration_attempt_limit'];
28
+ if (!object(value) || required.some(key => !(key in value)) || Object.keys(value).some(key => !allowed.includes(key))) throw new Error('invalid Execution Context fields');
29
+ if (value.schema_version !== EXECUTION_VERSION || value.change !== basename(change)) throw new Error('Execution Context version/change mismatch; explicit reconciliation required');
30
+ if (!['current','worktree'].includes(value.workspace_policy) || !['auto','serial','parallel'].includes(value.ticket_execution)) throw new Error('invalid workspace_policy or ticket_execution');
31
+ if (!text(value.branch) || !text(value.owner) || !sha(value.base_sha)) throw new Error('Execution Context requires branch, owner and immutable base_sha');
32
+ for (const key of ['max_parallel_tickets','concurrent_implementation_limit','integration_attempt_limit']) if (key in value && !positive(value[key])) throw new Error(`${key} must be a positive integer`);
33
+ if (value.workspace_policy === 'current' ? value.workspace_ref !== 'current' : !text(value.workspace_ref) || value.workspace_ref === 'current' || /[\\:\x00-\x1f]/.test(value.workspace_ref) || value.workspace_ref.split('/').some(part => !part || part === '.' || part === '..' || part === '.git')) throw new Error('workspace_ref must identify the selected portable change workspace');
34
+ return value;
35
+ }
36
+
37
+ /** Defaults do not impose an Agent count. Parent and child Ticket caps remain separate from it. */
38
+ export function ticketPolicy(...sources) {
39
+ const values = sources.filter(Boolean);
40
+ const modes = values.map(value => value.ticket_execution).filter(Boolean);
41
+ const limits = values.map(value => value.max_parallel_tickets).filter(value => value !== undefined);
42
+ if (modes.some(mode => !['auto','serial','parallel'].includes(mode)) || limits.some(limit => !positive(limit))) throw new Error('invalid Ticket scheduling policy');
43
+ return {ticket_execution:modes.includes('serial') ? 'serial' : modes[0] ?? 'auto', max_parallel_tickets:modes.includes('serial') ? 1 : limits.length ? Math.min(...limits) : null};
44
+ }
45
+
46
+ export function resolveExecutionWorkspace(repoRoot, context) {
47
+ if (!context || !repoRoot) return repoRoot;
48
+ const root = realpathSync.native(repoRoot);
49
+ const workspace = context.workspace_ref === 'current' ? root : runtime.safeWorkPathSync(root, context.workspace_ref, {mustExist:true,type:'directory'});
50
+ if (relative(realpathSync.native(workspace), realpathSync.native(git(workspace,['rev-parse','--show-toplevel']))) !== '') throw new Error('selected workspace is not a real Git checkout');
51
+ const common = cwd => realpathSync.native(resolve(cwd, git(cwd,['rev-parse','--git-common-dir'])));
52
+ if (common(root) !== common(workspace)) throw new Error('selected workspace belongs to another repository');
53
+ if (git(workspace,['symbolic-ref','--short','HEAD']) !== context.branch) throw new Error('selected workspace branch drifted');
54
+ git(workspace,['cat-file','-e',`${context.base_sha}^{commit}`]);
55
+ git(workspace,['merge-base','--is-ancestor',context.base_sha,'HEAD']);
56
+ if (context.workspace_policy === 'worktree') {
57
+ const paths = git(root,['worktree','list','--porcelain','-z']).split('\0').filter(row=>row.startsWith('worktree ')).map(row=>realpathSync.native(row.slice(9)));
58
+ if (!paths.includes(realpathSync.native(workspace)) || realpathSync.native(workspace) === root) throw new Error('selected change worktree is not registered');
59
+ }
60
+ return workspace;
61
+ }
62
+
63
+ function evidenceText(change, locator) {
64
+ if (!text(locator) || !locator.includes('#')) throw new Error('verification evidence requires an explicit artifact anchor');
65
+ const [file, anchor, extra] = locator.split('#');
66
+ if (!anchor || extra !== undefined) throw new Error('invalid evidence locator');
67
+ const path = runtime.safeWorkPathSync(change, file, {mustExist:true,type:'file'});
68
+ return runtime.readArtifactSectionSync(path, anchor).text;
69
+ }
70
+
71
+ export function validateExecutionRecord(change, record, context, {repoRoot=null, sourceRoot=repoRoot, checkCurrent=false} = {}) {
72
+ const errors = [], unit = record.ticket_id ?? record.purpose;
73
+ try {
74
+ const identity = record.ticket_id ? ['ticket_id'] : ['purpose','change'];
75
+ const keys = ['schema_version',...identity,'workspace_context','implementation_owners','status','checkpoint','verification','attempts'];
76
+ if (record.schema_version !== EXECUTION_VERSION || keys.some(key=>!(key in record)) || Object.keys(record).some(key=>!keys.includes(key))) throw new Error('legacy or invalid execution record; explicit reconciliation required');
77
+ if (record.ticket_id ? !/^T-\d{2,}$/.test(record.ticket_id) : record.purpose !== 'direct-spec' || record.change !== basename(change)) throw new Error('execution identity does not match this change');
78
+ if (!context || record.workspace_context !== EXECUTION_CONTEXT) throw new Error('execution must reference the unique change Execution Context');
79
+ if (!['active','review','verified','blocked','cancelled'].includes(record.status)) throw new Error('invalid execution status');
80
+ if (!Array.isArray(record.implementation_owners) || !record.implementation_owners.length || record.implementation_owners.some(owner=>!text(owner)) || new Set(record.implementation_owners).size !== record.implementation_owners.length) throw new Error('implementation_owners must list the actual distinct implementers');
81
+ if (!Number.isInteger(record.attempts) || record.attempts < 0 || record.attempts > (context.integration_attempt_limit ?? 3)) throw new Error('verification attempts exceed the effective limit');
82
+ if (record.checkpoint !== null && !text(record.checkpoint)) throw new Error('checkpoint must be null or a frozen review input locator');
83
+ const verification = record.verification;
84
+ if (!object(verification) || Object.keys(verification).sort().join(',') !== 'e2e,evidence,status' || !['pending','passed','failed'].includes(verification.status)) throw new Error('invalid workspace verification');
85
+ const e2e = verification.e2e;
86
+ if (!object(e2e) || Object.keys(e2e).sort().join(',') !== 'evidence,reason,required,status' || typeof e2e.required !== 'boolean' || !['pending','passed','failed','not-required'].includes(e2e.status)) throw new Error('invalid E2E disposition');
87
+ for (const value of [verification.evidence,e2e.evidence,e2e.reason]) if (value !== null && !text(value)) throw new Error('evidence/reason must be null or non-empty text');
88
+ if (!e2e.required && (e2e.status !== 'not-required' || !text(e2e.reason)) || e2e.required && e2e.status === 'not-required') throw new Error('E2E disposition needs a truthful result/reason');
89
+ if (record.status === 'verified') {
90
+ if (verification.status !== 'passed' || !['passed','not-required'].includes(e2e.status) || !record.checkpoint) throw new Error('verified execution requires passed checks, E2E and a frozen checkpoint');
91
+ const observed = evidenceText(change,verification.evidence);
92
+ if (!/^Result: passed\s*$/m.test(observed) || !/^Observed: \S/m.test(observed) || /^Command: /m.test(observed) && !/^Exit: 0\s*$/m.test(observed)) throw new Error('verification requires actual observations and successful command exits');
93
+ if (e2e.required) {
94
+ const result = evidenceText(change,e2e.evidence);
95
+ if (!/^Result: passed\s*$/m.test(result) || !/^Observed: \S/m.test(result) || /^Command: /m.test(result) && !/^Exit: 0\s*$/m.test(result) || !result.split('\n').some(line=>line.trim() === `Owner: ${context.owner}`)) throw new Error('required E2E needs observed passing evidence owned by the Lead');
96
+ }
97
+ const artifact = `evidence/${record.ticket_id ?? 'direct-spec'}.md`;
98
+ const source = readFileSync(runtime.safeWorkPathSync(change,artifact,{mustExist:true,type:'file'}),'utf8');
99
+ if (!source.split('\n').some(line=>line.trim() === `Input: ${record.checkpoint}`)) throw new Error('checkpoint must match the actual Independent Review input');
100
+ errors.push(...validateIndependentReview(change,artifact,{repoRoot,sourceRoot,requiredImplementers:record.implementation_owners,checkCurrent,requireDelivery:checkCurrent}));
101
+ }
102
+ } catch(error) { errors.push(`${unit}: ${error.message}`); }
103
+ return errors;
104
+ }
@@ -319,7 +319,9 @@ export function readSpecification(changeRoot, { allowLightweight = false, requir
319
319
  export function validateExecutionPolicy(plan, config = null) {
320
320
  const errors = [];
321
321
  const meta = plan.meta;
322
- if (!Number.isInteger(meta.concurrent_implementation_limit) || meta.concurrent_implementation_limit < 1 || !Number.isInteger(meta.integration_attempt_limit) || meta.integration_attempt_limit < 1) return ['plan must declare positive execution limits'];
322
+ if (!['auto','serial','parallel'].includes(meta.ticket_execution)) errors.push('plan must declare ticket_execution: auto, serial or parallel');
323
+ for (const key of ['concurrent_implementation_limit','max_parallel_tickets']) if (meta[key] !== undefined && (!Number.isInteger(meta[key]) || meta[key] < 1)) errors.push(`${key} must be a positive integer when specified`);
324
+ if (!Number.isInteger(meta.integration_attempt_limit) || meta.integration_attempt_limit < 1) errors.push('plan must declare a positive integration_attempt_limit');
323
325
  if (config) {
324
326
  if (config.schema_version !== 7 || !Number.isInteger(config.execution?.max_concurrent_implementations) || !Number.isInteger(config.execution?.max_integration_attempts)) return ['existing tool config requires schema v7 and explicit execution limits'];
325
327
  if (meta.concurrent_implementation_limit > config.execution.max_concurrent_implementations) errors.push(`concurrent_implementation_limit ${meta.concurrent_implementation_limit} exceeds config max_concurrent_implementations ${config.execution.max_concurrent_implementations}`);
@@ -34,7 +34,7 @@ function readLocator(root, locator) {
34
34
  export function reviewInputDigest(input) { return hash(JSON.stringify(input)); }
35
35
 
36
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} = {}) {
37
+ export function validateIndependentReview(root, artifact, {repoRoot=null, sourceRoot=repoRoot, requirePassed=true, expectedHead=null, expectedBase=null, expectedResults=null, requiredImplementers=[], requiredSpecifications=[], checkCurrent=false, requireDelivery=false, allowSourceFiles=false} = {}) {
38
38
  const errors = [], label = `independent-review: ${artifact}`;
39
39
  try {
40
40
  const path = safeWorkPathSync(root, artifact, {mustExist:true, type:'file'});
@@ -54,7 +54,7 @@ export function validateIndependentReview(root, artifact, {repoRoot=null, requir
54
54
  for (const source of input.sources) {
55
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
56
  sourceKinds.add(source.kind);
57
- if (repoRoot && checkCurrent && hash(readLocator(repoRoot,source.locator).text) !== source.sha256) throw new Error(`source drift: ${source.locator}`);
57
+ if (repoRoot && checkCurrent && hash(readLocator(sourceRoot,source.locator).text) !== source.sha256) throw new Error(`source drift: ${source.locator}`);
58
58
  }
59
59
  if (!sourceKinds.has('standards') || requirePassed && !sourceKinds.has('specification')) throw new Error('missing required review sources');
60
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');
@@ -43,8 +43,10 @@ function workingFile(root, path) {
43
43
 
44
44
  function workingTree(options) {
45
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');
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');
48
50
  const base = commit(root, options.base, 'base');
49
51
  const files = options.files?.map(portableFile);
50
52
  if (files && (!files.length || new Set(files).size !== files.length)) throw new Error('review-scope: empty or duplicate working-tree scope');
@@ -223,11 +223,11 @@ 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 match the current registry and persisted schema versions remain unchanged', () => {
226
+ test('fifteen Work IDs match the current registry and persisted schema versions match their contracts', () => {
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
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
- for (const [file, version] of [['config', 7], ['status', 5], ['change-status', 6], ['goal-plan', 7]]) {
230
+ for (const [file, version] of [['config', 7], ['status', 5], ['change-status', 6], ['goal-plan', 8], ['implementation-plan', 3], ['execution-context', 1], ['execution-record', 1]]) {
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);
233
233
  }
@@ -6,7 +6,7 @@ import {resolveReviewScope} from '../review-scope.mjs';
6
6
  import {reviewInputDigest} from '../review-evidence.mjs';
7
7
  import {readArtifactSectionSync} from '../../../../../lib/speculo-runtime.mjs';
8
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}) {
9
+ export function appendReviewFixture({repo,sourceRoot=repo,root,artifact='evidence/direct-spec.md',base,head=null,files=['value.txt'],sourceFiles=false,mode='native',reviewer='native:reviewer',implementers=['native:implementer'],spec=null,sourceLocators=null}) {
10
10
  const path=join(root,artifact);mkdirSync(dirname(path),{recursive:true});
11
11
  let prior=existsSync(path)?readFileSync(path,'utf8'):'';
12
12
  prior=prior.split('\n<!-- synthetic-independent-review -->')[0];
@@ -14,14 +14,14 @@ export function appendReviewFixture({repo,root,artifact='evidence/direct-spec.md
14
14
  let sources;
15
15
  if(sourceLocators) sources=sourceLocators.map(({kind,locator})=>{
16
16
  const [file,anchor]=locator.split('#');
17
- return {kind,locator,sha256:hash(anchor?readArtifactSectionSync(join(repo,file),anchor).text:readFileSync(join(repo,file),'utf8'))};
17
+ return {kind,locator,sha256:hash(anchor?readArtifactSectionSync(join(sourceRoot,file),anchor).text:readFileSync(join(sourceRoot,file),'utf8'))};
18
18
  });
19
19
  else {
20
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
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'))}));
22
+ sources=[['standards',standards],['specification',spec]].map(([kind,file])=>({kind,locator:relative(sourceRoot,file).split('\\').join('/'),sha256:hash(readFileSync(file,'utf8'))}));
23
23
  }
24
- const scope=resolveReviewScope(head?{repo,mode:'range',base,head}:{repo,mode:'working-tree',base,files});
24
+ const scope=resolveReviewScope(sourceFiles?{repo,mode:'source-files',files}:head?{repo,mode:'range',base,head}:{repo,mode:'working-tree',base,files});
25
25
  const input={scope,sources,exclusions:'Fixture-only records and unrelated inputs are outside the implementation scope.'};
26
26
  const digest=reviewInputDigest(input);
27
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`;
@@ -11,6 +11,7 @@ function fixture(t) {
11
11
  t.after(()=>rmSync(root,{recursive:true,force:true}));
12
12
  const git=(...args)=>execFileSync('git',['-C',root,...args],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
13
13
  git('init','-b','main');git('config','user.name','Fixture');git('config','user.email','test@example.invalid');
14
+ git('config','core.autocrlf','false');
14
15
  for(const path of ['one.txt','removed.txt','renamed.txt']) writeFileSync(join(root,path),'original\n');
15
16
  writeFileSync(join(root,'.gitignore'),'ignored/\n');git('add','.');git('-c','commit.gpgsign=false','commit','-m','base');
16
17
  const base=git('rev-parse','HEAD');
@@ -26,14 +27,14 @@ test('working-tree freezes staged, unstaged, added, deleted, renamed and mode ch
26
27
  const scope=f.scope();
27
28
  assert.equal(scope.outcome,'changes');assert.equal(scope.observed_head,f.base);assert.equal(scope.head_sha,undefined);
28
29
  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==='one.txt').after.mode,process.platform === 'win32' ? '100644' : '100755');
30
31
  assert.equal(scope.working_snapshot.find(x=>x.path==='removed.txt').after,null);
31
32
  assert.equal(scope.working_snapshot.find(x=>x.path==='new.bin').after.bytes,3);
32
33
  assert.deepEqual(f.scope(),scope);assert.equal(f.git('status','--porcelain'),status);assert.deepEqual(readFileSync(join(f.root,'.git/index')),before);
33
34
  writeFileSync(join(f.root,'new.bin'),Buffer.from([0,255,8]));assert.notEqual(f.scope().input_sha256,scope.input_sha256);
34
35
  });
35
36
  test('explicit scopes are literal, expose exclusions and retain deleted files',t=>{
36
- const f=fixture(t), path='literal [*] $(touch unsafe).txt';
37
+ const f=fixture(t), path='literal [abc] $(touch unsafe).txt';
37
38
  writeFileSync(join(f.root,path),'literal');rmSync(join(f.root,'removed.txt'));writeFileSync(join(f.root,'one.txt'),'changed');
38
39
  const scope=f.scope({files:[path,'removed.txt']});
39
40
  assert.deepEqual(scope.changed_paths,[path,'removed.txt']);assert.deepEqual(scope.excluded_paths,['one.txt']);
@@ -47,7 +48,7 @@ test('links are read as links and directory links never disclose outside bytes',
47
48
  t.after(()=>rmSync(outside,{recursive:true,force:true}));writeFileSync(join(outside,'secret'),'sensitive');
48
49
  symlinkSync(join(outside,'secret'),join(f.root,'link'));
49
50
  const scope=f.scope({files:['link']});assert.equal(scope.working_snapshot[0].after.kind,'symlink');
50
- symlinkSync(outside,join(f.root,'directory'));
51
+ symlinkSync(outside,join(f.root,'directory'),process.platform === 'win32' ? 'junction' : 'dir');
51
52
  assert.throws(()=>f.scope({files:['directory/secret']}),/symlink directory/);
52
53
  });
53
54
  test('unmerged index and borrowed parent repository are rejected',t=>{
@@ -5,6 +5,7 @@ import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { parseFrontmatter, validateChange, pathsOverlap } from './validate-specdev.mjs';
7
7
  import { CHANGE_ID, digest, findGraphCycle, resolveProjectSource, validateGoalMap, resolveToolContext, validateExecutionPolicy, canonicalToolPath } from './plan-contract.mjs';
8
+ import { readExecutionContext, ticketPolicy } from './execution-context.mjs';
8
9
  import * as runtime from '../../../../lib/speculo-runtime.mjs';
9
10
 
10
11
  const terminal = new Set(['done','cancelled']);
@@ -43,7 +44,7 @@ function contractHash(node) {
43
44
  }
44
45
  }
45
46
  }
46
- return digest(JSON.stringify({ meta, body: node.artifact.body, spec: node.specDigest, goal: node.goalDigest, dependencies: node.dependencies, serializations: node.serializations, sources }));
47
+ return digest(JSON.stringify({ meta, body: node.artifact.body, spec: node.specDigest, goal: node.goalDigest, scheduling:node.policy, workspace:readExecutionContext(dirname(dirname(node.artifact.path))), dependencies: node.dependencies, serializations: node.serializations, sources }));
47
48
  }
48
49
 
49
50
  export function analyzeMap({ mapPath, repoRoot, methodRoot, stateRoot, context: suppliedContext, previous = null }) {
@@ -68,9 +69,9 @@ export function analyzeMap({ mapPath, repoRoot, methodRoot, stateRoot, context:
68
69
  if (!Array.isArray(members) || !members.length || (parent && members.length < 2) || new Set(members).size !== members.length || members.some(id => !CHANGE_ID.test(id))) return failure(['invalid/duplicate member change IDs']);
69
70
  const planFile = join(root,parent ? 'implementation-plan.md' : 'goal-plan.md');
70
71
  const plan = existsSync(planFile) ? readArtifact(planFile) : null;
71
- const strategy = plan?.meta.ticket_workspace_policy ?? 'current';
72
- let limit = strategy === 'current' ? 1 : plan?.meta.concurrent_implementation_limit;
73
- if (!['current','required'].includes(strategy) || !Number.isInteger(limit) || limit < 1) errors.push('invalid workspace strategy or implementation limit');
72
+ const scopeContext = readExecutionContext(root);
73
+ const policy = ticketPolicy(plan?.meta,scopeContext);
74
+ const limit = policy.max_parallel_tickets;
74
75
  const configPath = join(context.specdevRoot, 'config.json');
75
76
  const config = existsSync(configPath) ? JSON.parse(readFileSync(runtime.safeWorkPathSync(context.specdevRoot, 'config.json', {mustExist:true,type:'file'}), 'utf8')) : null;
76
77
  if (plan) errors.push(...validateExecutionPolicy(plan, config));
@@ -81,6 +82,9 @@ export function analyzeMap({ mapPath, repoRoot, methodRoot, stateRoot, context:
81
82
  if (!existsSync(memberRoot) || lstatSync(memberRoot).isSymbolicLink() || !lstatSync(memberRoot).isDirectory()) { errors.push(`member must be a real change directory: ${member}`); continue; }
82
83
  memberRoots.set(member,memberRoot);
83
84
  const facts = runtime.readChangeFactsSync(context, member);
85
+ const childPlanPath = join(memberRoot,'goal-plan.md');
86
+ const childPlan = parent && existsSync(childPlanPath) ? readArtifact(childPlanPath) : null;
87
+ const memberPolicy = ticketPolicy(plan?.meta,childPlan?.meta,readExecutionContext(memberRoot));
84
88
  if (facts.location.root !== memberRoot) { errors.push(`member is not an active change: ${member}`); continue; }
85
89
  const specFile = join(memberRoot,'spec.md');
86
90
  const spec = readArtifact(specFile);
@@ -93,7 +97,7 @@ export function analyzeMap({ mapPath, repoRoot, methodRoot, stateRoot, context:
93
97
  const id = parent ? `${member}::${artifact.meta.id}` : artifact.meta.id;
94
98
  if (nodes.has(id)) { errors.push(`duplicate Ticket ${id}`); continue; }
95
99
  if (!Array.isArray(artifact.meta.blocked_by) || artifact.meta.blocked_by.some(value => !/^T-\d{2,}$/.test(value))) { errors.push(`invalid dependencies: ${id}`); continue; }
96
- nodes.set(id, { id, member, repoRoot, artifact, specDigest, facts, dependencies: artifact.meta.blocked_by.map(value => parent ? `${member}::${value}` : value), reasons: [], validationErrors: [] });
100
+ nodes.set(id, { id, member, repoRoot, policy:memberPolicy, artifact, specDigest, facts, dependencies: artifact.meta.blocked_by.map(value => parent ? `${member}::${value}` : value), reasons: [], validationErrors: [] });
97
101
  if (spec.meta.ready_for_tickets !== true) nodes.get(id).reasons.push('source Spec is not Ready');
98
102
  }
99
103
  }
@@ -146,7 +150,7 @@ export function analyzeMap({ mapPath, repoRoot, methodRoot, stateRoot, context:
146
150
  }
147
151
  for (const node of nodes.values()) {
148
152
  node.reasons.push(...node.validationErrors);
149
- if (!terminal.has(node.artifact.meta.status) && (!plan || plan.meta.ready_for_execution !== true)) node.reasons.push('Goal planning gate is not ready for execution');
153
+ if (!terminal.has(node.artifact.meta.status) && (plan && plan.meta.ready_for_execution !== true)) node.reasons.push('Goal planning gate is not ready for execution');
150
154
  if (node.artifact.meta.plan_contract_version !== 1) node.reasons.push('Ticket requires current plan_contract_version: 1');
151
155
  }
152
156
  const goalDigest = stableGoalContract(plan,entry);
@@ -157,7 +161,7 @@ export function analyzeMap({ mapPath, repoRoot, methodRoot, stateRoot, context:
157
161
  const contractDigests = Object.fromEntries([...nodes].map(([id,node]) => [id,contractHash(node)]));
158
162
  const invalidated = new Set();
159
163
  if (previous !== null) {
160
- if (previous.schema_version !== 1 || !previous.contract_digests || typeof previous.contract_digests !== 'object' || Array.isArray(previous.contract_digests) || previous.scope !== basename(root)) errors.push('previous checkpoint schema/scope does not match this Goal');
164
+ if (previous.schema_version !== 2 || !previous.contract_digests || typeof previous.contract_digests !== 'object' || Array.isArray(previous.contract_digests) || previous.scope !== basename(root)) errors.push('previous checkpoint schema/scope does not match this Goal');
161
165
  else {
162
166
  for (const [id,value] of Object.entries(contractDigests)) if (previous.contract_digests[id] !== value) invalidated.add(id);
163
167
  for (const id of Object.keys(previous.contract_digests)) if (!nodes.has(id)) warnings.push(`previous node removed; check coverage before accepting replan: ${id}`);
@@ -183,7 +187,7 @@ export function analyzeMap({ mapPath, repoRoot, methodRoot, stateRoot, context:
183
187
  if (!prerequisite || prerequisite.artifact.meta.status !== 'done' || prerequisite.reasons.length) node.reasons.push(`dependency not successfully satisfied: ${dependency}`);
184
188
  }
185
189
  for (const other of active) {
186
- const issue = strategy === 'current' ? 'current workspace already has an implementation writer' : conflicts(node,other,serializations);
190
+ const issue = conflicts(node,other,serializations);
187
191
  if (issue) node.reasons.push(`in-flight owner ${other.artifact.meta.owner ?? 'unknown'} (${other.id}): ${issue}`);
188
192
  }
189
193
  if (!node.reasons.length) candidates.push(node);
@@ -206,15 +210,16 @@ export function analyzeMap({ mapPath, repoRoot, methodRoot, stateRoot, context:
206
210
  for (const node of candidates.sort((a,b) => a.id.localeCompare(b.id))) {
207
211
  if (node.reasons.length) continue;
208
212
  const conflict = frontier.map(id => conflicts(node,nodes.get(id),serializations)).find(Boolean);
209
- if (conflict || frontier.length >= Math.max(0,limit-active.length)) { deferred.push({id:node.id, reason:conflict ?? 'workspace/config concurrency limit'});continue; }
213
+ const memberActive = active.filter(other=>other.member === node.member).length + frontier.filter(id=>nodes.get(id).member === node.member).length;
214
+ if (conflict || limit !== null && frontier.length >= Math.max(0,limit-active.length) || node.policy.max_parallel_tickets !== null && memberActive >= node.policy.max_parallel_tickets) { deferred.push({id:node.id, reason:conflict ?? 'Ticket scheduling concurrency limit'});continue; }
210
215
  frontier.push(node.id);
211
216
  }
212
217
  const blocked = [...nodes.values()].filter(node => node.reasons.length).map(node => ({id:node.id,reasons:[...new Set(node.reasons)]}));
213
218
  const nodeErrors = [...nodes.values()].flatMap(node => node.validationErrors.map(message => ({id:node.id,message})));
214
219
  const result = {
215
- schema_version:1, scope:basename(root), mode:'read-only-analysis',
220
+ schema_version:2, scope:basename(root), mode:'read-only-analysis',
216
221
  authorization_checked:false, transaction_gateway_checked:false,
217
- workspace_policy:strategy, implementation_limit:limit,
222
+ ticket_execution:policy.ticket_execution, max_parallel_tickets:limit,
218
223
  input_digest:digest(JSON.stringify({entry:entry.meta,contracts:contractDigests})),
219
224
  goal_contract_digest:goalDigest, contract_digests:contractDigests, frontier:errors.length ? [] : frontier,
220
225
  deferred, in_flight:active.map(node => ({id:node.id,owner:node.artifact.meta.owner})),
@@ -226,7 +231,7 @@ export function analyzeMap({ mapPath, repoRoot, methodRoot, stateRoot, context:
226
231
  return result;
227
232
  }
228
233
  function failure(errors) {
229
- return {schema_version:1,mode:'read-only-analysis',frontier:[],errors,validation_errors:[],eligible_for_final_verification:false};
234
+ return {schema_version:2,mode:'read-only-analysis',frontier:[],errors,validation_errors:[],eligible_for_final_verification:false};
230
235
  }
231
236
  function main(args) {
232
237
  const options = {};