cabloy 5.1.191 → 5.1.192

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 (82) hide show
  1. package/.cabloy-version +1 -1
  2. package/.github/workflows/agent-governance.yml +39 -0
  3. package/.github/workflows/docs-pages.yml +1 -0
  4. package/.gitignore +3 -0
  5. package/CHANGELOG.md +7 -0
  6. package/package.json +11 -5
  7. package/repo-agent-governance/adapters/claude/contract-loop-gate.ts +199 -0
  8. package/{.claude → repo-agent-governance}/commands/release.md +20 -20
  9. package/repo-agent-governance/managed-assets.json +887 -0
  10. package/repo-agent-governance/manifest.json +56 -0
  11. package/{CLAUDE.md → repo-agent-governance/policies/repository.md} +4 -2
  12. package/repo-agent-governance/scripts/governance.mjs +570 -0
  13. package/repo-agent-governance/scripts/pack-check.mjs +43 -0
  14. package/{.claude → repo-agent-governance}/skills/cabloy-contract-loop/references/contract-loop-map.md +4 -4
  15. package/{.claude → repo-agent-governance}/skills/cabloy-master-detail/SKILL.md +1 -1
  16. package/{.claude → repo-agent-governance}/skills/cabloy-module-removal/SKILL.md +1 -1
  17. package/{.claude → repo-agent-governance}/skills/cabloy-spec-execution/SKILL.md +1 -1
  18. package/{.claude → repo-agent-governance}/skills/cabloy-spec-execution/references/execution-protocol.md +8 -8
  19. package/{.claude → repo-agent-governance}/skills/cabloy-spec-execution/references/status-and-evidence.md +9 -9
  20. package/{.claude → repo-agent-governance}/skills/cabloy-spec-generation/SKILL.md +2 -2
  21. package/{.claude → repo-agent-governance}/skills/cabloy-spec-generation/references/repo-aware-discovery.md +7 -7
  22. package/{.claude → repo-agent-governance}/skills/cabloy-spec-generation/references/repo-specs-document-set.md +30 -30
  23. package/{.claude → repo-agent-governance}/skills/cabloy-spec-generation/references/traceability-and-status-rules.md +9 -9
  24. package/{.claude → repo-agent-governance}/skills/cabloy-workflow/SKILL.md +7 -8
  25. package/{.claude → repo-agent-governance}/skills/cabloy-worktree-environment/SKILL.md +9 -9
  26. package/{.claude → repo-agent-governance}/skills/cabloy-zova-source-reading/SKILL.md +5 -5
  27. package/repo-agent-governance/tests/governance.test.mjs +295 -0
  28. package/repo-agent-governance/tools/contract-loop/check.mjs +71 -0
  29. package/repo-agent-governance/tools/contract-loop/core.mjs +189 -0
  30. package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.mjs +858 -0
  31. package/{.claude/skills/cabloy-spec-generation/scripts → repo-agent-governance/tools/spec-charts}/generate-implementation-charts.test.mjs +95 -20
  32. package/repo-docs/.vitepress/config.mjs +1 -0
  33. package/repo-docs/ai/agent-governance.md +80 -0
  34. package/repo-docs/ai/cli-to-skill-map.md +3 -3
  35. package/repo-docs/ai/docs-skills-rules-mapping.md +27 -26
  36. package/repo-docs/ai/edition-consistency-checklist.md +8 -6
  37. package/repo-docs/ai/future-skill-roadmap.md +2 -2
  38. package/repo-docs/ai/introduction.md +7 -6
  39. package/repo-docs/ai/repo-guidance.md +3 -2
  40. package/repo-docs/ai/rules-and-config.md +23 -15
  41. package/repo-docs/ai/skills.md +7 -7
  42. package/repo-docs/ai/verification.md +2 -1
  43. package/repo-docs/fullstack/contract-loop-playbook.md +1 -1
  44. package/repo-docs/fullstack/quickstart.md +1 -1
  45. package/repo-docs/reference/package-map.md +3 -2
  46. package/repo-docs/reference/repo-scripts.md +17 -2
  47. package/scripts/bootstrapAgentGovernance.mjs +81 -0
  48. package/scripts/init.ts +1 -0
  49. package/scripts/release.ts +15 -0
  50. package/scripts/upgrade.ts +66 -7
  51. package/zova/pnpm-lock.yaml +3 -3
  52. package/.claude/hooks/contract-loop-gate.ts +0 -421
  53. package/.claude/scheduled_tasks.lock +0 -1
  54. package/.claude/skills/cabloy-spec-generation/scripts/generate-implementation-charts.mjs +0 -385
  55. /package/{.claude → repo-agent-governance/adapters/claude}/settings.json +0 -0
  56. /package/{.claude → repo-agent-governance}/skills/cabloy-backend-scaffold/SKILL.md +0 -0
  57. /package/{.claude → repo-agent-governance}/skills/cabloy-backend-scaffold/evals/evals.json +0 -0
  58. /package/{.claude → repo-agent-governance}/skills/cabloy-backend-scaffold/references/backend-thread-map.md +0 -0
  59. /package/{.claude → repo-agent-governance}/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +0 -0
  60. /package/{.claude → repo-agent-governance}/skills/cabloy-contract-loop/SKILL.md +0 -0
  61. /package/{.claude → repo-agent-governance}/skills/cabloy-contract-loop/evals/evals.json +0 -0
  62. /package/{.claude → repo-agent-governance}/skills/cabloy-contract-loop/references/resource-custom-state-pattern.md +0 -0
  63. /package/{.claude → repo-agent-governance}/skills/cabloy-contract-loop/references/verification-checklist.md +0 -0
  64. /package/{.claude → repo-agent-governance}/skills/cabloy-domain-planning/SKILL.md +0 -0
  65. /package/{.claude → repo-agent-governance}/skills/cabloy-frontend-scaffold/SKILL.md +0 -0
  66. /package/{.claude → repo-agent-governance}/skills/cabloy-frontend-scaffold/evals/evals.json +0 -0
  67. /package/{.claude → repo-agent-governance}/skills/cabloy-frontend-scaffold/references/follow-up-checklist.md +0 -0
  68. /package/{.claude → repo-agent-governance}/skills/cabloy-frontend-scaffold/references/frontend-thread-map.md +0 -0
  69. /package/{.claude → repo-agent-governance}/skills/cabloy-resource-field-update/SKILL.md +0 -0
  70. /package/{.claude → repo-agent-governance}/skills/cabloy-resource-field-update/evals/evals.json +0 -0
  71. /package/{.claude → repo-agent-governance}/skills/cabloy-resource-field-update/references/custom-renderer-demo-checklist.md +0 -0
  72. /package/{.claude → repo-agent-governance}/skills/cabloy-resource-field-update/references/field-update-decision-tree.md +0 -0
  73. /package/{.claude → repo-agent-governance}/skills/cabloy-resource-field-update/references/follow-up-checklist.md +0 -0
  74. /package/{.claude → repo-agent-governance}/skills/cabloy-resource-field-update/references/verification-checklist.md +0 -0
  75. /package/{.claude → repo-agent-governance}/skills/cabloy-spec-execution/evals/evals.json +0 -0
  76. /package/{.claude → repo-agent-governance}/skills/cabloy-spec-generation/evals/evals.json +0 -0
  77. /package/{.claude → repo-agent-governance}/skills/cabloy-workflow/evals/evals.json +0 -0
  78. /package/{.claude → repo-agent-governance}/skills/cabloy-workflow/references/cli-strategy.md +0 -0
  79. /package/{.claude → repo-agent-governance}/skills/cabloy-workflow/references/edition-detection.md +0 -0
  80. /package/{.claude → repo-agent-governance}/skills/cabloy-worktree-environment/evals/evals.json +0 -0
  81. /package/{.claude → repo-agent-governance}/skills/cabloy-zova-source-reading/references/analysis-modes.md +0 -0
  82. /package/{.claude → repo-agent-governance}/skills/cabloy-zova-source-reading/references/core-reading-paths.md +0 -0
