@namewta/speculo 1.0.2 → 1.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 (83) hide show
  1. package/README.md +8 -3
  2. package/package.json +2 -2
  3. package/template/AGENTS.md +3 -1
  4. package/template/canonical/canonical-specdev-goal-plan.md +757 -225
  5. package/template/canonical/canonical-specdev-grill-with-docs.md +221 -133
  6. package/template/canonical/canonical-specdev-spec.md +73 -3
  7. package/template/canonical/canonical-specdev-tickets.md +681 -252
  8. package/template/canonical/canonical-specdev-wayfinder.md +330 -113
  9. package/template/commands/archive-and-consolidate.md +39 -3
  10. package/template/commands/git-history-squash.md +76 -0
  11. package/template/commands/git-repository-audit.md +3 -602
  12. package/template/commands/references/git-repository-audit-procedure.md +608 -0
  13. package/template/skills/archive-and-consolidate/SKILL.md +1 -1
  14. package/template/skills/archive-and-consolidate/references/entry-procedure.md +11 -3
  15. package/template/skills/git-history-squash/SKILL.md +2 -0
  16. package/template/skills/git-history-squash/references/entry-procedure.md +1 -1
  17. package/template/skills/writing-great-skills/SKILL.md +2 -0
  18. package/template/skills/writing-great-skills/references/document-contract.md +23 -0
  19. package/template/workflows/learning/common/rules/activation-and-memory.md +7 -3
  20. package/template/workflows/ops/common/rules/activation-and-memory.md +7 -3
  21. package/template/workflows/person/common/rules/activation-and-memory.md +7 -3
  22. package/template/workflows/specdev/G-grill-with-docs/G-grill-with-docs.md +13 -136
  23. package/template/workflows/specdev/G-grill-with-docs/references/interview-procedure.md +134 -0
  24. package/template/workflows/specdev/I-implement/I-implement.md +15 -189
  25. package/template/workflows/specdev/I-implement/evidence-template.md +12 -0
  26. package/template/workflows/specdev/I-implement/execution-preflight.md +1 -1
  27. package/template/workflows/specdev/I-implement/references/implementation-procedure.md +192 -0
  28. package/template/workflows/specdev/P-goal-plan/P-goal-plan.md +28 -143
  29. package/template/workflows/specdev/P-goal-plan/completion-control.md +1 -1
  30. package/template/workflows/specdev/P-goal-plan/references/goal-lifecycle.md +35 -0
  31. package/template/workflows/specdev/P-goal-plan/references/goal-tickets-map-template.md +15 -0
  32. package/template/workflows/specdev/P-goal-plan/references/map-control.md +28 -0
  33. package/template/workflows/specdev/{O-orchestrate-implementation/O-orchestrate-implementation.md → P-goal-plan/references/multi-change-plan.md} +21 -33
  34. package/template/workflows/specdev/P-goal-plan/references/replan-and-recovery.md +21 -0
  35. package/template/workflows/specdev/P-goal-plan/references/single-change-plan.md +149 -0
  36. package/template/workflows/specdev/R-review-architecture/R-review-architecture.md +48 -53
  37. package/template/workflows/specdev/R-review-architecture/architecture-review-template.md +19 -10
  38. package/template/workflows/specdev/R-review-architecture/proposal-to-ticket.md +3 -1
  39. package/template/workflows/specdev/R-review-architecture/review-rubric.md +52 -0
  40. package/template/workflows/specdev/README.md +36 -216
  41. package/template/workflows/specdev/T-tickets/T-tickets.md +19 -230
  42. package/template/workflows/specdev/T-tickets/references/planning-procedure.md +233 -0
  43. package/template/workflows/specdev/T-tickets/ticket-template.md +16 -0
  44. package/template/workflows/specdev/T-tickets/tickets-map-template.md +14 -0
  45. package/template/workflows/specdev/T-triage/T-triage.md +3 -1
  46. package/template/workflows/specdev/W-wayfinder/W-wayfinder.md +24 -118
  47. package/template/workflows/specdev/W-wayfinder/references/initiative-discovery.md +29 -0
  48. package/template/workflows/specdev/W-wayfinder/references/initiative-template.json +8 -0
  49. package/template/workflows/specdev/W-wayfinder/references/map-traversal.md +120 -0
  50. package/template/workflows/specdev/W-wayfinder/wayfinder-map-template.md +4 -0
  51. package/template/workflows/specdev/common/README.md +1 -1
  52. package/template/workflows/specdev/common/rules/activation-and-memory.md +7 -3
  53. package/template/workflows/specdev/common/rules/artifact-contract.md +10 -2
  54. package/template/workflows/specdev/common/rules/operating-governance.md +38 -0
  55. package/template/workflows/specdev/common/rules/parent-implementation-orchestration.md +6 -2
  56. package/template/workflows/specdev/common/rules/skill-invocation.md +27 -0
  57. package/template/workflows/specdev/common/rules/workflow-routing.md +24 -0
  58. package/template/workflows/specdev/common/rules/workflow-state-and-lifecycle.md +93 -0
  59. package/template/workflows/specdev/common/schemas/goal-tickets-map.schema.json +33 -0
  60. package/template/workflows/specdev/common/schemas/initiative.schema.json +94 -0
  61. package/template/workflows/specdev/common/schemas/ticket.schema.json +168 -1
  62. package/template/workflows/specdev/common/schemas/tickets-map.schema.json +74 -6
  63. package/template/workflows/specdev/common/skills/code-review/SKILL.md +3 -2
  64. package/template/workflows/specdev/common/skills/code-review/references/risk-review.md +25 -0
  65. package/template/workflows/specdev/common/skills/plan-quality-review/SKILL.md +10 -0
  66. package/template/workflows/specdev/common/skills/plan-quality-review/references/checklist.md +13 -0
  67. package/template/workflows/specdev/common/skills/subagent-delivery/SKILL.md +5 -83
  68. package/template/workflows/specdev/common/skills/subagent-delivery/references/dispatch-and-accept.md +87 -0
  69. package/template/workflows/specdev/common/tools/README.md +14 -2
  70. package/template/workflows/specdev/common/tools/plan-contract.mjs +256 -0
  71. package/template/workflows/specdev/common/tools/ticket-control.mjs +251 -0
  72. package/template/workflows/specdev/common/tools/validate-specdev.mjs +58 -40
  73. package/template/workflows/specdev/manifest.json +97 -1
  74. package/template/canonical/canonical-specdev-orchestrate-implementation.md +0 -2839
  75. package/template/workflows/specdev/O-orchestrate-implementation/implementation-evidence-template.md +0 -39
  76. package/template/workflows/specdev/O-orchestrate-implementation/implementation-map-template.md +0 -50
  77. package/template/workflows/specdev/O-orchestrate-implementation/implementation-plan-template.md +0 -61
  78. package/template/workflows/specdev/R-review-architecture/architecture-report-contract.md +0 -123
  79. package/template/workflows/specdev/R-review-architecture/architecture-review-report-template.html +0 -106
  80. /package/template/workflows/specdev/{O-orchestrate-implementation/conflict-and-drift.md → P-goal-plan/references/multi-conflict-and-drift.md} +0 -0
  81. /package/template/workflows/specdev/{O-orchestrate-implementation/execution-loop.md → P-goal-plan/references/multi-execution-loop.md} +0 -0
  82. /package/template/workflows/specdev/{O-orchestrate-implementation/input-readiness.md → P-goal-plan/references/multi-input-readiness.md} +0 -0
  83. /package/template/workflows/specdev/{O-orchestrate-implementation/super-dag.md → P-goal-plan/references/multi-super-dag.md} +0 -0
