@vegastack/vegafactory 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +417 -0
  3. package/dist/dashboard-artifact.json +21240 -0
  4. package/dist/index.js +22752 -0
  5. package/dist/run-wrapper.js +377 -0
  6. package/package.json +46 -0
  7. package/skill/dev-architect/SKILL.md +106 -0
  8. package/skill/dev-architect/agents/openai.yaml +4 -0
  9. package/skill/dev-architect/references/ai-agents.md +96 -0
  10. package/skill/dev-architect/references/conventions.md +113 -0
  11. package/skill/dev-architect/references/data.md +89 -0
  12. package/skill/dev-architect/references/infra.md +99 -0
  13. package/skill/dev-architect/references/mobile.md +75 -0
  14. package/skill/dev-architect/references/pinned-facts.md +133 -0
  15. package/skill/dev-architect/references/principles.md +117 -0
  16. package/skill/dev-architect/references/security.md +90 -0
  17. package/skill/dev-architect/references/stack.md +38 -0
  18. package/skill/dev-architect/references/web.md +102 -0
  19. package/skill/dev-architect/refresh/REFRESH.md +31 -0
  20. package/skill/dev-architect/refresh/sources.json +239 -0
  21. package/skill/dev-chronicle/SKILL.md +52 -0
  22. package/skill/dev-chronicle/agents/openai.yaml +4 -0
  23. package/skill/dev-chronicle/references/conventions.md +113 -0
  24. package/skill/dev-chronicle/references/styles.md +75 -0
  25. package/skill/dev-chronicle/refresh/REFRESH.md +3 -0
  26. package/skill/dev-chronicle/refresh/sources.json +6 -0
  27. package/skill/dev-debug/SKILL.md +43 -0
  28. package/skill/dev-debug/agents/openai.yaml +4 -0
  29. package/skill/dev-debug/references/conventions.md +113 -0
  30. package/skill/dev-debug/references/loop-ladder.md +20 -0
  31. package/skill/dev-debug/refresh/REFRESH.md +3 -0
  32. package/skill/dev-debug/refresh/sources.json +6 -0
  33. package/skill/dev-implement/SKILL.md +81 -0
  34. package/skill/dev-implement/agents/openai.yaml +4 -0
  35. package/skill/dev-implement/assets/workflows/implement-children.js +12 -0
  36. package/skill/dev-implement/references/ask-route.md +80 -0
  37. package/skill/dev-implement/references/changelog-and-chronicle.md +21 -0
  38. package/skill/dev-implement/references/conventions.md +113 -0
  39. package/skill/dev-implement/references/ledger-and-resume.md +52 -0
  40. package/skill/dev-implement/references/parallel-children.md +35 -0
  41. package/skill/dev-implement/references/worktrees.md +63 -0
  42. package/skill/dev-implement/refresh/REFRESH.md +3 -0
  43. package/skill/dev-implement/refresh/sources.json +6 -0
  44. package/skill/dev-implement/scripts/children.mjs +479 -0
  45. package/skill/dev-implement/scripts/effective-policy.mjs +694 -0
  46. package/skill/dev-implement/scripts/evidence-check.mjs +107 -0
  47. package/skill/dev-implement/scripts/evidence-upload.mjs +181 -0
  48. package/skill/dev-implement/scripts/learning.mjs +41 -0
  49. package/skill/dev-implement/scripts/lib/approval.mjs +1182 -0
  50. package/skill/dev-implement/scripts/lib/gh.mjs +101 -0
  51. package/skill/dev-implement/scripts/preflight.mjs +153 -0
  52. package/skill/dev-implement/scripts/questions.mjs +381 -0
  53. package/skill/dev-implement/scripts/reclaim.mjs +116 -0
  54. package/skill/dev-implement/scripts/recovery.mjs +213 -0
  55. package/skill/dev-implement/scripts/worktree.mjs +991 -0
  56. package/skill/dev-intake/SKILL.md +80 -0
  57. package/skill/dev-intake/agents/openai.yaml +4 -0
  58. package/skill/dev-intake/references/ask-route.md +80 -0
  59. package/skill/dev-intake/references/brief-template.md +89 -0
  60. package/skill/dev-intake/references/conventions.md +113 -0
  61. package/skill/dev-intake/refresh/REFRESH.md +3 -0
  62. package/skill/dev-intake/refresh/sources.json +6 -0
  63. package/skill/dev-intake/scripts/brief-lint.mjs +87 -0
  64. package/skill/dev-intake/scripts/lib/approval.mjs +1182 -0
  65. package/skill/dev-intake/scripts/questions.mjs +381 -0
  66. package/skill/dev-plan/SKILL.md +54 -0
  67. package/skill/dev-plan/agents/openai.yaml +4 -0
  68. package/skill/dev-plan/references/ask-route.md +80 -0
  69. package/skill/dev-plan/references/conventions.md +113 -0
  70. package/skill/dev-plan/references/plan-format.md +82 -0
  71. package/skill/dev-plan/refresh/REFRESH.md +3 -0
  72. package/skill/dev-plan/refresh/sources.json +6 -0
  73. package/skill/dev-plan/scripts/lib/approval.mjs +1182 -0
  74. package/skill/dev-plan/scripts/plan-lint.mjs +319 -0
  75. package/skill/dev-plan/scripts/questions.mjs +381 -0
  76. package/skill/dev-review/SKILL.md +76 -0
  77. package/skill/dev-review/agents/openai.yaml +4 -0
  78. package/skill/dev-review/assets/review-known-patterns.md.template +32 -0
  79. package/skill/dev-review/references/conventions.md +113 -0
  80. package/skill/dev-review/references/cross-agent.md +39 -0
  81. package/skill/dev-review/references/dispatch-prompts.md +121 -0
  82. package/skill/dev-review/references/security-axis.md +45 -0
  83. package/skill/dev-review/refresh/REFRESH.md +5 -0
  84. package/skill/dev-review/refresh/sources.json +6 -0
  85. package/skill/dev-setup/SKILL.md +113 -0
  86. package/skill/dev-setup/agents/openai.yaml +4 -0
  87. package/skill/dev-setup/assets/agents-section.md.template +24 -0
  88. package/skill/dev-setup/assets/dev-profile.md.template +102 -0
  89. package/skill/dev-setup/assets/factory-board.yml.template +162 -0
  90. package/skill/dev-setup/assets/hooks/decision-nudge.mjs +30 -0
  91. package/skill/dev-setup/assets/hooks/prompt-skill-mention.mjs +29 -0
  92. package/skill/dev-setup/assets/hooks/session-end.mjs +14 -0
  93. package/skill/dev-setup/assets/hooks/session-start.mjs +178 -0
  94. package/skill/dev-setup/assets/hooks/ship-guard.mjs +738 -0
  95. package/skill/dev-setup/assets/hooks/skill-activated.mjs +47 -0
  96. package/skill/dev-setup/assets/hooks/stop-heartbeat.mjs +14 -0
  97. package/skill/dev-setup/assets/workflows/implement-children.js +12 -0
  98. package/skill/dev-setup/references/ask-route.md +80 -0
  99. package/skill/dev-setup/references/conventions.md +113 -0
  100. package/skill/dev-setup/references/github-app.md +286 -0
  101. package/skill/dev-setup/references/harness-facts.md +169 -0
  102. package/skill/dev-setup/references/stack-playbooks.md +124 -0
  103. package/skill/dev-setup/refresh/REFRESH.md +13 -0
  104. package/skill/dev-setup/refresh/sources.json +602 -0
  105. package/skill/dev-setup/scripts/effective-policy.mjs +694 -0
  106. package/skill/dev-setup/scripts/questions.mjs +381 -0
  107. package/skill/dev-setup/scripts/ship-policy.mjs +195 -0
  108. package/skill/dev-ship/SKILL.md +56 -0
  109. package/skill/dev-ship/agents/openai.yaml +4 -0
  110. package/skill/dev-ship/references/conventions.md +113 -0
  111. package/skill/dev-ship/references/runbook.md +72 -0
  112. package/skill/dev-ship/refresh/REFRESH.md +3 -0
  113. package/skill/dev-ship/refresh/sources.json +6 -0
  114. package/skill/dev-ship/scripts/lib/approval.mjs +1182 -0
  115. package/skill/dev-ship/scripts/ship-gate.mjs +485 -0
  116. package/skill/dev-status/SKILL.md +56 -0
  117. package/skill/dev-status/agents/openai.yaml +4 -0
  118. package/skill/dev-status/references/conventions.md +113 -0
  119. package/skill/dev-status/refresh/REFRESH.md +3 -0
  120. package/skill/dev-status/refresh/sources.json +6 -0
  121. package/skill/dev-status/scripts/effective-policy.mjs +694 -0
  122. package/skill/dev-status/scripts/status.mjs +311 -0
  123. package/skill/skill-maintainer/SKILL.md +82 -0
  124. package/skill/skill-maintainer/agents/openai.yaml +4 -0
  125. package/skill/skill-maintainer/references/release-ops.md +61 -0
  126. package/skill/skill-maintainer/references/standards.md +137 -0
  127. package/skill/skill-maintainer/refresh/REFRESH.md +40 -0
  128. package/skill/skill-maintainer/refresh/sources.json +103 -0
  129. package/skill/skill-scan/SKILL.md +59 -0
  130. package/skill/skill-scan/agents/openai.yaml +4 -0
  131. package/skill/skill-scan/refresh/REFRESH.md +18 -0
  132. package/skill/skill-scan/refresh/sources.json +133 -0
  133. package/skill/skill-scan/scripts/lib/skillspector.mjs +226 -0
  134. package/skill/skill-scan/scripts/skill-scan.mjs +1002 -0
  135. package/skill/skillify/SKILL.md +108 -0
  136. package/skill/skillify/agents/openai.yaml +4 -0
  137. package/skill/skillify/assets/templates/README.md.template +26 -0
  138. package/skill/skillify/assets/templates/REFRESH.md.template +16 -0
  139. package/skill/skillify/assets/templates/SKILL.md.template +27 -0
  140. package/skill/skillify/assets/templates/evals.json.template +12 -0
  141. package/skill/skillify/assets/templates/openai.yaml.template +4 -0
  142. package/skill/skillify/assets/templates/skill.test.ts.template +27 -0
  143. package/skill/skillify/assets/templates/sources.json.template +6 -0
  144. package/skill/skillify/assets/templates/trigger-queries.json.template +1 -0
  145. package/skill/skillify/references/authoring.md +103 -0
  146. package/skill/skillify/references/eval-playbook.md +112 -0
  147. package/skill/skillify/refresh/REFRESH.md +23 -0
  148. package/skill/skillify/refresh/sources.json +6 -0
  149. package/skill/skillify/scripts/scaffold-skill.mjs +334 -0
  150. package/skill/skillify/scripts/trigger-check.mjs +209 -0
  151. package/skill/vegafactory-setup/SKILL.md +77 -0
  152. package/skill/vegafactory-setup/agents/openai.yaml +4 -0
  153. package/skill/vegafactory-setup/assets/control-room/boards.md.template +17 -0
  154. package/skill/vegafactory-setup/assets/control-room/decisions.md.template +5 -0
  155. package/skill/vegafactory-setup/assets/control-room/group.md.template +44 -0
  156. package/skill/vegafactory-setup/assets/control-room/onboarding/dispatcher-box.md.template +86 -0
  157. package/skill/vegafactory-setup/assets/control-room/onboarding/new-repo.md.template +13 -0
  158. package/skill/vegafactory-setup/assets/control-room/onboarding/new-teammate.md.template +12 -0
  159. package/skill/vegafactory-setup/assets/control-room/org.md.template +60 -0
  160. package/skill/vegafactory-setup/assets/control-room/people.csv.template +2 -0
  161. package/skill/vegafactory-setup/assets/control-room/repos.md.template +7 -0
  162. package/skill/vegafactory-setup/assets/control-room/rules/CODEOWNERS.template +6 -0
  163. package/skill/vegafactory-setup/assets/control-room/rules/README.md.template +9 -0
  164. package/skill/vegafactory-setup/assets/control-room/rules/stats-privacy.md.template +44 -0
  165. package/skill/vegafactory-setup/assets/control-room/templates/README.md.template +9 -0
  166. package/skill/vegafactory-setup/references/control-room.md +149 -0
  167. package/skill/vegafactory-setup/references/conventions.md +113 -0
  168. package/skill/vegafactory-setup/refresh/REFRESH.md +5 -0
  169. package/skill/vegafactory-setup/refresh/sources.json +6 -0
  170. package/skill-integrity.json +252 -0