@@ -0,0 +1,858 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { basename, resolve } from 'node:path';
4
+
5
+ const STATUS_ORDER = [
6
+ 'not-started',
7
+ 'in-progress',
8
+ 'implementation-complete',
9
+ 'verified',
10
+ 'blocked',
11
+ 'waived',
12
+ 'deferred',
13
+ ];
14
+ const STATUS_COLORS = {
15
+ 'not-started': 'var(--blue)',
16
+ 'in-progress': 'var(--orange)',
17
+ 'implementation-complete': 'var(--violet)',
18
+ 'verified': 'var(--aqua)',
19
+ 'blocked': 'var(--red)',
20
+ 'waived': 'var(--yellow)',
21
+ 'deferred': 'var(--muted)',
22
+ };
23
+
24
+ const COPY = {
25
+ en: {
26
+ ganttTitle: 'Implementation Roadmap',
27
+ ganttSubtitle:
28
+ 'WBS sequencing, dependencies, derived status, and illustrative relative timeboxes',
29
+ burndownTitle: 'WBS Scope-Count Burndown',
30
+ burndownSubtitle:
31
+ 'Derived WBS scope snapshot — remaining-count reference, not a calendar or historical trend',
32
+ phase: 'Phase',
33
+ wbs: 'WBS',
34
+ work: 'Work package',
35
+ dependency: 'Dependencies',
36
+ status: 'Status',
37
+ weeks: 'Relative implementation order (illustrative)',
38
+ current: 'Current reviewed state',
39
+ remaining: 'WBS items remaining',
40
+ verified: 'verified',
41
+ approved: 'Approved WBS scope',
42
+ noNext: 'No WBS item is ready to execute after the current scope.',
43
+ noReady: 'No next WBS item is ready to execute.',
44
+ source: 'Sources',
45
+ reviewed: 'Last reviewed',
46
+ scopeReference: 'Scope-count reference',
47
+ notTime: 'Verified WBS items in approved scope — not time',
48
+ remainingAxis: 'WBS items remaining',
49
+ logicalBaseline: 'Logical approved-scope baseline',
50
+ currentSnapshot: 'Current snapshot',
51
+ remainingShort: 'remaining',
52
+ emptyScope: 'No active WBS items remain in scope.',
53
+ noHistory: 'No observed intermediate status points are available.',
54
+ formula: 'remaining = active scope − verified',
55
+ phaseScope: 'Scope by phase',
56
+ active: 'Active WBS items',
57
+ deferred: 'Deferred',
58
+ completion: 'Completion',
59
+ howRead: 'How to read this chart',
60
+ caveat: 'Derived visualization only — not a schedule, velocity trend, or forecast',
61
+ chartCaveat:
62
+ 'Relative positions and overlap are illustrative; they are not calendar dates, duration, staffing, or concurrency commitments.',
63
+ metadata:
64
+ 'Derived from pdp-wbs.md, test-plan.md, and progress.md. This SVG is not planning authority.',
65
+ next: 'Next executable WBS item',
66
+ },
67
+ zh: {
68
+ ganttTitle: '实施路线图',
69
+ ganttSubtitle: 'WBS 顺序、依赖关系、派生状态和示意性相对时间框',
70
+ burndownTitle: 'WBS 范围燃尽图',
71
+ burndownSubtitle: '派生的 WBS 范围快照——剩余项参考,不是日历或历史趋势',
72
+ phase: '阶段',
73
+ wbs: 'WBS',
74
+ work: '工作包',
75
+ dependency: '依赖',
76
+ status: '状态',
77
+ weeks: '相对实施顺序(示意)',
78
+ current: '当前审查状态',
79
+ remaining: '剩余 WBS 项',
80
+ verified: '已核验',
81
+ approved: '已批准 WBS 范围',
82
+ noNext: '选择其依赖均已满足的最早未核验 WBS 项。',
83
+ noReady: '当前没有可执行的下一项 WBS 工作。',
84
+ source: '来源',
85
+ reviewed: '最后审查日期',
86
+ scopeReference: '范围计数参考',
87
+ notTime: '已核验的批准范围 WBS 项——不是时间',
88
+ remainingAxis: '剩余 WBS 项',
89
+ logicalBaseline: '逻辑上的批准范围基线',
90
+ currentSnapshot: '当前快照',
91
+ remainingShort: '剩余',
92
+ emptyScope: '当前范围内没有活跃 WBS 项。',
93
+ noHistory: '没有可用的中间历史状态点。',
94
+ formula: '剩余 = 活跃范围 − 已核验',
95
+ phaseScope: '按阶段统计范围',
96
+ active: '活跃 WBS 项',
97
+ deferred: '已延期',
98
+ completion: '完成情况',
99
+ howRead: '图表说明',
100
+ caveat: '仅为派生可视化——不是计划、速度趋势或预测',
101
+ chartCaveat: '相对位置和重叠仅为示意;不代表日历日期、工期、人员或并发承诺。',
102
+ next: '下一项 WBS 工作',
103
+ metadata: '派生自 pdp-wbs.md、test-plan.md 和 progress.md。本 SVG 不是计划权威。',
104
+ },
105
+ };
106
+
107
+ function escapeXml(value) {
108
+ return String(value)
109
+ .replace(/&/g, '&')
110
+ .replace(/</g, '&lt;')
111
+ .replace(/>/g, '&gt;')
112
+ .replace(/"/g, '&quot;')
113
+ .replace(/'/g, '&apos;');
114
+ }
115
+
116
+ function isAsciiLetter(character) {
117
+ if (!character || character.length !== 1) return false;
118
+ const codePoint = character.codePointAt(0);
119
+ return (codePoint >= 0x41 && codePoint <= 0x5a) || (codePoint >= 0x61 && codePoint <= 0x7a);
120
+ }
121
+
122
+ function isAsciiDigit(character) {
123
+ if (!character || character.length !== 1) return false;
124
+ const codePoint = character.codePointAt(0);
125
+ return codePoint >= 0x30 && codePoint <= 0x39;
126
+ }
127
+
128
+ function isAsciiIdentifierCharacter(character) {
129
+ return isAsciiLetter(character) || isAsciiDigit(character);
130
+ }
131
+
132
+ function isAsciiWordCharacter(character) {
133
+ return isAsciiIdentifierCharacter(character) || character === '_';
134
+ }
135
+
136
+ function isHorizontalWhitespace(character) {
137
+ return character === ' ' || character === '\t';
138
+ }
139
+
140
+ function skipHorizontalWhitespace(value, index) {
141
+ let cursor = index;
142
+ while (isHorizontalWhitespace(value[cursor])) {
143
+ cursor++;
144
+ }
145
+ return cursor;
146
+ }
147
+
148
+ function consumeHorizontalWhitespace(value, index) {
149
+ const cursor = skipHorizontalWhitespace(value, index);
150
+ return cursor === index ? null : cursor;
151
+ }
152
+
153
+ function isWideChartCharacter(character) {
154
+ const codePoint = character.codePointAt(0);
155
+ return (
156
+ codePoint > 0xffff ||
157
+ (codePoint >= 0x2e80 && codePoint <= 0x9fff) ||
158
+ (codePoint >= 0xac00 && codePoint <= 0xd7af) ||
159
+ (codePoint >= 0xf900 && codePoint <= 0xfaff)
160
+ );
161
+ }
162
+
163
+ function isCjkUnifiedIdeograph(character) {
164
+ const codePoint = character.codePointAt(0);
165
+ return codePoint >= 0x3400 && codePoint <= 0x9fff;
166
+ }
167
+
168
+ function isTokenStart(value, index) {
169
+ return index === 0 || !isAsciiWordCharacter(value[index - 1]);
170
+ }
171
+
172
+ function readIdentifier(value, index, prefix, { wildcard = false } = {}) {
173
+ if (!value.startsWith(prefix, index) || !isTokenStart(value, index)) return null;
174
+ let cursor = index + prefix.length;
175
+ let segmentStart = cursor;
176
+ while (isAsciiIdentifierCharacter(value[cursor])) {
177
+ cursor++;
178
+ }
179
+ if (cursor === segmentStart) return null;
180
+ while (value[cursor] === '-') {
181
+ if (wildcard && value[cursor + 1] === '*') {
182
+ return { end: cursor + 2, value: value.slice(index, cursor + 2) };
183
+ }
184
+ const hyphen = cursor;
185
+ segmentStart = ++cursor;
186
+ while (isAsciiIdentifierCharacter(value[cursor])) {
187
+ cursor++;
188
+ }
189
+ if (cursor === segmentStart) {
190
+ return { end: hyphen, value: value.slice(index, hyphen) };
191
+ }
192
+ }
193
+ return { end: cursor, value: value.slice(index, cursor) };
194
+ }
195
+
196
+ function findIdentifierMatches(value, prefix, options) {
197
+ const identifiers = [];
198
+ let index = 0;
199
+ while (index < value.length) {
200
+ const found = value.indexOf(prefix, index);
201
+ if (found === -1) break;
202
+ const identifier = readIdentifier(value, found, prefix, options);
203
+ if (identifier) {
204
+ identifiers.push(identifier);
205
+ index = identifier.end;
206
+ } else {
207
+ index = found + prefix.length;
208
+ }
209
+ }
210
+ return identifiers;
211
+ }
212
+
213
+ function findIdentifiers(value, prefix, options) {
214
+ return findIdentifierMatches(value, prefix, options).map(identifier => identifier.value);
215
+ }
216
+
217
+ function parseHeadingPrefix(line, hashes) {
218
+ if (!line.startsWith('#'.repeat(hashes))) return null;
219
+ if (line[hashes] === '#') return null;
220
+ return consumeHorizontalWhitespace(line, hashes);
221
+ }
222
+
223
+ function parseLabeledValue(line, label) {
224
+ if (line.slice(0, label.length).toLowerCase() !== label.toLowerCase()) return null;
225
+ if (line[label.length] !== ':') return null;
226
+ const value = line.slice(label.length + 1).trim();
227
+ return value || null;
228
+ }
229
+
230
+ function readDate(value, index) {
231
+ const date = value.slice(index, index + 10);
232
+ if (
233
+ date.length !== 10 ||
234
+ date[4] !== '-' ||
235
+ date[7] !== '-' ||
236
+ ![...date.slice(0, 4)].every(isAsciiDigit) ||
237
+ ![...date.slice(5, 7)].every(isAsciiDigit) ||
238
+ ![...date.slice(8)].every(isAsciiDigit)
239
+ ) {
240
+ return null;
241
+ }
242
+ return date;
243
+ }
244
+
245
+ function reviewDateFromLine(line) {
246
+ const normalized = line.toLowerCase();
247
+ const labels = [
248
+ { index: normalized.indexOf('last reviewed'), length: 'last reviewed'.length },
249
+ { index: line.indexOf('最后审查日期'), length: '最后审查日期'.length },
250
+ ];
251
+ for (const label of labels) {
252
+ if (label.index === -1) continue;
253
+ let cursor = skipHorizontalWhitespace(line, label.index + label.length);
254
+ if (line[cursor] === ':' || line[cursor] === ':') cursor++;
255
+ cursor = skipHorizontalWhitespace(line, cursor);
256
+ const date = readDate(line, cursor);
257
+ if (date) return date;
258
+ }
259
+ return null;
260
+ }
261
+
262
+ function chartTextWidth(value, fontSize) {
263
+ return [...String(value)].reduce((width, character) => {
264
+ const characterWidth = isWideChartCharacter(character)
265
+ ? 1
266
+ : /[MW@#%&]/.test(character)
267
+ ? 0.9
268
+ : /[A-Z0-9]/.test(character)
269
+ ? 0.62
270
+ : /[ilI.,:;!'` ]/.test(character)
271
+ ? 0.28
272
+ : 0.5;
273
+ return width + characterWidth * fontSize;
274
+ }, 0);
275
+ }
276
+
277
+ function wrapChartText(value, maxWidth, measure = value => [...String(value)].length) {
278
+ const words = String(value).trim().replace(/\s+/g, ' ').split(' ');
279
+ const lines = [];
280
+ let line = '';
281
+ for (const word of words) {
282
+ const characters = [...word];
283
+ if (measure(word) > maxWidth) {
284
+ if (line) {
285
+ lines.push(line);
286
+ line = '';
287
+ }
288
+ let chunk = '';
289
+ let chunkWidth = 0;
290
+ for (const character of characters) {
291
+ const characterWidth = measure(character);
292
+ if (chunk && chunkWidth + characterWidth > maxWidth) {
293
+ lines.push(chunk);
294
+ chunk = '';
295
+ chunkWidth = 0;
296
+ }
297
+ chunk += character;
298
+ chunkWidth += characterWidth;
299
+ }
300
+ if (chunk) lines.push(chunk);
301
+ continue;
302
+ }
303
+ const candidate = line ? `${line} ${word}` : word;
304
+ if (measure(candidate) > maxWidth) {
305
+ lines.push(line);
306
+ line = word;
307
+ } else {
308
+ line = candidate;
309
+ }
310
+ }
311
+ if (line) lines.push(line);
312
+ return lines;
313
+ }
314
+
315
+ function languageFromReadme(readme) {
316
+ let cjk = 0;
317
+ let latin = 0;
318
+ for (const character of readme) {
319
+ if (isCjkUnifiedIdeograph(character)) cjk++;
320
+ if (isAsciiLetter(character)) latin++;
321
+ }
322
+ return cjk > latin / 3 ? 'zh' : 'en';
323
+ }
324
+
325
+ function normalizeStatus(value) {
326
+ const normalized = value.trim().replace(/`/g, '').toLowerCase();
327
+ if (!STATUS_ORDER.includes(normalized)) {
328
+ throw new Error(`Unsupported progress status: ${value.trim()}`);
329
+ }
330
+ return normalized;
331
+ }
332
+
333
+ function isTableDividerCell(value) {
334
+ const cell = value.trim();
335
+ return Boolean(cell) && [...cell].every(character => character === '-' || character === ':');
336
+ }
337
+
338
+ function parseTableRow(line) {
339
+ const trimmed = line.trim();
340
+ if (!trimmed.startsWith('|')) return null;
341
+ const cells = trimmed
342
+ .split('|')
343
+ .slice(1, -1)
344
+ .map(cell => cell.trim());
345
+ return isTableDividerCell(cells[0] ?? '') ? null : cells;
346
+ }
347
+
348
+ function readProgressId(value) {
349
+ return findIdentifiers(value.replace(/`/g, ''), 'WBS-')[0] ?? null;
350
+ }
351
+
352
+ function progressColumnIndexes(cells) {
353
+ let id;
354
+ let status;
355
+ for (const [index, cell] of cells.entries()) {
356
+ const label = cell.replace(/`/g, '').trim().toLowerCase();
357
+ if (label === 'wbs id') id = index;
358
+ if (label === 'status') status = index;
359
+ }
360
+ return id === undefined || status === undefined ? null : { id, status };
361
+ }
362
+
363
+ export function parseProgress(markdown) {
364
+ const rows = new Map();
365
+ let reviewed;
366
+ let columns = null;
367
+ for (const line of markdown.split(/\r?\n/)) {
368
+ reviewed ??= reviewDateFromLine(line);
369
+ const cells = parseTableRow(line);
370
+ if (!cells) {
371
+ if (line.trim() && !line.trim().startsWith('|')) columns = null;
372
+ continue;
373
+ }
374
+ const headerColumns = progressColumnIndexes(cells);
375
+ if (headerColumns) {
376
+ columns = headerColumns;
377
+ continue;
378
+ }
379
+ if (!columns) continue;
380
+ const id = readProgressId(cells[columns.id] ?? '');
381
+ if (!id) continue;
382
+ rows.set(id, normalizeStatus(cells[columns.status] ?? ''));
383
+ }
384
+ return { rows, reviewed: reviewed ?? 'not recorded' };
385
+ }
386
+
387
+ function parsePhaseHeading(line) {
388
+ const contentStart = parseHeadingPrefix(line, 3);
389
+ if (contentStart === null || !line.startsWith('Phase', contentStart)) return null;
390
+ let cursor = consumeHorizontalWhitespace(line, contentStart + 'Phase'.length);
391
+ if (cursor === null) return null;
392
+ const numberStart = cursor;
393
+ while (isAsciiDigit(line[cursor])) {
394
+ cursor++;
395
+ }
396
+ if (cursor === numberStart) return null;
397
+ cursor = skipHorizontalWhitespace(line, cursor);
398
+ if (line[cursor] !== ':') return null;
399
+ const title = line.slice(cursor + 1).trim();
400
+ if (!title) return null;
401
+ return { number: line.slice(numberStart, cursor).trim(), title };
402
+ }
403
+
404
+ function parseTaskHeading(line) {
405
+ const contentStart = parseHeadingPrefix(line, 4);
406
+ if (contentStart === null) return null;
407
+ const identifier = readIdentifier(line, contentStart, 'WBS-');
408
+ if (!identifier || hasMalformedIdentifierTail(line, identifier.end)) return null;
409
+ const cursor = skipHorizontalWhitespace(line, identifier.end);
410
+ if (line[cursor] !== ':') return null;
411
+ const title = line.slice(cursor + 1).trim();
412
+ if (!title) return null;
413
+ return { id: identifier.value, title };
414
+ }
415
+
416
+ function dependencyFromLines(lines) {
417
+ for (const line of lines) {
418
+ const dependency = parseLabeledValue(line.trim(), 'Dependencies');
419
+ if (dependency) return dependency;
420
+ }
421
+ return null;
422
+ }
423
+
424
+ function isDeferred(lines) {
425
+ for (const line of lines) {
426
+ const normalized = line.trim().replaceAll('`', '').toLowerCase();
427
+ if (normalized === 'deferred scope' || parseLabeledValue(normalized, 'status') === 'deferred') {
428
+ return true;
429
+ }
430
+ }
431
+ return false;
432
+ }
433
+
434
+ function isIdentifierContinuation(character) {
435
+ return character === '-' || isAsciiIdentifierCharacter(character);
436
+ }
437
+
438
+ function hasMalformedIdentifierTail(value, end) {
439
+ return value[end] === '-' && (value[end + 1] === ':' || isHorizontalWhitespace(value[end + 1]));
440
+ }
441
+
442
+ function atpReferences(lines) {
443
+ const ids = new Set();
444
+ for (const line of lines) {
445
+ for (const identifier of findIdentifierMatches(line, 'ATP-')) {
446
+ if (!isIdentifierContinuation(line[identifier.end])) ids.add(identifier.value);
447
+ }
448
+ }
449
+ return [...ids];
450
+ }
451
+
452
+ export function parseWbs(markdown) {
453
+ const lines = markdown.split(/\r?\n/);
454
+ const phaseStarts = [];
455
+ for (const [index, line] of lines.entries()) {
456
+ const phase = parsePhaseHeading(line);
457
+ if (phase) phaseStarts.push({ ...phase, index });
458
+ }
459
+ if (!phaseStarts.length) {
460
+ throw new Error('No formal `### Phase <number>:` headings were found in pdp-wbs.md.');
461
+ }
462
+ const phases = phaseStarts.map((phase, phaseIndex) => {
463
+ const bodyLines = lines.slice(phase.index + 1, phaseStarts[phaseIndex + 1]?.index);
464
+ const phaseDependency = dependencyFromLines(bodyLines) ?? 'none';
465
+ const taskStarts = [];
466
+ for (const [index, line] of bodyLines.entries()) {
467
+ const task = parseTaskHeading(line);
468
+ if (task) taskStarts.push({ ...task, index });
469
+ }
470
+ const tasks = taskStarts.map((task, taskIndex) => {
471
+ const taskBody = bodyLines.slice(task.index + 1, taskStarts[taskIndex + 1]?.index);
472
+ return {
473
+ id: task.id,
474
+ title: task.title,
475
+ dependency: dependencyFromLines(taskBody) ?? phaseDependency,
476
+ deferred: isDeferred(taskBody),
477
+ atps: atpReferences(taskBody),
478
+ };
479
+ });
480
+ return { number: phase.number, title: phase.title, dependency: phaseDependency, tasks };
481
+ });
482
+ const tasks = phases.flatMap(phase =>
483
+ phase.tasks.map(task => {
484
+ task.phase = { number: phase.number, title: phase.title };
485
+ return task;
486
+ }),
487
+ );
488
+ if (!tasks.length) {
489
+ throw new Error('No formal `#### WBS-…:` tasks were found in pdp-wbs.md.');
490
+ }
491
+ const ids = new Set(tasks.map(task => task.id));
492
+ for (const task of tasks) {
493
+ for (const dependency of findIdentifiers(task.dependency, 'WBS-', { wildcard: true })) {
494
+ if (dependency.endsWith('-*')) {
495
+ const prefix = dependency.slice(0, -1);
496
+ if (![...ids].some(id => id.startsWith(prefix))) {
497
+ throw new Error(`${task.id} depends on unknown WBS task group ${dependency}.`);
498
+ }
499
+ } else if (!ids.has(dependency)) {
500
+ throw new Error(`${task.id} depends on unknown WBS task ${dependency}.`);
501
+ }
502
+ }
503
+ }
504
+ return { phases, tasks };
505
+ }
506
+
507
+ function readStrictAtpId(value) {
508
+ const trimmed = value.trim();
509
+ const unwrapped =
510
+ trimmed.startsWith('`') && trimmed.endsWith('`') ? trimmed.slice(1, -1) : trimmed;
511
+ const identifier = readIdentifier(unwrapped, 0, 'ATP-');
512
+ return identifier?.end === unwrapped.length ? identifier.value : null;
513
+ }
514
+
515
+ export function parseAtpIds(markdown) {
516
+ const ids = new Set();
517
+ for (const line of markdown.split(/\r?\n/)) {
518
+ const cells = parseTableRow(line);
519
+ if (cells?.[0]) {
520
+ const id = readStrictAtpId(cells[0]);
521
+ if (id) ids.add(id);
522
+ continue;
523
+ }
524
+ for (let hashes = 2; hashes <= line.length; hashes++) {
525
+ const contentStart = parseHeadingPrefix(line, hashes);
526
+ if (contentStart === null) continue;
527
+ const content = line.slice(contentStart).trimStart();
528
+ const id = readIdentifier(content, content.startsWith('`') ? 1 : 0, 'ATP-');
529
+ if (!id) break;
530
+ const after = content.slice(id.end);
531
+ if (content.startsWith('`')) {
532
+ if (after.startsWith('`')) ids.add(id.value);
533
+ } else if (!after || isHorizontalWhitespace(after[0])) {
534
+ ids.add(id.value);
535
+ }
536
+ break;
537
+ }
538
+ }
539
+ return ids;
540
+ }
541
+
542
+ export function createChartModel({ readme, wbs, progress, testPlan }) {
543
+ const language = languageFromReadme(readme);
544
+ const atpIds = parseAtpIds(testPlan);
545
+ const { phases, tasks } = parseWbs(wbs);
546
+ const { rows: statusRows, reviewed } = parseProgress(progress);
547
+ const unknownProgress = [...statusRows.keys()].filter(id => !tasks.some(task => task.id === id));
548
+ if (unknownProgress.length) {
549
+ throw new Error(
550
+ `progress.md contains WBS rows not defined in pdp-wbs.md: ${unknownProgress.join(', ')}.`,
551
+ );
552
+ }
553
+ for (const task of tasks) {
554
+ if (!statusRows.has(task.id))
555
+ throw new Error(`${task.id} has no corresponding progress.md status row.`);
556
+ task.status = statusRows.get(task.id);
557
+ if (task.deferred && task.status !== 'deferred') {
558
+ throw new Error(
559
+ `${task.id} is marked deferred in pdp-wbs.md but has progress status ${task.status}.`,
560
+ );
561
+ }
562
+ for (const atp of task.atps) {
563
+ if (!atpIds.has(atp))
564
+ throw new Error(`${task.id} references undefined ${atp} in test-plan.md.`);
565
+ }
566
+ }
567
+ const activeTasks = tasks.filter(task => !task.deferred && task.status !== 'deferred');
568
+ const deferredTasks = tasks.filter(task => !activeTasks.includes(task));
569
+ const verified = activeTasks.filter(task => task.status === 'verified').length;
570
+ return {
571
+ language,
572
+ copy: COPY[language],
573
+ phases,
574
+ tasks,
575
+ activeTasks,
576
+ deferredTasks,
577
+ verified,
578
+ reviewed,
579
+ };
580
+ }
581
+
582
+ function style() {
583
+ return `<style>
584
+ :root { --surface:#fcfcfb;--page:#f9f9f7;--ink:#0b0b0b;--secondary:#52514e;--muted:#898781;--grid:#e1e0d9;--axis:#c3c2b7;--blue:#2a78d6;--orange:#eb6834;--aqua:#1baf7a;--violet:#4a3aa7;--red:#e34948;--yellow:#eda100;--blue-wash:#eaf3fd;--aqua-wash:#e7f7f0; }
585
+ @media (prefers-color-scheme:dark) { :root { --surface:#1a1a19;--page:#0d0d0d;--ink:#fff;--secondary:#c3c2b7;--muted:#a09f99;--grid:#2c2c2a;--axis:#383835;--blue:#3987e5;--orange:#d95926;--aqua:#199e70;--violet:#9085e9;--red:#e66767;--yellow:#c98500;--blue-wash:#172435;--aqua-wash:#122b23; } }
586
+ .canvas{fill:var(--page)}.card{fill:var(--surface);stroke:var(--grid);stroke-width:1}.title{fill:var(--ink);font-family:Arial,Helvetica,sans-serif;font-size:27px;font-weight:700}.subtitle{fill:var(--secondary);font-family:Arial,Helvetica,sans-serif;font-size:14px}.heading{fill:var(--ink);font-family:Arial,Helvetica,sans-serif;font-size:13px;font-weight:700}.phase{fill:var(--ink);font-family:Arial,Helvetica,sans-serif;font-size:10px;font-weight:700}.task{fill:var(--ink);font-family:Arial,Helvetica,sans-serif;font-size:10px}.small{fill:var(--muted);font-family:Arial,Helvetica,sans-serif;font-size:9px}.caption{fill:var(--secondary);font-family:Arial,Helvetica,sans-serif;font-size:11px}.num{fill:var(--ink);font-family:Arial,Helvetica,sans-serif;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums}.grid{stroke:var(--grid);stroke-width:1;shape-rendering:crispEdges}.axis{stroke:var(--axis);stroke-width:1;shape-rendering:crispEdges}.row:focus{outline:2px solid var(--ink);outline-offset:2px}
587
+ </style>`;
588
+ }
589
+
590
+ function statusLabel(status, language) {
591
+ const labels = {
592
+ en: {
593
+ 'not-started': 'Not started',
594
+ 'in-progress': 'In progress',
595
+ 'implementation-complete': 'Implementation complete',
596
+ 'verified': 'Verified',
597
+ 'blocked': 'Blocked',
598
+ 'waived': 'Waived',
599
+ 'deferred': 'Deferred',
600
+ },
601
+ zh: {
602
+ 'not-started': '未开始',
603
+ 'in-progress': '进行中',
604
+ 'implementation-complete': '实施完成',
605
+ 'verified': '已核验',
606
+ 'blocked': '受阻',
607
+ 'waived': '已豁免',
608
+ 'deferred': '已延期',
609
+ },
610
+ };
611
+ return labels[language][status];
612
+ }
613
+
614
+ function statusIcon(x, y, status) {
615
+ if (status === 'verified')
616
+ return `<circle cx="${x}" cy="${y}" r="6" fill="var(--surface)" stroke="var(--axis)"/><path d="M${x - 3} ${y} L${x - 1} ${y + 2} L${x + 4} ${y - 3}" fill="none" stroke="var(--ink)" stroke-width="1.1" stroke-linecap="round"/>`;
617
+ return `<circle cx="${x}" cy="${y}" r="5" fill="${STATUS_COLORS[status]}"/>`;
618
+ }
619
+
620
+ function renderChartTextLines(className, x, y, lines, lineHeight) {
621
+ if (lines.length === 1)
622
+ return `<text class="${className}" x="${x}" y="${y}">${escapeXml(lines[0])}</text>`;
623
+ return `<text class="${className}" x="${x}" y="${y}">${lines.map((line, lineIndex) => `<tspan x="${x}" dy="${lineIndex ? lineHeight : 0}">${escapeXml(line)}</tspan>`).join('')}</text>`;
624
+ }
625
+
626
+ function svgDocument(title, desc, body, height, metadata) {
627
+ return `<?xml version="1.0" encoding="UTF-8"?>
628
+ <svg xmlns="http://www.w3.org/2000/svg" width="1600" height="${height}" viewBox="0 0 1600 ${height}" role="img" aria-labelledby="chart-title chart-desc">
629
+ <title id="chart-title">${escapeXml(title)}</title>
630
+ <desc id="chart-desc">${escapeXml(desc)}</desc>
631
+ <metadata>${escapeXml(metadata)}</metadata>
632
+ ${style()}
633
+ ${body}
634
+ </svg>
635
+ `;
636
+ }
637
+
638
+ export function renderGantt(model, suiteName) {
639
+ const { copy: t, tasks, reviewed, language } = model;
640
+ const phaseX = 72;
641
+ const wbsX = 265;
642
+ const workX = 405;
643
+ const dependencyX = 680;
644
+ const statusX = 900;
645
+ const plotX = 980;
646
+ const plotW = 520;
647
+ const rowY = 270;
648
+ const rowH = 34;
649
+ const phaseLineH = 12;
650
+ const workMaxWidth = dependencyX - workX - 10;
651
+ const dependencyMaxWidth = statusX - dependencyX - 12;
652
+ let nextY = rowY;
653
+ const layout = tasks.map((task, index) => {
654
+ const phaseStart = index === 0 || tasks[index - 1].phase.number !== task.phase.number;
655
+ const descriptionLines = phaseStart ? wrapChartText(task.phase.title, 30) : [];
656
+ const workLines = wrapChartText(task.title, workMaxWidth, value => chartTextWidth(value, 10));
657
+ const dependencyLines = wrapChartText(task.dependency, dependencyMaxWidth, value =>
658
+ chartTextWidth(value, 9),
659
+ );
660
+ const contentLines = Math.max(workLines.length, dependencyLines.length);
661
+ const taskRowH = Math.max(rowH, contentLines * phaseLineH + 22);
662
+ const phaseLabelY = phaseStart ? nextY : undefined;
663
+ const y = phaseStart ? nextY + 23 + descriptionLines.length * phaseLineH : nextY;
664
+ const separatorY = y + Math.max(14, contentLines * phaseLineH + 2);
665
+ nextY = y + taskRowH;
666
+ return {
667
+ task,
668
+ index,
669
+ y,
670
+ separatorY,
671
+ phaseLabelY,
672
+ descriptionLines,
673
+ workLines,
674
+ dependencyLines,
675
+ };
676
+ });
677
+ const contentBottom = layout.at(-1)?.separatorY ?? rowY;
678
+ const footerY = Math.max(772, contentBottom + 80);
679
+ const height = Math.max(850, footerY + 78);
680
+ const rows = layout
681
+ .map(
682
+ ({
683
+ task,
684
+ index,
685
+ y,
686
+ separatorY,
687
+ phaseLabelY,
688
+ descriptionLines,
689
+ workLines,
690
+ dependencyLines,
691
+ }) => {
692
+ const barX = plotX + (index / Math.max(tasks.length, 1)) * (plotW - 90);
693
+ const barW = task.status === 'verified' ? 58 : 42;
694
+ const phaseHeader =
695
+ phaseLabelY === undefined
696
+ ? ''
697
+ : `<text class="phase" x="${phaseX}" y="${phaseLabelY}">${escapeXml(`${t.phase} ${task.phase.number}`)}</text>${descriptionLines.map((line, lineIndex) => `<text class="small" x="${phaseX}" y="${phaseLabelY + 13 + lineIndex * phaseLineH}">${escapeXml(line)}</text>`).join('')}`;
698
+ const dependencyText = renderChartTextLines(
699
+ 'small',
700
+ dependencyX,
701
+ y,
702
+ dependencyLines,
703
+ phaseLineH,
704
+ );
705
+ return `<g class="row" tabindex="0" aria-label="${escapeXml(`${task.id}: ${task.title}; ${statusLabel(task.status, language)}`)}"><title>${escapeXml(`${task.id}: ${task.title}; dependencies: ${task.dependency}; ${statusLabel(task.status, language)}`)}</title><line class="grid" x1="72" y1="${separatorY}" x2="1500" y2="${separatorY}"/>${phaseHeader}<text class="num" x="${wbsX}" y="${y}">${escapeXml(task.id)}</text>${renderChartTextLines('task', workX, y, workLines, phaseLineH)}${dependencyText}${statusIcon(statusX + 5, y - 4, task.status)}<text class="small" x="${statusX + 17}" y="${y}">${escapeXml(statusLabel(task.status, language))}</text><rect x="${barX.toFixed(1)}" y="${y - 15}" width="${barW}" height="18" rx="4" fill="${STATUS_COLORS[task.status]}"/></g>`;
706
+ },
707
+ )
708
+ .join('\n');
709
+ const completed = new Set(tasks.filter(task => task.status === 'verified').map(task => task.id));
710
+ const dependencyIds = task =>
711
+ findIdentifiers(task.dependency, 'WBS-', { wildcard: true }).flatMap(value => {
712
+ if (!value.endsWith('-*')) return [value];
713
+ const prefix = value.slice(0, -1);
714
+ return tasks
715
+ .filter(candidate => candidate.id.startsWith(prefix))
716
+ .map(candidate => candidate.id);
717
+ });
718
+ const nextTask = tasks.find(
719
+ task =>
720
+ task.status !== 'verified' &&
721
+ task.status !== 'deferred' &&
722
+ task.status !== 'waived' &&
723
+ task.status !== 'blocked' &&
724
+ dependencyIds(task).every(id => completed.has(id)),
725
+ );
726
+ const nextLabel = nextTask
727
+ ? `${nextTask.id}: ${nextTask.title}`
728
+ : model.verified === model.activeTasks.length
729
+ ? t.noReady
730
+ : t.noNext;
731
+ const body = `<rect class="canvas" width="1600" height="${height}"/><rect class="card" x="32" y="28" width="1536" height="${height - 56}" rx="14"/>
732
+ <text class="title" x="72" y="84">${escapeXml(`${suiteName} ${t.ganttTitle}`)}</text><text class="subtitle" x="72" y="110">${escapeXml(t.ganttSubtitle)}</text><text class="small" x="1500" y="84" text-anchor="end">${escapeXml(`${t.reviewed}: ${reviewed}`)}</text>
733
+ <rect x="72" y="142" width="1428" height="78" rx="8" fill="var(--aqua-wash)"/><text class="heading" x="96" y="171">${escapeXml(t.current)}</text><text class="caption" x="96" y="195">${escapeXml(nextLabel)}</text><text class="heading" x="1320" y="171" text-anchor="end">${escapeXml(t.next)}</text><text class="heading" x="1320" y="195" text-anchor="end">${model.verified} / ${model.activeTasks.length} ${escapeXml(t.verified)}</text>
734
+ <text class="small" x="${phaseX}" y="246">${escapeXml(t.phase)}</text><text class="small" x="${wbsX}" y="246">${escapeXml(t.wbs)}</text><text class="small" x="${workX}" y="246">${escapeXml(t.work)}</text><text class="small" x="${dependencyX}" y="246">${escapeXml(t.dependency)}</text><text class="small" x="${statusX}" y="246">${escapeXml(t.status)}</text><text class="small" x="${plotX}" y="246">${escapeXml(t.weeks)}</text><line class="axis" x1="${plotX}" y1="254" x2="1500" y2="254"/>
735
+ ${rows}
736
+ <line class="grid" x1="72" y1="${footerY}" x2="1500" y2="${footerY}"/><text class="small" x="72" y="${footerY + 28}">${escapeXml(`${t.source}: pdp-wbs.md · test-plan.md · progress.md (${t.reviewed}: ${reviewed})`)}</text><text class="small" x="1500" y="${footerY + 28}" text-anchor="end">${escapeXml(t.chartCaveat)}</text>`;
737
+ return svgDocument(
738
+ `${suiteName} ${t.ganttTitle}`,
739
+ `${t.ganttTitle}. ${tasks.length} formal WBS tasks. Current derived status reviewed ${reviewed}. ${t.chartCaveat}`,
740
+ body,
741
+ height,
742
+ t.metadata,
743
+ );
744
+ }
745
+
746
+ export function renderBurndown(model, suiteName) {
747
+ const { copy: t, phases, activeTasks, deferredTasks, verified, reviewed, language } = model;
748
+ const total = activeTasks.length;
749
+ const remaining = total - verified;
750
+ const height = 1050;
751
+ const x0 = 190;
752
+ const x1 = 910;
753
+ const y0 = 410;
754
+ const y1 = 650;
755
+ const completion = total ? verified / total : 0;
756
+ const currentX = x0 + completion * (x1 - x0);
757
+ const currentY = y0 + completion * (y1 - y0);
758
+ const currentMarker =
759
+ remaining === 0 && total > 0
760
+ ? `<circle cx="${currentX.toFixed(1)}" cy="${currentY.toFixed(1)}" r="14" fill="var(--aqua)" stroke="var(--surface)" stroke-width="3"/><path d="M${(currentX - 6).toFixed(1)} ${currentY.toFixed(1)} L${(currentX - 2).toFixed(1)} ${(currentY + 4).toFixed(1)} L${(currentX + 6).toFixed(1)} ${(currentY - 5).toFixed(1)}" fill="none" stroke="var(--surface)" stroke-width="1.8" stroke-linecap="round"/>`
761
+ : `<circle cx="${currentX.toFixed(1)}" cy="${currentY.toFixed(1)}" r="8" fill="var(--orange)" stroke="var(--surface)" stroke-width="2"/>`;
762
+ const phaseRows = phases
763
+ .map((phase, index) => {
764
+ const phaseTasks = phase.tasks.filter(task => !task.deferred && task.status !== 'deferred');
765
+ const phaseVerified = phaseTasks.filter(task => task.status === 'verified').length;
766
+ const y = 805 + index * 28;
767
+ const width = phaseTasks.length ? (phaseVerified / phaseTasks.length) * 210 : 0;
768
+ return `<g class="row" tabindex="0" aria-label="${escapeXml(`${t.phase} ${phase.number}: ${phaseVerified} of ${phaseTasks.length} ${t.verified}`)}"><title>${escapeXml(`${t.phase} ${phase.number} ${phase.title}: ${phaseVerified} of ${phaseTasks.length} ${t.verified}`)}</title><text class="task" x="96" y="${y}">${escapeXml(`${t.phase} ${phase.number} · ${phase.title}`)}</text><text class="num" x="730" y="${y}" text-anchor="end">${phaseVerified} / ${phaseTasks.length}</text><text class="num" x="850" y="${y}" text-anchor="end">${phaseTasks.length - phaseVerified}</text><rect x="930" y="${y - 11}" width="210" height="12" rx="6" fill="var(--blue-wash)"/><rect x="930" y="${y - 11}" width="${width}" height="12" rx="6" fill="var(--aqua)"/>${statusIcon(1215, y - 5, phaseVerified === phaseTasks.length ? 'verified' : 'in-progress')}<text class="small" x="1228" y="${y}">${escapeXml(phaseVerified === phaseTasks.length ? statusLabel('verified', language) : `${phaseVerified}/${phaseTasks.length}`)}</text></g>`;
769
+ })
770
+ .join('\n');
771
+ const body = `<rect class="canvas" width="1600" height="${height}"/><rect class="card" x="32" y="28" width="1536" height="${height - 56}" rx="14"/>
772
+ <text class="title" x="72" y="84">${escapeXml(`${suiteName} ${t.burndownTitle}`)}</text><text class="subtitle" x="72" y="110">${escapeXml(t.burndownSubtitle)}</text><text class="small" x="1500" y="84" text-anchor="end">${escapeXml(`${t.reviewed}: ${reviewed}`)}</text>
773
+ <rect x="72" y="142" width="1428" height="116" rx="8" fill="var(--aqua-wash)"/><text class="heading" x="96" y="170">${escapeXml(t.current)}</text><text x="96" y="231" class="title" style="font-size:52px">${remaining}</text><text class="heading" x="155" y="215">${escapeXml(t.remaining)}</text><text class="caption" x="155" y="237">${escapeXml(`${verified} / ${total} ${t.verified}`)}</text><text class="caption" x="700" y="205">${escapeXml(t.approved)}</text><text class="heading" x="700" y="230">${total}</text><text class="caption" x="1030" y="205">${escapeXml(t.deferred)}</text><text class="heading" x="1030" y="230">${deferredTasks.length}</text>
774
+ <text class="heading" x="72" y="300">${escapeXml(t.scopeReference)}</text><text class="caption" x="72" y="321">${escapeXml(t.noHistory)}</text><rect class="card" x="72" y="340" width="940" height="390" rx="9"/><text class="small" x="116" y="372">${escapeXml(t.remainingAxis)}</text><text class="small" x="550" y="704" text-anchor="middle">${escapeXml(t.notTime)}</text>
775
+ <g class="grid"><line x1="${x0}" y1="${y0}" x2="${x1}" y2="${y0}"/><line x1="${x0}" y1="${y0 + 80}" x2="${x1}" y2="${y0 + 80}"/><line x1="${x0}" y1="${y0 + 160}" x2="${x1}" y2="${y0 + 160}"/><line x1="${x0}" y1="${y1}" x2="${x1}" y2="${y1}"/><line x1="${x0}" y1="${y0}" x2="${x0}" y2="${y1}"/><line x1="${x1}" y1="${y0}" x2="${x1}" y2="${y1}"/></g><line class="axis" x1="${x0}" y1="${y1}" x2="${x1}" y2="${y1}"/><line class="axis" x1="${x0}" y1="${y0}" x2="${x0}" y2="${y1}"/>
776
+ <line x1="${x0}" y1="${y0}" x2="${currentX.toFixed(1)}" y2="${currentY.toFixed(1)}" stroke="var(--blue)" stroke-width="2"/><line x1="${currentX.toFixed(1)}" y1="${currentY.toFixed(1)}" x2="${x1}" y2="${y1}" stroke="var(--blue)" stroke-width="2" stroke-dasharray="6 5"/><circle cx="${x0}" cy="${y0}" r="7" fill="var(--blue)" stroke="var(--surface)" stroke-width="2"/><text class="heading" x="${x0 + 22}" y="${y0 + 18}">${escapeXml(t.logicalBaseline)}</text><text class="caption" x="${x0 + 22}" y="${y0 + 37}">${total} ${escapeXml(t.remainingShort)}</text>${currentMarker}<text class="heading" x="${Math.max(x0 + 150, currentX - 22)}" y="${Math.max(y0 + 60, currentY - 49)}" text-anchor="end">${escapeXml(t.currentSnapshot)}</text><text class="caption" x="${Math.max(x0 + 150, currentX - 22)}" y="${Math.max(y0 + 79, currentY - 30)}" text-anchor="end">${escapeXml(`${remaining} ${t.remainingShort} · ${verified} ${t.verified}`)}</text>
777
+ <rect x="1040" y="340" width="460" height="390" rx="9" fill="var(--blue-wash)"/><text class="heading" x="1070" y="378">${escapeXml(t.howRead)}</text><text class="caption" x="1070" y="412">${escapeXml(t.formula)}</text><text class="caption" x="1070" y="448">${escapeXml(t.noHistory)}</text><text class="caption" x="1070" y="474">${escapeXml(t.caveat)}</text><text class="caption" x="1070" y="510">${escapeXml(t.chartCaveat)}</text>
778
+ <rect class="card" x="72" y="758" width="1428" height="${height - 846}" rx="9"/><text class="heading" x="96" y="786">${escapeXml(t.phaseScope)}</text><text class="small" x="96" y="800">${escapeXml(`${t.active}: ${total}; ${t.verified}: ${verified}; ${t.remaining}: ${remaining}`)}</text><text class="small" x="730" y="786" text-anchor="end">${escapeXml(t.verified)}</text><text class="small" x="850" y="786" text-anchor="end">${escapeXml(t.remaining)}</text><text class="small" x="930" y="786">${escapeXml(t.completion)}</text>${phaseRows}
779
+ <line class="grid" x1="72" y1="${height - 74}" x2="1500" y2="${height - 74}"/><text class="small" x="72" y="${height - 48}">${escapeXml(`${t.source}: pdp-wbs.md · progress.md (${t.reviewed}: ${reviewed})`)}</text><text class="small" x="1500" y="${height - 48}" text-anchor="end">${escapeXml(t.caveat)}</text>`;
780
+ return svgDocument(
781
+ `${suiteName} ${t.burndownTitle}`,
782
+ `${t.burndownTitle}. ${total} active WBS items; ${verified} verified; ${remaining} remaining. ${t.noHistory}`,
783
+ body,
784
+ height,
785
+ t.metadata,
786
+ );
787
+ }
788
+
789
+ function suiteNameFromReadme(readme, fallback) {
790
+ for (const line of readme.split(/\r?\n/)) {
791
+ if (!line.startsWith('# ') || line.startsWith('## ')) continue;
792
+ let title = line.slice(2).trim();
793
+ for (const suffix of ['Internal Planning', '内部规划']) {
794
+ if (title.endsWith(suffix)) {
795
+ title = title.slice(0, -suffix.length).trimEnd();
796
+ }
797
+ }
798
+ return title || fallback;
799
+ }
800
+ return fallback;
801
+ }
802
+
803
+ export async function generateCharts(directory, { check = false } = {}) {
804
+ const root = resolve(directory);
805
+ const [readme, wbs, progress, testPlan] = await Promise.all(
806
+ ['README.md', 'pdp-wbs.md', 'progress.md', 'test-plan.md'].map(name =>
807
+ readFile(resolve(root, name), 'utf8'),
808
+ ),
809
+ );
810
+ const model = createChartModel({ readme, wbs, progress, testPlan });
811
+ const suiteName = suiteNameFromReadme(readme, basename(root));
812
+ const artifacts = new Map([
813
+ ['implementation-gantt.svg', renderGantt(model, suiteName)],
814
+ ['implementation-burndown.svg', renderBurndown(model, suiteName)],
815
+ ]);
816
+ const stale = [];
817
+ for (const [name, content] of artifacts) {
818
+ const path = resolve(root, name);
819
+ let current;
820
+ try {
821
+ current = await readFile(path, 'utf8');
822
+ } catch {
823
+ current = undefined;
824
+ }
825
+ if (current !== content) stale.push(name);
826
+ if (!check && current !== content) await writeFile(path, content);
827
+ }
828
+ if (check && stale.length) {
829
+ throw new Error(
830
+ `Derived implementation charts are stale: ${stale.join(', ')}. Run \`npm run spec:charts -- ${basename(root)}\`.`,
831
+ );
832
+ }
833
+ return { model, artifacts, stale };
834
+ }
835
+
836
+ async function main() {
837
+ const args = process.argv.slice(2);
838
+ const check = args.includes('--check');
839
+ const target = args.find(arg => !arg.startsWith('--'));
840
+ if (!target) {
841
+ throw new Error(
842
+ 'Usage: generate-implementation-charts.mjs [--check] <repo-specs/<suite>|<suite>.',
843
+ );
844
+ }
845
+ const directory = target.includes('/') ? target : `repo-specs/${target}`;
846
+ const result = await generateCharts(directory, { check });
847
+ const mode = check ? 'checked' : 'generated';
848
+ process.stdout.write(
849
+ `${mode} implementation-gantt.svg and implementation-burndown.svg for ${directory} (${result.model.language}; ${result.model.verified}/${result.model.activeTasks.length} verified).\n`,
850
+ );
851
+ }
852
+
853
+ if (import.meta.url === `file://${process.argv[1]}`) {
854
+ main().catch(error => {
855
+ console.error(`spec chart generation failed: ${error.message}`);
856
+ process.exitCode = 1;
857
+ });
858
+ }