@@ -0,0 +1,256 @@
1
+ /** Strict, additive Plan contracts. Read-only; no authorization or state writes. */
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
+
6
+ export const CHANGE_ID = /^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
7
+ const DIGEST = /^[a-f0-9]{64}$/;
8
+ const plainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
9
+ const nonempty = value => typeof value === 'string' && value.trim().length > 0;
10
+ const strings = value => Array.isArray(value) && value.every(nonempty);
11
+ const placeholder = value => !nonempty(value) || /^(?:unreviewed|unassigned|\.\.\.|TBD)$/i.test(value.trim()) || /\{\{|<[^>]*(?:真实|标题|填写|说明|目标|摘要)[^>]*>/.test(value);
12
+ export const digest = value => createHash('sha256').update(value).digest('hex');
13
+ export const pathText = value => typeof value === 'string' ? /^<Path>([^<>]+)<\/Path>$/.exec(value)?.[1] ?? null : null;
14
+
15
+ function within(root, target) {
16
+ const rel = relative(root, target);
17
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
18
+ }
19
+
20
+ function projectPathSyntax(taggedPath) {
21
+ const logical = pathText(taggedPath);
22
+ if (!logical || isAbsolute(logical) || /^[A-Za-z]:/.test(logical) || logical.includes('\\') || logical.split('/').some(part => part === '..' || part === '.' || part === '') || /[{}*?]/.test(logical)) throw new Error(`invalid project source Path: ${String(taggedPath)}`);
23
+ if (/(^|\/)(?:node_modules|\.git|\.cache)(\/|$)|(^|\/)plugins\/cache(\/|$)/.test(logical)) throw new Error(`project source is a cache or protected metadata: ${logical}`);
24
+ return logical;
25
+ }
26
+ /** Resolve user-maintained project sources without following links outside the source domain. */
27
+ export function resolveProjectSource(repoRoot, taggedPath) {
28
+ const logical = projectPathSyntax(taggedPath);
29
+ if (!repoRoot) throw new Error('project Skill validation requires --repo');
30
+ const root = realpathSync(resolve(repoRoot));
31
+ const lexical = resolve(root, logical);
32
+ if (!within(root, lexical)) throw new Error(`project source escapes --repo: ${logical}`);
33
+ const real = realpathSync(lexical);
34
+ if (!within(root, real)) throw new Error(`project source symlink escapes --repo: ${logical}`);
35
+ for (const candidate of [logical, relative(root, real).split(sep).join('/')]) {
36
+ if (/(^|\/)(?:node_modules|\.git|\.cache)(\/|$)|(^|\/)plugins\/cache(\/|$)/.test(candidate)) {
37
+ throw new Error(`project source is a cache or protected metadata, not a user-maintained Skill: ${logical}`);
38
+ }
39
+ }
40
+ if (!lstatSync(real).isFile()) throw new Error(`project source is not a file: ${logical}`);
41
+ return real;
42
+ }
43
+
44
+ export function validateBindings(meta, repoRoot) {
45
+ const errors = [];
46
+ if (!Array.isArray(meta.skill_bindings)) return ['skill_bindings must be a JSON array'];
47
+ const active = meta.ready === true || ['in_progress', 'review', 'done'].includes(meta.status);
48
+ if (!nonempty(meta.skill_scan) || (active && placeholder(meta.skill_scan))) errors.push('skill_scan must record actual source discovery and applicability, not unreviewed');
49
+ const seen = new Set();
50
+ const allowed = new Set(['id','path','sha256','phase','operation','inputs','outputs','required','on_failure','condition','references']);
51
+ for (const [index, binding] of meta.skill_bindings.entries()) {
52
+ const label = `skill_bindings[${index}]`;
53
+ if (!plainObject(binding)) { errors.push(`${label} must be an object`); continue; }
54
+ for (const key of Object.keys(binding)) if (!allowed.has(key)) errors.push(`${label}: unknown field ${key}`);
55
+ for (const key of ['id','path','operation']) if (placeholder(binding[key])) errors.push(`${label}.${key} must be explicit`);
56
+ if (!DIGEST.test(binding.sha256 ?? '')) errors.push(`${label}.sha256 must be a SHA-256 digest`);
57
+ if (!['plan','implement','verify'].includes(binding.phase)) errors.push(`${label}.phase is invalid`);
58
+ for (const key of ['inputs','outputs']) if (!strings(binding[key]) || !binding[key].length || binding[key].some(placeholder)) errors.push(`${label}.${key} must be a nonempty explicit string array`);
59
+ if (typeof binding.required !== 'boolean') errors.push(`${label}.required must be boolean`);
60
+ if (!['block-ticket','report-and-continue'].includes(binding.on_failure)) errors.push(`${label}.on_failure is invalid`);
61
+ if (binding.required === true && binding.on_failure !== 'block-ticket') errors.push(`${label}: required Skill failure must block-ticket`);
62
+ if (binding.required === false && placeholder(binding.condition)) errors.push(`${label}: optional Skill requires an explicit condition`);
63
+ const key = `${binding.id}\0${binding.path}\0${binding.phase}\0${binding.operation}`;
64
+ if (seen.has(key)) errors.push(`${label}: duplicate Skill invocation`);
65
+ seen.add(key);
66
+ try { projectPathSyntax(binding.path); } catch (error) { errors.push(`${label}: ${error.message}`); }
67
+ if (!pathText(binding.path)?.endsWith('/SKILL.md')) errors.push(`${label}: entry must end with /SKILL.md`);
68
+ if (meta.status !== "done") try {
69
+ const source = resolveProjectSource(repoRoot, binding.path);
70
+ const bytes = readFileSync(source);
71
+ const text = bytes.toString('utf8');
72
+ const header = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text)?.[1];
73
+ const name = header ? /^name:\s*["']?([^\r\n"']+)["']?\s*$/m.exec(header)?.[1]?.trim() : null;
74
+ if (name !== binding.id) errors.push(`${label}: Skill id does not match entry name (${name ?? 'missing'})`);
75
+ if (digest(bytes) !== binding.sha256) errors.push(`${label}: Skill source digest drift: ${binding.path}`);
76
+ } catch (error) { errors.push(`${label}: ${error.message}`); }
77
+ if (binding.references !== undefined && !Array.isArray(binding.references)) errors.push(`${label}.references must be an array`);
78
+ const references = Array.isArray(binding.references) ? binding.references : [];
79
+ const refsSeen = new Set();
80
+ for (const [refIndex, ref] of references.entries()) {
81
+ const refLabel = `${label}.references[${refIndex}]`;
82
+ if (!plainObject(ref)) { errors.push(`${refLabel} must be an object`); continue; }
83
+ if (Object.keys(ref).some(key => !['path','sha256','when'].includes(key))) errors.push(`${refLabel}: unknown reference field`);
84
+ try { projectPathSyntax(ref.path); } catch (error) { errors.push(`${refLabel}: ${error.message}`); }
85
+ if (placeholder(ref.when) || !DIGEST.test(ref.sha256 ?? '')) errors.push(`${refLabel}: explicit when and sha256 required`);
86
+ if (refsSeen.has(ref.path)) errors.push(`${refLabel}: duplicate reference`);
87
+ refsSeen.add(ref.path);
88
+ if (meta.status !== "done") try {
89
+ if (digest(readFileSync(resolveProjectSource(repoRoot, ref.path))) !== ref.sha256) errors.push(`${refLabel}: reference digest drift`);
90
+ } catch (error) { errors.push(`${refLabel}: ${error.message}`); }
91
+ }
92
+ }
93
+ return errors;
94
+ }
95
+
96
+ export function readEvidenceRecords(text, heading) {
97
+ const start = text.indexOf(`${heading}\n`);
98
+ if (start < 0) throw new Error(`missing '${heading}'`);
99
+ const tail = text.slice(start + heading.length + 1).split(/^## /m)[0];
100
+ const match = /```json\s*\n([\s\S]*?)\n```/.exec(tail);
101
+ if (!match) throw new Error(`${heading} requires a JSON array code block`);
102
+ const value = JSON.parse(match[1]);
103
+ if (!Array.isArray(value)) throw new Error(`${heading} must contain an array`);
104
+ return value;
105
+ }
106
+
107
+ export function validateTicketPlan(artifact, { repoRoot = null, requirePlan = false, checkEvidence = true } = {}) {
108
+ const { meta, body, path } = artifact;
109
+ const errors = [];
110
+ if (meta.plan_contract_version === undefined) {
111
+ if (requirePlan && !['done','cancelled'].includes(meta.status)) errors.push('legacy Ticket must be upgraded to plan_contract_version: 1 before implementation');
112
+ return errors;
113
+ }
114
+ if (meta.plan_contract_version !== 1) errors.push('unsupported plan_contract_version');
115
+ errors.push(...validateBindings(meta, repoRoot));
116
+ 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');
117
+ if (meta.ready === true || ['in_progress','review','done'].includes(meta.status)) {
118
+ for (const heading of ['## 11. SKILL 调用计划', '## 12. 停止、检查点与交付']) if (!body.includes(heading)) errors.push(`ready Plan Ticket missing '${heading}'`);
119
+ }
120
+ if (checkEvidence && meta.status === 'done' && meta.skill_bindings?.some(binding => binding?.required === true)) {
121
+ try {
122
+ const evidencePath = join(dirname(dirname(path)), 'evidence', `${meta.id}.md`);
123
+ if (lstatSync(evidencePath).isSymbolicLink()) throw new Error('Skill evidence must not be a symlink');
124
+ const records = readEvidenceRecords(readFileSync(evidencePath,'utf8').replace(/\r\n/g,'\n'), '## Skill Execution Records');
125
+ for (const binding of meta.skill_bindings.filter(value => value?.required === true)) {
126
+ const matches = records.filter(record => plainObject(record) && record.id === binding.id && record.phase === binding.phase && record.operation === binding.operation);
127
+ if (matches.length !== 1 || matches[0].sha256 !== binding.sha256 || matches[0].status !== 'passed' || !strings(matches[0].evidence) || !matches[0].evidence.length || matches[0].evidence.some(placeholder)) {
128
+ errors.push(`required Skill has no unique passed execution evidence: ${binding.id}/${binding.phase}/${binding.operation}`);
129
+ }
130
+ }
131
+ } catch (error) { errors.push(`Skill execution evidence: ${error.message}`); }
132
+ }
133
+ return errors;
134
+ }
135
+
136
+ export function validatePlanMap(artifact, changeDirectory = null) {
137
+ const { meta, body } = artifact;
138
+ if (meta.plan_contract_version === undefined) return [];
139
+ const errors = [];
140
+ if (meta.plan_contract_version !== 1) errors.push('unsupported plan_contract_version');
141
+ if (!Number.isInteger(meta.plan_revision) || meta.plan_revision < 1) errors.push('plan_revision must be a positive integer');
142
+ if (!nonempty(meta.deliverable_policy) || (meta.status !== 'draft' && placeholder(meta.deliverable_policy))) errors.push('deliverable_policy must record user quantity requirements or their absence');
143
+ if (!body.includes('## 9. 总控与恢复')) errors.push("missing '## 9. 总控与恢复'");
144
+ const deliveries = meta.requested_deliverables;
145
+ if (!Array.isArray(deliveries)) { errors.push('requested_deliverables must be a JSON array'); return errors; }
146
+ const names = new Set();
147
+ for (const item of deliveries) {
148
+ if (!plainObject(item) || placeholder(item.name) || !Number.isInteger(item.count) || item.count < 1 || Object.keys(item).some(key => !['name','count'].includes(key))) {
149
+ errors.push('requested_deliverables entries require an explicit name and positive integer count'); continue;
150
+ }
151
+ if (names.has(item.name)) errors.push(`duplicate requested deliverable: ${item.name}`);
152
+ names.add(item.name);
153
+ }
154
+ if (meta.status === 'completed' && deliveries.length && changeDirectory) {
155
+ try {
156
+ const evidencePath = join(changeDirectory,'evidence','goal-delivery.md');
157
+ if (lstatSync(evidencePath).isSymbolicLink()) throw new Error('delivery evidence must not be a symlink');
158
+ const records = readEvidenceRecords(readFileSync(evidencePath,'utf8').replace(/\r\n/g,'\n'),'## Delivery Records');
159
+ for (const item of deliveries) {
160
+ const matched = records.filter(record => record?.name === item.name);
161
+ const outputs = matched[0]?.outputs;
162
+ if (matched.length !== 1 || !strings(outputs) || new Set(outputs).size !== outputs.length || outputs.length !== item.count) errors.push(`actual delivery quantity must equal requested ${item.name}: ${item.count}`);
163
+ }
164
+ } catch (error) { errors.push(`Goal delivery evidence: ${error.message}`); }
165
+ }
166
+ return errors;
167
+ }
168
+
169
+ export function validateInvocationCoverage(tickets, matrix) {
170
+ const errors = [];
171
+ for (const [id, ticket] of tickets) {
172
+ if (ticket.meta.plan_contract_version !== 1 || ticket.meta.ready !== true) continue;
173
+ const paths = new Set((ticket.meta.skill_bindings ?? []).map(binding => pathText(binding?.path)).filter(Boolean));
174
+ const listed = new Set((matrix ?? []).filter(entry => entry.appliesTo.has('ALL') || entry.appliesTo.has(id)).map(entry => entry.path).filter(Boolean));
175
+ for (const skill of listed) if (!paths.has(skill)) errors.push(`${id}: project Skill in Map has no invocation binding: ${skill}`);
176
+ for (const skill of paths) if (!listed.has(skill)) errors.push(`${id}: invocation binding is absent from Map project Skill routing: ${skill}`);
177
+ }
178
+ return errors;
179
+ }
180
+
181
+ export function validateGoalMap(artifact, directory) {
182
+ const errors = [];
183
+ const allowed = new Set(['schema_version','artifact','change','implementation_map','implementation_plan']);
184
+ if (artifact.meta.schema_version !== 1 || artifact.meta.artifact !== 'goal-tickets-map') errors.push('invalid goal-tickets-map schema');
185
+ if (artifact.meta.change !== basename(directory)) errors.push('goal-tickets-map change does not match directory');
186
+ for (const key of Object.keys(artifact.meta)) if (!allowed.has(key)) errors.push(`goal-tickets-map may not cache state or unknown field: ${key}`);
187
+ for (const [key,file] of [['implementation_map','implementation-map.md'],['implementation_plan','implementation-plan.md']]) {
188
+ const value = pathText(artifact.meta[key]);
189
+ if (value !== `{roots.state}/specdev/changes/${basename(directory)}/${file}`) errors.push(`goal-tickets-map ${key} must target its own parent artifact`);
190
+ if (!existsSync(join(directory,file)) || lstatSync(join(directory,file)).isSymbolicLink() || !lstatSync(join(directory,file)).isFile()) errors.push(`goal-tickets-map missing real ${file}`);
191
+ }
192
+ return errors;
193
+ }
194
+
195
+ export function findGraphCycle(graph) {
196
+ const visiting = new Set(), visited = new Set(), stack = [];
197
+ function visit(id) {
198
+ if (visiting.has(id)) return [...stack.slice(stack.indexOf(id)), id];
199
+ if (visited.has(id)) return null;
200
+ visiting.add(id);stack.push(id);
201
+ for (const parent of graph.get(id) ?? []) { const result = visit(parent); if (result) return result; }
202
+ stack.pop();visiting.delete(id);visited.add(id);return null;
203
+ }
204
+ for (const id of graph.keys()) { const result = visit(id); if (result) return result; }
205
+ return null;
206
+ }
207
+
208
+ export function validateInitiative(directory) {
209
+ const file = join(directory,'initiative.json');
210
+ if (!existsSync(file)) return [];
211
+ const errors = [];
212
+ let data;
213
+ try {
214
+ if (lstatSync(file).isSymbolicLink()) throw new Error('initiative must be a real file');
215
+ data = JSON.parse(readFileSync(file,'utf8'));
216
+ } catch (error) { return [`initiative.json: ${error.message}`]; }
217
+ if (!plainObject(data)) return ['initiative.json must contain an object'];
218
+ const keys = ['schema_version','artifact','change','revision','destination','changes'];
219
+ if (keys.some(key => !(key in data)) || Object.keys(data).some(key => !keys.includes(key))) errors.push('initiative.json: missing or unknown fields');
220
+ if (data.schema_version !== 1 || data.artifact !== 'initiative' || data.change !== basename(directory)) errors.push('initiative.json: schema/artifact/change mismatch');
221
+ if (!Number.isInteger(data.revision) || data.revision < 1 || placeholder(data.destination)) errors.push('initiative.json: positive revision and explicit destination required');
222
+ if (!Array.isArray(data.changes)) return [...errors,'initiative.json: changes must be an array'];
223
+ const graph = new Map(), targets = new Set();
224
+ const fields = ['id','name','background','scope','non_goals','unknowns','depends_on','target'];
225
+ for (const [i,item] of data.changes.entries()) {
226
+ const label = `initiative.json changes[${i}]`;
227
+ if (!plainObject(item)) { errors.push(`${label} must be an object`); continue; }
228
+ if (fields.some(key => !(key in item)) || Object.keys(item).some(key => !fields.includes(key))) errors.push(`${label}: missing or unknown fields`);
229
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(item.id ?? '')) errors.push(`${label}: invalid candidate id`);
230
+ if (graph.has(item.id)) errors.push(`${label}: duplicate candidate id`);
231
+ for (const key of ['name','background','scope']) if (placeholder(item[key])) errors.push(`${label}.${key} must be explicit`);
232
+ for (const key of ['non_goals','unknowns','depends_on']) if (!strings(item[key])) errors.push(`${label}.${key} must be a string array`);
233
+ if (Array.isArray(item.depends_on) && new Set(item.depends_on).size !== item.depends_on.length) errors.push(`${label}: duplicate dependencies`);
234
+ graph.set(item.id, Array.isArray(item.depends_on) ? item.depends_on : []);
235
+ if (item.target !== null) {
236
+ if (!CHANGE_ID.test(item.target ?? '') || item.target === basename(directory)) { errors.push(`${label}: invalid/self target change`); continue; }
237
+ if (targets.has(item.target)) errors.push(`${label}: duplicate target change`);
238
+ targets.add(item.target);
239
+ const target = join(dirname(directory),item.target);
240
+ if (!existsSync(target) || lstatSync(target).isSymbolicLink() || !lstatSync(target).isDirectory()) errors.push(`${label}: target is missing or not a real change directory`);
241
+ else if (existsSync(join(target,'implementation-map.md'))) errors.push(`${label}: target must not be a parent implementation change`);
242
+ else {
243
+ try {
244
+ const statusFile = join(target,'.status.json');
245
+ if (lstatSync(statusFile).isSymbolicLink() || !lstatSync(statusFile).isFile()) throw new Error('status must be a real file');
246
+ const status = JSON.parse(readFileSync(statusFile,'utf8'));
247
+ if (status.schema_version !== 6 || status.artifact !== 'change-status' || status.change !== item.target) throw new Error('status identity does not match target change');
248
+ } catch (error) { errors.push(`${label}: target has no valid owned change status: ${error.message}`); }
249
+ }
250
+ }
251
+ }
252
+ for (const [id,dependencies] of graph) for (const dependency of dependencies) if (!graph.has(dependency)) errors.push(`initiative.json: ${id} references missing ${dependency}`);
253
+ const cycle = findGraphCycle(graph);
254
+ if (cycle) errors.push(`initiative.json: candidate dependency cycle: ${cycle.join(' -> ')}`);
255
+ return errors;
256
+ }
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env node
2
+ /** Read-only Goal dispatcher analysis. Never executes tools, mutates state, or grants permission. */
3
+ import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
4
+ import { basename, dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { parseFrontmatter, validateChange, pathsOverlap } from './validate-specdev.mjs';
7
+ import { CHANGE_ID, digest, findGraphCycle, resolveProjectSource, validateGoalMap } from './plan-contract.mjs';
8
+
9
+ const terminal = new Set(['done','cancelled']);
10
+ const inFlight = new Set(['in_progress','review']);
11
+ function readArtifact(file) {
12
+ if (!existsSync(file) || lstatSync(file).isSymbolicLink() || !lstatSync(file).isFile()) throw new Error(`missing or noncanonical artifact: ${file}`);
13
+ return { path: file, ...parseFrontmatter(file) };
14
+ }
15
+ function readStatus(root) {
16
+ const file = join(root,'.status.json');
17
+ if (!existsSync(file) || lstatSync(file).isSymbolicLink()) throw new Error(`missing real change status: ${file}`);
18
+ return JSON.parse(readFileSync(file,'utf8'));
19
+ }
20
+ function findConfig(root) {
21
+ let here = resolve(root);
22
+ while (true) {
23
+ const candidate = join(here,'.speculo','specdev','config.json');
24
+ if (existsSync(candidate)) return JSON.parse(readFileSync(candidate,'utf8'));
25
+ const parent = dirname(here);if (parent === here) return null;here = parent;
26
+ }
27
+ }
28
+ function resourceOverlap(left, right) {
29
+ if (left === right) return true;
30
+ const prefix = value => value.endsWith('*') ? value.slice(0,-1) : null;
31
+ return (prefix(left) !== null && right.startsWith(prefix(left))) || (prefix(right) !== null && left.startsWith(prefix(right)));
32
+ }
33
+ function conflicts(a, b, serializations) {
34
+ if (serializations.has([a.id,b.id].sort().join('\0'))) return 'explicit serialization';
35
+ for (const left of a.artifact.meta.writable_paths ?? []) for (const right of b.artifact.meta.writable_paths ?? []) if (pathsOverlap(left,right)) return `writable overlap: ${left} / ${right}`;
36
+ for (const left of a.artifact.meta.resource_claims ?? []) for (const right of b.artifact.meta.resource_claims ?? []) if (resourceOverlap(left,right)) return `semantic resource: ${left} / ${right}`;
37
+ return null;
38
+ }
39
+ function stableGoalContract(plan, map) {
40
+ const meta = { ...(plan?.meta ?? {}) };
41
+ for (const key of ['status','ready_for_execution','source_map_revision']) delete meta[key];
42
+ const body = (plan?.body ?? '').split(/(?=^## )/m)
43
+ .filter(section => !/^## (?:\d+\. )?(?:Progress and Decisions|Ready Frontier and Waves|进度与决定)/.test(section)).join('');
44
+ return digest(JSON.stringify({meta,body,deliverables:map.meta.requested_deliverables ?? [],policy:map.meta.deliverable_policy ?? null}));
45
+ }
46
+ function contractHash(node) {
47
+ const meta = { ...node.artifact.meta };
48
+ for (const key of ['status','ready','owner']) delete meta[key];
49
+ const sources = [];
50
+ {
51
+ for (const binding of node.artifact.meta.skill_bindings ?? []) {
52
+ for (const item of [binding, ...(binding.references ?? [])]) {
53
+ try { sources.push([item.path,terminal.has(node.artifact.meta.status) ? item.sha256 : digest(readFileSync(resolveProjectSource(node.repoRoot,item.path)))]); }
54
+ catch { sources.push([item?.path ?? null,'unavailable']); }
55
+ }
56
+ }
57
+ }
58
+ return digest(JSON.stringify({ meta, body: node.artifact.body, spec: node.specDigest, goal: node.goalDigest, dependencies: node.dependencies, serializations: node.serializations, sources }));
59
+ }
60
+
61
+ export function analyzeMap({ mapPath, repoRoot, previous = null }) {
62
+ if (!mapPath || !repoRoot) throw new Error('--map and --repo are required');
63
+ mapPath = resolve(mapPath);repoRoot = resolve(repoRoot);
64
+ const root = dirname(mapPath);
65
+ let entry = readArtifact(mapPath);
66
+ const errors = [], warnings = [], nodes = new Map(), memberRoots = new Map(), serializations = new Set();
67
+ let parent = false;
68
+ if (entry.meta.artifact === 'goal-tickets-map') {
69
+ errors.push(...validateGoalMap(entry,root));
70
+ if (errors.length) return failure(errors);
71
+ entry = readArtifact(join(root,'implementation-map.md'));
72
+ }
73
+ if (entry.meta.artifact === 'implementation-map') parent = true;
74
+ else if (entry.meta.artifact !== 'tickets-map') throw new Error('entry must be tickets-map, goal-tickets-map, or implementation-map');
75
+ const members = parent ? entry.meta.members : [basename(root)];
76
+ 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']);
77
+ const planFile = join(root,parent ? 'implementation-plan.md' : 'goal-plan.md');
78
+ const plan = existsSync(planFile) ? readArtifact(planFile) : null;
79
+ const strategy = plan?.meta.ticket_workspace_policy ?? 'current';
80
+ let limit = strategy === 'current' ? 1 : plan?.meta.implementation_agent_limit;
81
+ if (!['current','required'].includes(strategy) || !Number.isInteger(limit) || limit < 1) errors.push('invalid workspace strategy or implementation limit');
82
+ const config = findConfig(root);
83
+ if (plan && (!config || !Number.isInteger(config.execution?.max_implementation_agents) || plan.meta.implementation_agent_limit > config.execution.max_implementation_agents)) errors.push('plan implementation limit is not backed by existing config');
84
+
85
+ for (const member of members) {
86
+ const memberRoot = parent ? join(dirname(root),member) : root;
87
+ if (memberRoot === root && parent) { errors.push('parent cannot include itself'); continue; }
88
+ if (!existsSync(memberRoot) || lstatSync(memberRoot).isSymbolicLink() || !lstatSync(memberRoot).isDirectory()) { errors.push(`member must be a real change directory: ${member}`); continue; }
89
+ memberRoots.set(member,memberRoot);
90
+ const status = readStatus(memberRoot);
91
+ const specFile = join(memberRoot,'spec.md');
92
+ const spec = readArtifact(specFile);
93
+ const specDigest = digest(readFileSync(specFile));
94
+ const ticketRoot = join(memberRoot,'ticket');
95
+ if (!existsSync(ticketRoot) || lstatSync(ticketRoot).isSymbolicLink() || !lstatSync(ticketRoot).isDirectory()) { errors.push(`missing real Ticket directory: ${member}`); continue; }
96
+ for (const file of readdirSync(ticketRoot).filter(name => name.endsWith('.md')).sort()) {
97
+ const artifact = readArtifact(join(ticketRoot,file));
98
+ if (!/^T-\d{2,}$/.test(artifact.meta.id ?? '') || artifact.meta.change !== member) { errors.push(`invalid Ticket identity: ${member}/${file}`); continue; }
99
+ const id = parent ? `${member}::${artifact.meta.id}` : artifact.meta.id;
100
+ if (nodes.has(id)) { errors.push(`duplicate Ticket ${id}`); continue; }
101
+ if (!Array.isArray(artifact.meta.blocked_by) || artifact.meta.blocked_by.some(value => !/^T-\d{2,}$/.test(value))) { errors.push(`invalid dependencies: ${id}`); continue; }
102
+ nodes.set(id, { id, member, repoRoot, artifact, specDigest, status, dependencies: artifact.meta.blocked_by.map(value => parent ? `${member}::${value}` : value), reasons: [], validationErrors: [] });
103
+ if (spec.meta.ready_for_tickets !== true) nodes.get(id).reasons.push('source Spec is not Ready');
104
+ }
105
+ }
106
+ if (!nodes.size) errors.push('map has no Tickets');
107
+ if (parent) {
108
+ if (!Array.isArray(entry.meta.tasks) || entry.meta.tasks.length !== nodes.size || new Set(entry.meta.tasks).size !== nodes.size || entry.meta.tasks.some(id => !nodes.has(id))) errors.push('parent inventory must exactly match child Tickets');
109
+ if (!Array.isArray(entry.meta.dependencies) || !Array.isArray(entry.meta.serializations)) errors.push('parent dependencies and serializations must be arrays');
110
+ for (const edge of entry.meta.dependencies ?? []) {
111
+ const parts = /^([^ ]+) <- ([^ ]+)$/.exec(String(edge));
112
+ if (!parts || !nodes.has(parts[1]) || !nodes.has(parts[2])) { errors.push(`invalid composite dependency: ${edge}`); continue; }
113
+ if (!nodes.get(parts[1]).dependencies.includes(parts[2])) nodes.get(parts[1]).dependencies.push(parts[2]);
114
+ }
115
+ for (const edge of entry.meta.serializations ?? []) {
116
+ const parts = /^([^ ]+) <> ([^ ]+)$/.exec(String(edge));
117
+ if (!parts || !nodes.has(parts[1]) || !nodes.has(parts[2]) || parts[1] === parts[2]) { errors.push(`invalid serialization: ${edge}`); continue; }
118
+ serializations.add([parts[1],parts[2]].sort().join('\0'));
119
+ }
120
+ }
121
+ const graph = new Map([...nodes].map(([id,node]) => [id,node.dependencies]));
122
+ for (const [id,dependencies] of graph) for (const dependency of dependencies) if (!nodes.has(dependency)) errors.push(`${id}: missing dependency ${dependency}`);
123
+ const cycle = findGraphCycle(graph);if (cycle) errors.push(`dependency cycle: ${cycle.join(' -> ')}`);
124
+
125
+ // Reuse existing stage gates. Local diagnostics block their owner, structural ones block dispatch.
126
+ const validation = validateChange(root,parent ? 'goal-plan' : 'tickets',repoRoot);
127
+ warnings.push(...validation.warnings);
128
+ for (const error of validation.errors) {
129
+ let assigned = false;
130
+ for (const node of nodes.values()) {
131
+ const prefix = parent ? `${node.member}: ` : '';
132
+ const detail = prefix && error.startsWith(prefix) ? error.slice(prefix.length) : !parent ? error : null;
133
+ if (detail !== null && (detail.startsWith(`${basename(node.artifact.path)}:`) || detail.startsWith(`${node.artifact.meta.id}:`))) {
134
+ node.validationErrors.push(error);assigned = true;
135
+ }
136
+ }
137
+ if (!assigned && parent) {
138
+ for (const member of members) if (error.startsWith(`${member}: `)) {
139
+ for (const node of nodes.values()) if (node.member === member) node.validationErrors.push(error);
140
+ assigned = true;
141
+ }
142
+ if (error.startsWith('member changes already belong to unfinished parent implementation')) {
143
+ const bracket = error.indexOf('[');
144
+ try {
145
+ const overlapping = JSON.parse(error.slice(bracket));
146
+ for (const node of nodes.values()) if (overlapping.includes(node.member)) node.validationErrors.push(error);
147
+ assigned = true;
148
+ } catch { /* keep an unparseable ownership problem global */ }
149
+ }
150
+ }
151
+ if (!assigned) errors.push(error);
152
+ }
153
+ for (const node of nodes.values()) {
154
+ node.reasons.push(...node.validationErrors);
155
+ 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');
156
+ if (!terminal.has(node.artifact.meta.status) && node.artifact.meta.plan_contract_version !== 1) node.reasons.push('legacy Ticket needs explicit Plan-contract upgrade before dispatch');
157
+ }
158
+ const goalDigest = stableGoalContract(plan,entry);
159
+ for (const node of nodes.values()) {
160
+ node.goalDigest = goalDigest;
161
+ node.serializations = [...serializations].filter(pair => pair.split('\0').includes(node.id)).sort();
162
+ }
163
+ const contractDigests = Object.fromEntries([...nodes].map(([id,node]) => [id,contractHash(node)]));
164
+ const invalidated = new Set();
165
+ if (previous !== null) {
166
+ 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');
167
+ else {
168
+ for (const [id,value] of Object.entries(contractDigests)) if (previous.contract_digests[id] !== value) invalidated.add(id);
169
+ for (const id of Object.keys(previous.contract_digests)) if (!nodes.has(id)) warnings.push(`previous node removed; check coverage before accepting replan: ${id}`);
170
+ }
171
+ let changed = true;
172
+ while (changed) {
173
+ changed = false;
174
+ for (const [id,node] of nodes) if (!invalidated.has(id) && node.dependencies.some(dependency => invalidated.has(dependency))) { invalidated.add(id);changed = true; }
175
+ }
176
+ for (const id of invalidated) nodes.get(id).reasons.push('contract drift: replan this node and affected downstream before dispatch');
177
+ }
178
+ const active = [...nodes.values()].filter(node => inFlight.has(node.artifact.meta.status));
179
+ const done = [], cancelled = [], candidates = [], deferred = [];
180
+ for (const node of nodes.values()) {
181
+ const meta = node.artifact.meta;
182
+ if (meta.status === 'done') { done.push(node.id);continue; }
183
+ if (meta.status === 'cancelled') { cancelled.push(node.id);continue; }
184
+ if (inFlight.has(meta.status)) continue;
185
+ if (meta.status !== 'ready' || meta.ready !== true) node.reasons.push(`not Ready: ${meta.status}`);
186
+ if (meta.owner === 'unassigned' || !meta.owner) node.reasons.push('implementation owner not assigned');
187
+ for (const dependency of node.dependencies) {
188
+ const prerequisite = nodes.get(dependency);
189
+ if (!prerequisite || prerequisite.artifact.meta.status !== 'done' || prerequisite.reasons.length) node.reasons.push(`dependency not successfully satisfied: ${dependency}`);
190
+ }
191
+ for (const other of active) {
192
+ const issue = strategy === 'current' ? 'current workspace already has an implementation writer' : conflicts(node,other,serializations);
193
+ if (issue) node.reasons.push(`in-flight owner ${other.artifact.meta.owner ?? 'unknown'} (${other.id}): ${issue}`);
194
+ }
195
+ if (!node.reasons.length) candidates.push(node);
196
+ }
197
+ // Propagate blockers even when a downstream node sorts before its prerequisite.
198
+ let propagated = true;
199
+ while (propagated) {
200
+ propagated = false;
201
+ for (const node of nodes.values()) {
202
+ if (terminal.has(node.artifact.meta.status)) continue;
203
+ for (const dependency of node.dependencies) {
204
+ if (nodes.get(dependency)?.reasons.length) {
205
+ const reason = `blocked dependency closure: ${dependency}`;
206
+ if (!node.reasons.includes(reason)) { node.reasons.push(reason);propagated = true; }
207
+ }
208
+ }
209
+ }
210
+ }
211
+ const frontier = [];
212
+ for (const node of candidates.sort((a,b) => a.id.localeCompare(b.id))) {
213
+ if (node.reasons.length) continue;
214
+ const conflict = frontier.map(id => conflicts(node,nodes.get(id),serializations)).find(Boolean);
215
+ if (conflict || frontier.length >= Math.max(0,limit-active.length)) { deferred.push({id:node.id, reason:conflict ?? 'workspace/config concurrency limit'});continue; }
216
+ frontier.push(node.id);
217
+ }
218
+ const blocked = [...nodes.values()].filter(node => node.reasons.length).map(node => ({id:node.id,reasons:[...new Set(node.reasons)]}));
219
+ const nodeErrors = [...nodes.values()].flatMap(node => node.validationErrors.map(message => ({id:node.id,message})));
220
+ const result = {
221
+ schema_version:1, scope:basename(root), mode:'read-only-analysis',
222
+ authorization_checked:false, transaction_gateway_checked:false,
223
+ workspace_policy:strategy, implementation_limit:limit,
224
+ input_digest:digest(JSON.stringify({entry:entry.meta,contracts:contractDigests})),
225
+ goal_contract_digest:goalDigest, contract_digests:contractDigests, frontier:errors.length ? [] : frontier,
226
+ deferred, in_flight:active.map(node => ({id:node.id,owner:node.artifact.meta.owner})),
227
+ done, cancelled, blocked, invalidated:[...invalidated].sort(),
228
+ eligible_for_final_verification:errors.length === 0 && blocked.length === 0 && active.length === 0 && done.length + cancelled.length === nodes.size,
229
+ validation_errors:nodeErrors, errors:[...new Set(errors)], warnings,
230
+ next: 'Lead must verify real authorization, existing transaction/ownership gateways, Skill execution and Goal acceptance; this output does not execute anything.'
231
+ };
232
+ return result;
233
+ }
234
+ function failure(errors) {
235
+ return {schema_version:1,mode:'read-only-analysis',frontier:[],errors,validation_errors:[],eligible_for_final_verification:false};
236
+ }
237
+ function main(args) {
238
+ const options = {};
239
+ for (let i=0;i<args.length;i++) {
240
+ const key = args[i];
241
+ if (!['--map','--repo','--previous'].includes(key) || !args[i+1] || args[i+1].startsWith('--') || key in options) throw new Error('Usage: ticket-control.mjs --map <map-file> --repo <project-root> [--previous <checkpoint-json>]');
242
+ options[key]=args[++i];
243
+ }
244
+ const result = analyzeMap({mapPath:options['--map'],repoRoot:options['--repo'],previous:options['--previous'] ? JSON.parse(readFileSync(options['--previous'],'utf8')) : null});
245
+ console.log(JSON.stringify(result,null,2));
246
+ return result.errors.length || result.validation_errors.length ? 1 : 0;
247
+ }
248
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
249
+ try { process.exitCode=main(process.argv.slice(2)); }
250
+ catch (error) { console.error(JSON.stringify({error:error.message,mode:'read-only-analysis'}));process.exitCode=1; }
251
+ }