@@ -0,0 +1,319 @@
1
+ #!/usr/bin/env node
2
+ // dev-plan guard: deterministic checks on a drafted plan comment. Placeholders
3
+ // and structural gaps block; nothing here warns. The banned-placeholder list's
4
+ // single home is this file — brief-lint defers inline-plan checks to it.
5
+ //
6
+ // Exit codes: 0 pass · 2 blocked (this guard has no warn class).
7
+ // Usage: node plan-lint.mjs --file <plan.md> --json
8
+ import { readFileSync } from 'node:fs';
9
+ import { resolve } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ // Self-contained on purpose: plan-lint ships with dev-plan and must run on a
12
+ // standalone install, so it carries its own tiny flag/result helpers instead of
13
+ // importing dev-implement's lib.
14
+
15
+ export const bannedPlaceholders = [
16
+ /\bTBD\b/,
17
+ /\bTODO\b/,
18
+ /implement later/i,
19
+ /fill in details/i,
20
+ /add appropriate error handling/i,
21
+ /\badd validation\b/i,
22
+ /handle edge cases/i,
23
+ /write tests for the above/i,
24
+ /similar to task \d+/i,
25
+ ];
26
+
27
+ // Independent groups: the optional block that declares which work may run at the
28
+ // same time, and the disjoint file set that bounds each group. The grammar's
29
+ // single home is here — dev-implement reads `--groups` JSON rather than the
30
+ // markdown, so exactly one parser exists in the family.
31
+ const GROUPS_HEADING = '**Independent groups:**';
32
+ const GROUP_LINE = /^- `([^`]+)` — (.*)$/;
33
+
34
+ // Files nearly every change in a repo edits. Two children that both touch one of
35
+ // these are not independent whatever the plan claims, and the join is the worst
36
+ // place to find that out — a lockfile three-way merge most of all. Measured on the
37
+ // 18 Epic B plans: 5 of 153 child pairs were disjoint, and these files are why.
38
+ const NEVER_PARALLEL = [
39
+ 'bun.lock',
40
+ 'package.json',
41
+ 'packages/cli/packaging.json',
42
+ '.vegastack/dev.md',
43
+ '.vegastack/chronicle.md',
44
+ '.vegastack/skillspector-baseline.json',
45
+ ];
46
+
47
+ const FLEET_HEADING = '**Fleet parallel:**';
48
+ const FLEET_RESOURCE = /^[a-z0-9][a-z0-9._:/-]{0,127}$/;
49
+ const TASK_ID = /^[1-9]\d*-T[1-9]\d*$/;
50
+
51
+ function structuralLines(text) {
52
+ let fence = null;
53
+ return String(text).split('\n').map((line, index) => {
54
+ const opening = /^ {0,3}(`{3,}|~{3,})(.*?)$/.exec(line);
55
+ const structural = fence === null && opening === null && !/^(?: {4}|\t)/.test(line);
56
+ if (opening) {
57
+ if (fence === null) fence = opening[1];
58
+ else if (opening[1][0] === fence[0] && opening[1].length >= fence.length && opening[2].trim() === '') fence = null;
59
+ }
60
+ return { line, index, structural };
61
+ });
62
+ }
63
+
64
+ function parseClosedJson(raw) {
65
+ if (typeof raw !== 'string' || Buffer.byteLength(raw, 'utf8') > 8192) throw new Error('fleet declaration exceeds 8 KiB');
66
+ const value = JSON.parse(raw);
67
+ const tokens = raw.match(/"(?:[^"\\]|\\.)*"|[{}\[\],:]|[^\s{}\[\],:]+/g) ?? [];
68
+ let cursor = 0;
69
+ function visit(depth = 0) {
70
+ if (depth > 16) throw new Error('fleet declaration nesting is too deep');
71
+ const token = tokens[cursor++];
72
+ if (token === '{') {
73
+ const seen = new Set();
74
+ while (tokens[cursor] !== '}') {
75
+ const key = JSON.parse(tokens[cursor++]);
76
+ if (seen.has(key) || ['__proto__', 'prototype', 'constructor'].includes(key)) throw new Error('duplicate or unsafe fleet declaration key');
77
+ seen.add(key); cursor++; visit(depth + 1);
78
+ if (tokens[cursor] === ',') cursor++;
79
+ }
80
+ cursor++;
81
+ } else if (token === '[') {
82
+ while (tokens[cursor] !== ']') {
83
+ visit(depth + 1);
84
+ if (tokens[cursor] === ',') cursor++;
85
+ }
86
+ cursor++;
87
+ }
88
+ }
89
+ visit();
90
+ if (cursor !== tokens.length) throw new Error('invalid fleet declaration JSON');
91
+ return value;
92
+ }
93
+
94
+ function literalPlanPath(value) {
95
+ return typeof value === 'string' && value.length > 0 && !value.startsWith('/') && !/[\\\x00-\x1f*?\[\]{}]/.test(value)
96
+ && !/^[a-z]:/i.test(value) && value.split('/').every((part) => part !== '' && part !== '.' && part !== '..');
97
+ }
98
+
99
+ function selectedTaskFiles(text, selectedTaskIds) {
100
+ const tasks = new Map();
101
+ let current = null;
102
+ for (const row of structuralLines(text)) {
103
+ if (!row.structural) continue;
104
+ const header = /^- \[[ xX]\] \*\*Task .*?<!-- task-id:([1-9]\d*-T[1-9]\d*) -->/.exec(row.line);
105
+ if (header) {
106
+ current = header[1];
107
+ if (tasks.has(current)) throw new Error('duplicate structural task identity');
108
+ tasks.set(current, []);
109
+ continue;
110
+ }
111
+ if (/^#{1,6}\s/.test(row.line) || /^- \[[ xX]\] \*\*Task /.test(row.line)) current = null;
112
+ if (current && /^\s*(?:- )?Files —/.test(row.line)) {
113
+ if (row.line.includes('``')) throw new Error('selected task has an empty Files path');
114
+ tasks.get(current).push([...row.line.matchAll(/`([^`]+)`/g)].map((match) => match[1]));
115
+ }
116
+ }
117
+ const files = [];
118
+ for (const id of selectedTaskIds) {
119
+ const clauses = tasks.get(id);
120
+ if (!clauses || clauses.length !== 1 || clauses[0].length === 0) throw new Error('selected task lacks one canonical Files clause');
121
+ files.push(...clauses[0]);
122
+ }
123
+ if (!files.every(literalPlanPath)) throw new Error('selected task has a nonliteral repository path');
124
+ const unique = [...new Set(files)];
125
+ if (unique.length === 0 || unique.some(sharedByEveryChild)) throw new Error('selected task has repository-wide shared or empty file scope');
126
+ return unique;
127
+ }
128
+
129
+ const exclusiveFleet = (reason) => ({ eligible: false, independent: false, taskIds: [], paths: [], resources: [], reason });
130
+
131
+ export function parseFleetParallelDeclaration(text, selectedTaskIds) {
132
+ try {
133
+ if (!Array.isArray(selectedTaskIds) || selectedTaskIds.length === 0 || selectedTaskIds.length > 64
134
+ || !selectedTaskIds.every((id) => TASK_ID.test(id)) || new Set(selectedTaskIds).size !== selectedTaskIds.length) throw new Error('invalid selected task IDs');
135
+ const rows = structuralLines(text);
136
+ const declarations = rows.filter((row) => row.structural && row.line.startsWith(FLEET_HEADING));
137
+ if (declarations.length === 0) return exclusiveFleet('fleet declaration absent');
138
+ if (declarations.length !== 1) throw new Error('duplicate fleet declaration');
139
+ const declaration = declarations[0];
140
+ const constraints = rows.find((row) => row.structural && row.line.startsWith('**Constraints:**'));
141
+ const boundary = rows.find((row) => row.structural && (row.line.startsWith(GROUPS_HEADING) || /^### Tasks\s*$/.test(row.line)));
142
+ if (!constraints || !boundary || declaration.index <= constraints.index || declaration.index >= boundary.index) throw new Error('fleet declaration is outside its structural location');
143
+ const raw = declaration.line.slice(FLEET_HEADING.length).trim();
144
+ const value = parseClosedJson(raw);
145
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('fleet declaration must be an object');
146
+ if (Object.keys(value).sort().join(',') !== 'eligible,resources,schemaVersion,taskIds') throw new Error('unknown or missing fleet declaration field');
147
+ if (value.schemaVersion !== 1 || value.eligible !== true) throw new Error('unsupported fleet declaration');
148
+ if (!Array.isArray(value.taskIds) || value.taskIds.length === 0 || value.taskIds.length > 64
149
+ || !value.taskIds.every((id) => TASK_ID.test(id)) || new Set(value.taskIds).size !== value.taskIds.length) throw new Error('invalid fleet task IDs');
150
+ if (JSON.stringify(value.taskIds) !== JSON.stringify(selectedTaskIds)) throw new Error('fleet task IDs differ from selected tasks');
151
+ if (!Array.isArray(value.resources) || value.resources.length > 64 || !value.resources.every((resource) => FLEET_RESOURCE.test(resource))
152
+ || new Set(value.resources).size !== value.resources.length) throw new Error('invalid fleet resources');
153
+ return { eligible: true, independent: true, taskIds: value.taskIds, paths: selectedTaskFiles(text, value.taskIds), resources: value.resources, reason: null };
154
+ } catch (error) {
155
+ return exclusiveFleet(error instanceof Error ? error.message : 'invalid fleet declaration');
156
+ }
157
+ }
158
+
159
+ export function sharedByEveryChild(path) {
160
+ const normalized = normalizeGroupPath(path);
161
+ return normalized.endsWith('README.md') || NEVER_PARALLEL.includes(normalized);
162
+ }
163
+
164
+ export function normalizeGroupPath(path) {
165
+ return String(path).replace(/^\.\//, '').replace(/\/{2,}/g, '/');
166
+ }
167
+
168
+ export function parseIndependentGroups(text) {
169
+ const lines = String(text).split('\n');
170
+ const start = lines.findIndex((line) => line.trim().startsWith(GROUPS_HEADING));
171
+ if (start === -1) return [];
172
+ const groups = [];
173
+ for (let i = start + 1; i < lines.length; i += 1) {
174
+ const line = lines[i].trim();
175
+ if (!line.startsWith('- ')) break;
176
+ const match = GROUP_LINE.exec(line);
177
+ if (!match) {
178
+ groups.push({ id: null, members: [], files: [], line });
179
+ continue;
180
+ }
181
+ const rest = match[2];
182
+ const cut = rest.indexOf(' · Files:');
183
+ const membersPart = cut === -1 ? rest : rest.slice(0, cut);
184
+ const filesPart = cut === -1 ? '' : rest.slice(cut + ' · Files:'.length);
185
+ const members = membersPart.split(',').map((m) => m.trim()).filter(Boolean);
186
+ const files = (filesPart.match(/`[^`]+`/g) || []).map((f) => normalizeGroupPath(f.slice(1, -1).trim()));
187
+ groups.push({ id: match[1].trim(), members, files, line });
188
+ }
189
+ return groups;
190
+ }
191
+
192
+ export function groupOverlaps(groups) {
193
+ const found = [];
194
+ const named = groups.filter((g) => g.id);
195
+ for (let i = 0; i < named.length; i += 1) {
196
+ for (let j = i + 1; j < named.length; j += 1) {
197
+ for (const a of named[i].files) {
198
+ for (const b of named[j].files) {
199
+ if (a === b) found.push({ a: named[i].id, b: named[j].id, path: a });
200
+ else if (a.endsWith('/') && b.startsWith(a)) found.push({ a: named[i].id, b: named[j].id, path: a });
201
+ else if (b.endsWith('/') && a.startsWith(b)) found.push({ a: named[i].id, b: named[j].id, path: b });
202
+ }
203
+ }
204
+ }
205
+ }
206
+ return found;
207
+ }
208
+
209
+ export function lintPlan(text) {
210
+ const blocks = [];
211
+
212
+ if (!/<!--\s*vsk:v1\s+type=plan\b/.test(text)) blocks.push('missing plan marker (<!-- vsk:v1 type=plan rev=n -->)');
213
+
214
+ for (const pattern of bannedPlaceholders) {
215
+ const hit = pattern.exec(text);
216
+ if (hit) blocks.push(`banned placeholder: "${hit[0]}" — plans carry the actual content`);
217
+ }
218
+
219
+ // A Task-header line not carried by a checkbox would otherwise be absorbed
220
+ // into the previous task's chunk and inherit its sections — detect it.
221
+ // Anchored to the line START so mid-line references ("consumes Task 2's
222
+ // output") never false-block.
223
+ for (const line of text.split('\n')) {
224
+ const t = line.trim();
225
+ if (/^(\*\*|[-*]\s+\*\*)?Task \d+:/.test(t) && !/^- \[[ x]\]/.test(t)) {
226
+ blocks.push(`task line without a checkbox: "${t.slice(0, 60)}"`);
227
+ }
228
+ }
229
+
230
+ const tasks = text.split(/^- \[[ x]\] \*\*Task /m).slice(1);
231
+ if (tasks.length === 0) blocks.push('no checkbox tasks found (- [ ] **Task N: ...**)');
232
+ tasks.forEach((task, index) => {
233
+ const n = index + 1;
234
+ if (!/Files\s*—/.test(task)) blocks.push(`task ${n}: missing "Files —" line with exact paths`);
235
+ if (!/Interfaces\s*—/.test(task)) blocks.push(`task ${n}: missing "Interfaces —" block (consumes/produces)`);
236
+ if (!/Steps[:\s]/.test(task)) blocks.push(`task ${n}: missing "Steps" line`);
237
+ if (/failing test/i.test(task) && !task.includes('```')) {
238
+ blocks.push(`task ${n}: a failing-test step must carry the actual test code in a fenced block`);
239
+ }
240
+ });
241
+
242
+ const groups = parseIndependentGroups(text);
243
+ const seenIds = new Set();
244
+ const seenMembers = new Map();
245
+ for (const group of groups) {
246
+ if (!group.id) {
247
+ blocks.push(`independent group line not in the "- \`id\` — members · Files: \`path\`" shape: "${group.line}"`);
248
+ continue;
249
+ }
250
+ if (seenIds.has(group.id)) blocks.push(`independent group id "${group.id}" appears twice`);
251
+ seenIds.add(group.id);
252
+ if (group.files.length === 0) blocks.push(`independent group "${group.id}": no file set declared`);
253
+ for (const file of group.files.filter(sharedByEveryChild)) {
254
+ blocks.push(`independent group "${group.id}" declares ${file}, which nearly every change edits — these children run in sequence`);
255
+ }
256
+ // A group is one child at a time: two issues in one group would run at once on
257
+ // one file set, the exact collision the disjoint sets rule out.
258
+ const children = group.members.filter((member) => /^#\d+$/.test(member));
259
+ if (children.length > 1) {
260
+ blocks.push(`independent group "${group.id}" names ${children.join(', ')} — they would share one file set, and a parallel group carries one child; give each its own group and a disjoint set, or run them in plan order`);
261
+ }
262
+ for (const member of group.members) {
263
+ if (seenMembers.has(member) && seenMembers.get(member) !== group.id) {
264
+ blocks.push(`independent group member "${member}" appears in more than one group`);
265
+ } else seenMembers.set(member, group.id);
266
+ }
267
+ }
268
+ for (const clash of groupOverlaps(groups)) {
269
+ blocks.push(`independent groups "${clash.a}" and "${clash.b}" overlap on ${clash.path}`);
270
+ }
271
+
272
+ const fleetRows = structuralLines(text).filter((row) => row.structural && row.line.startsWith(FLEET_HEADING));
273
+ if (fleetRows.length > 0) {
274
+ const selected = [...String(text).matchAll(/^- \[[ xX]\] \*\*Task .*?<!-- task-id:([1-9]\d*-T[1-9]\d*) -->/gm)].map((match) => match[1]);
275
+ const fleet = parseFleetParallelDeclaration(text, selected);
276
+ if (!fleet.independent) blocks.push('invalid fleet declaration: ' + fleet.reason);
277
+ }
278
+
279
+ return { blocks, warns: [] };
280
+ }
281
+
282
+ const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
283
+ if (invokedDirectly) {
284
+ const argv = process.argv.slice(2);
285
+ const json = argv.includes('--json');
286
+ const wantGroups = argv.includes('--groups');
287
+ const fileIndex = argv.indexOf('--file');
288
+ let outcome;
289
+ let text = null;
290
+ if (fileIndex === -1 || !argv[fileIndex + 1]) {
291
+ outcome = { blocks: ['usage: plan-lint.mjs --file <plan.md> [--groups] [--json]'], warns: [] };
292
+ } else {
293
+ try {
294
+ text = readFileSync(argv[fileIndex + 1], 'utf8');
295
+ outcome = lintPlan(text);
296
+ } catch (error) {
297
+ outcome = { blocks: [`cannot read plan: ${error.message}`], warns: [] };
298
+ }
299
+ }
300
+ const ok = outcome.blocks.length === 0;
301
+ // --groups hands the validated groups to the rest of the family, so exactly one
302
+ // parser for the grammar exists. Without the flag the shape is untouched:
303
+ // dev-implement's evidence flow and the dev-plan body both read it.
304
+ // The published contract is { id, members, files }; `line` is the parser's own
305
+ // reporting aid and stays out of the JSON other skills consume.
306
+ const groups = wantGroups
307
+ ? (ok && text !== null ? parseIndependentGroups(text).map((g) => ({ id: g.id, members: g.members, files: g.files })) : [])
308
+ : null;
309
+ if (json) {
310
+ const payload = { guard: 'plan-lint', ok, ...outcome };
311
+ if (wantGroups) payload.groups = groups;
312
+ console.log(JSON.stringify(payload, null, 2));
313
+ } else {
314
+ console.log(`plan-lint: ${ok ? 'pass' : 'BLOCKED'}`);
315
+ for (const b of outcome.blocks) console.log(` block: ${b}`);
316
+ if (wantGroups) console.log(` groups: ${groups.map((g) => g.id).join(', ') || 'none'}`);
317
+ }
318
+ process.exit(ok ? 0 : 2);
319
+ }