@hegemonart/get-design-done 1.19.6 → 1.20.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 (93) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/CHANGELOG.md +60 -0
  4. package/README.md +12 -0
  5. package/agents/design-reflector.md +13 -0
  6. package/connections/connections.md +3 -0
  7. package/connections/figma.md +2 -0
  8. package/connections/gdd-state.md +186 -0
  9. package/hooks/budget-enforcer.ts +716 -0
  10. package/hooks/context-exhaustion.ts +251 -0
  11. package/hooks/gdd-read-injection-scanner.ts +172 -0
  12. package/hooks/hooks.json +3 -3
  13. package/package.json +19 -6
  14. package/reference/config-schema.md +2 -2
  15. package/reference/error-recovery.md +58 -0
  16. package/reference/registry.json +7 -0
  17. package/reference/schemas/budget.schema.json +42 -0
  18. package/reference/schemas/events.schema.json +55 -0
  19. package/reference/schemas/generated.d.ts +419 -0
  20. package/reference/schemas/iteration-budget.schema.json +36 -0
  21. package/reference/schemas/mcp-gdd-state-tools.schema.json +89 -0
  22. package/reference/schemas/rate-limits.schema.json +31 -0
  23. package/scripts/aggregate-agent-metrics.ts +282 -0
  24. package/scripts/codegen-schema-types.ts +149 -0
  25. package/scripts/lib/error-classifier.cjs +232 -0
  26. package/scripts/lib/error-classifier.d.cts +44 -0
  27. package/scripts/lib/event-stream/emitter.ts +88 -0
  28. package/scripts/lib/event-stream/index.ts +154 -0
  29. package/scripts/lib/event-stream/types.ts +127 -0
  30. package/scripts/lib/event-stream/writer.ts +154 -0
  31. package/scripts/lib/gdd-errors/classification.ts +124 -0
  32. package/scripts/lib/gdd-errors/index.ts +218 -0
  33. package/scripts/lib/gdd-state/gates.ts +216 -0
  34. package/scripts/lib/gdd-state/index.ts +167 -0
  35. package/scripts/lib/gdd-state/lockfile.ts +232 -0
  36. package/scripts/lib/gdd-state/mutator.ts +574 -0
  37. package/scripts/lib/gdd-state/parser.ts +523 -0
  38. package/scripts/lib/gdd-state/types.ts +179 -0
  39. package/scripts/lib/iteration-budget.cjs +205 -0
  40. package/scripts/lib/iteration-budget.d.cts +32 -0
  41. package/scripts/lib/jittered-backoff.cjs +112 -0
  42. package/scripts/lib/jittered-backoff.d.cts +38 -0
  43. package/scripts/lib/lockfile.cjs +177 -0
  44. package/scripts/lib/lockfile.d.cts +21 -0
  45. package/scripts/lib/prompt-sanitizer/index.ts +435 -0
  46. package/scripts/lib/prompt-sanitizer/patterns.ts +173 -0
  47. package/scripts/lib/rate-guard.cjs +365 -0
  48. package/scripts/lib/rate-guard.d.cts +38 -0
  49. package/scripts/mcp-servers/gdd-state/schemas/add_blocker.schema.json +67 -0
  50. package/scripts/mcp-servers/gdd-state/schemas/add_decision.schema.json +68 -0
  51. package/scripts/mcp-servers/gdd-state/schemas/add_must_have.schema.json +68 -0
  52. package/scripts/mcp-servers/gdd-state/schemas/checkpoint.schema.json +51 -0
  53. package/scripts/mcp-servers/gdd-state/schemas/frontmatter_update.schema.json +62 -0
  54. package/scripts/mcp-servers/gdd-state/schemas/get.schema.json +51 -0
  55. package/scripts/mcp-servers/gdd-state/schemas/probe_connections.schema.json +75 -0
  56. package/scripts/mcp-servers/gdd-state/schemas/resolve_blocker.schema.json +66 -0
  57. package/scripts/mcp-servers/gdd-state/schemas/set_status.schema.json +47 -0
  58. package/scripts/mcp-servers/gdd-state/schemas/transition_stage.schema.json +70 -0
  59. package/scripts/mcp-servers/gdd-state/schemas/update_progress.schema.json +58 -0
  60. package/scripts/mcp-servers/gdd-state/server.ts +288 -0
  61. package/scripts/mcp-servers/gdd-state/tools/add_blocker.ts +72 -0
  62. package/scripts/mcp-servers/gdd-state/tools/add_decision.ts +89 -0
  63. package/scripts/mcp-servers/gdd-state/tools/add_must_have.ts +113 -0
  64. package/scripts/mcp-servers/gdd-state/tools/checkpoint.ts +60 -0
  65. package/scripts/mcp-servers/gdd-state/tools/frontmatter_update.ts +91 -0
  66. package/scripts/mcp-servers/gdd-state/tools/get.ts +51 -0
  67. package/scripts/mcp-servers/gdd-state/tools/index.ts +51 -0
  68. package/scripts/mcp-servers/gdd-state/tools/probe_connections.ts +73 -0
  69. package/scripts/mcp-servers/gdd-state/tools/resolve_blocker.ts +84 -0
  70. package/scripts/mcp-servers/gdd-state/tools/set_status.ts +54 -0
  71. package/scripts/mcp-servers/gdd-state/tools/shared.ts +194 -0
  72. package/scripts/mcp-servers/gdd-state/tools/transition_stage.ts +80 -0
  73. package/scripts/mcp-servers/gdd-state/tools/update_progress.ts +81 -0
  74. package/scripts/validate-frontmatter.ts +114 -0
  75. package/scripts/validate-schemas.ts +401 -0
  76. package/skills/brief/SKILL.md +15 -6
  77. package/skills/design/SKILL.md +31 -13
  78. package/skills/explore/SKILL.md +41 -17
  79. package/skills/health/SKILL.md +15 -4
  80. package/skills/optimize/SKILL.md +3 -3
  81. package/skills/pause/SKILL.md +16 -10
  82. package/skills/plan/SKILL.md +33 -17
  83. package/skills/progress/SKILL.md +15 -11
  84. package/skills/resume/SKILL.md +19 -10
  85. package/skills/settings/SKILL.md +11 -3
  86. package/skills/todo/SKILL.md +12 -3
  87. package/skills/verify/SKILL.md +65 -29
  88. package/hooks/budget-enforcer.js +0 -329
  89. package/hooks/context-exhaustion.js +0 -127
  90. package/hooks/gdd-read-injection-scanner.js +0 -39
  91. package/scripts/aggregate-agent-metrics.js +0 -173
  92. package/scripts/validate-frontmatter.cjs +0 -68
  93. package/scripts/validate-schemas.cjs +0 -242
@@ -0,0 +1,523 @@
1
+ // scripts/lib/gdd-state/parser.ts — turns STATE.md text into ParsedState.
2
+ //
3
+ // Design constraints:
4
+ // 1. Pure function (no I/O). `read()` in index.ts supplies the file.
5
+ // 2. Byte-identical round-trip for well-formed files. The serializer
6
+ // uses `body_preamble` / `body_trailer` + each block's raw body text
7
+ // to reproduce the input exactly (when inner arrays were not mutated).
8
+ // 3. No YAML dependency. STATE frontmatter is flat `key: value`.
9
+ // 4. Tolerant of unknown frontmatter keys, unknown blocks, extra blank
10
+ // lines — but rejects fundamentally broken structure (missing
11
+ // closing frontmatter, unterminated `<block>` tags).
12
+ // 5. Preserves each block's raw body so the serializer can emit it
13
+ // verbatim when the consumer did not touch the parsed structure.
14
+ //
15
+ // Block tag conventions (from reference/STATE-TEMPLATE.md):
16
+ // <position> ... </position>
17
+ // <decisions> ... </decisions>
18
+ // <must_haves> ... </must_haves>
19
+ // <connections> ... </connections>
20
+ // <blockers> ... </blockers>
21
+ // <parallelism_decision> ... </parallelism_decision>
22
+ // <todos> ... </todos>
23
+ // <timestamps> ... </timestamps>
24
+ //
25
+ // Tags always appear at column 0, each on its own line. Bodies may span
26
+ // multiple lines and may contain `<!-- ... -->` comments (which are
27
+ // preserved in `raw_bodies` for round-trip).
28
+
29
+ import {
30
+ ParseError,
31
+ isConnectionStatus,
32
+ isDecisionStatus,
33
+ isMustHaveStatus,
34
+ type Blocker,
35
+ type ConnectionStatus,
36
+ type Decision,
37
+ type DecisionStatus,
38
+ type Frontmatter,
39
+ type MustHave,
40
+ type MustHaveStatus,
41
+ type ParsedState,
42
+ type Position,
43
+ } from './types.ts';
44
+
45
+ /** Block names recognized by the parser in canonical order. */
46
+ export const BLOCK_ORDER = [
47
+ 'position',
48
+ 'decisions',
49
+ 'must_haves',
50
+ 'connections',
51
+ 'blockers',
52
+ 'parallelism_decision',
53
+ 'todos',
54
+ 'timestamps',
55
+ ] as const;
56
+
57
+ export type BlockName = (typeof BLOCK_ORDER)[number];
58
+
59
+ /** Raw bodies captured from the input; used by the serializer to preserve
60
+ * formatting when a block's parsed representation round-trips unchanged. */
61
+ export interface RawBlockBodies {
62
+ position: string | null;
63
+ decisions: string | null;
64
+ must_haves: string | null;
65
+ connections: string | null;
66
+ blockers: string | null;
67
+ parallelism_decision: string | null;
68
+ todos: string | null;
69
+ timestamps: string | null;
70
+ }
71
+
72
+ /** Separator text appearing BEFORE each block's opening tag, counted from
73
+ * the end of the previous recognized block (or from the end of the
74
+ * frontmatter for the first block). Captures the blank lines and
75
+ * free-form markdown that template authors place between blocks. */
76
+ export interface BlockGaps {
77
+ position: string;
78
+ decisions: string;
79
+ must_haves: string;
80
+ connections: string;
81
+ blockers: string;
82
+ parallelism_decision: string;
83
+ todos: string;
84
+ timestamps: string;
85
+ }
86
+
87
+ /** Full parser result — `ParsedState` plus the raw bodies map and raw
88
+ * frontmatter. The `raw_bodies` map is consumed by `serialize()` so
89
+ * untouched blocks can emit verbatim. */
90
+ export interface ParseResult {
91
+ state: ParsedState;
92
+ raw_bodies: RawBlockBodies;
93
+ /** Verbatim frontmatter body (between the `---` fences). Serializer
94
+ * emits it back when `state.frontmatter` is semantically unchanged. */
95
+ raw_frontmatter: string;
96
+ /** Map of per-block preceding separators. Serializer emits these before
97
+ * each present block. */
98
+ block_gaps: BlockGaps;
99
+ /** Detected line-ending: '\n' or '\r\n'. Serializer emits this back. */
100
+ line_ending: '\n' | '\r\n';
101
+ /** True when the last byte of the original input was a newline. */
102
+ trailing_newline: boolean;
103
+ }
104
+
105
+ const EMPTY_RAW_BODIES: RawBlockBodies = {
106
+ position: null,
107
+ decisions: null,
108
+ must_haves: null,
109
+ connections: null,
110
+ blockers: null,
111
+ parallelism_decision: null,
112
+ todos: null,
113
+ timestamps: null,
114
+ };
115
+
116
+ /**
117
+ * Parse STATE.md text.
118
+ *
119
+ * @throws ParseError on structurally invalid input.
120
+ */
121
+ export function parse(raw: string): ParseResult {
122
+ // Normalize line endings for parsing; remember the original choice so
123
+ // the serializer emits them back. We do the substring math on the
124
+ // normalized string (simpler to reason about); the serializer converts
125
+ // back when writing.
126
+ const line_ending: '\n' | '\r\n' = raw.includes('\r\n') ? '\r\n' : '\n';
127
+ const normalized: string = line_ending === '\r\n' ? raw.replace(/\r\n/g, '\n') : raw;
128
+ const trailing_newline: boolean = normalized.endsWith('\n');
129
+
130
+ // --- 1. Frontmatter --------------------------------------------------
131
+ if (!normalized.startsWith('---\n')) {
132
+ throw new ParseError('file must begin with "---" frontmatter fence', 1);
133
+ }
134
+ const fmEnd: number = normalized.indexOf('\n---\n', 4);
135
+ if (fmEnd === -1) {
136
+ throw new ParseError('unterminated frontmatter (missing closing "---")', 1);
137
+ }
138
+ const fmText: string = normalized.slice(4, fmEnd);
139
+ const frontmatter: Frontmatter = parseFrontmatter(fmText);
140
+ const afterFm: number = fmEnd + 5; // past "\n---\n"
141
+
142
+ // --- 2. Body scan (locate blocks) ------------------------------------
143
+ const body: string = normalized.slice(afterFm);
144
+ const lines: string[] = body.split('\n');
145
+ // Track line ranges (inclusive) for each recognized block.
146
+ const blocks: Array<{ name: BlockName; openLine: number; closeLine: number }> = [];
147
+ const seen = new Set<BlockName>();
148
+ const blockOpen = /^<([a-z_]+)>\s*$/;
149
+ const blockClose = /^<\/([a-z_]+)>\s*$/;
150
+
151
+ for (let i = 0; i < lines.length; i++) {
152
+ const line = lines[i] ?? '';
153
+ const openMatch = line.match(blockOpen);
154
+ if (!openMatch) continue;
155
+ const name = openMatch[1] as BlockName;
156
+ if (!BLOCK_ORDER.includes(name)) {
157
+ // Unknown block — skip (forward compat); don't record.
158
+ continue;
159
+ }
160
+ if (seen.has(name)) {
161
+ // Duplicate block — first one wins; reject to avoid silent drops.
162
+ throw new ParseError(`duplicate block <${name}>`, lineToFileLine(afterFm, normalized, i));
163
+ }
164
+ // Find matching close at the same col-0 position.
165
+ let close = -1;
166
+ for (let j = i + 1; j < lines.length; j++) {
167
+ const cm = (lines[j] ?? '').match(blockClose);
168
+ if (cm && cm[1] === name) {
169
+ close = j;
170
+ break;
171
+ }
172
+ }
173
+ if (close === -1) {
174
+ throw new ParseError(
175
+ `unterminated block <${name}> (no </${name}>)`,
176
+ lineToFileLine(afterFm, normalized, i),
177
+ );
178
+ }
179
+ blocks.push({ name, openLine: i, closeLine: close });
180
+ seen.add(name);
181
+ i = close; // continue scanning after the close tag
182
+ }
183
+
184
+ // --- 3. Compute body_preamble / body_trailer / block_gaps ------------
185
+ // body_preamble = text between frontmatter-end and first block's <tag>.
186
+ // body_trailer = text after the last block's </tag>.
187
+ // block_gaps[name] = text between the previous recognized block's </tag>
188
+ // and this block's <tag>. For the first block this
189
+ // equals body_preamble.
190
+ let body_preamble: string;
191
+ let body_trailer: string;
192
+ const block_gaps: BlockGaps = {
193
+ position: '',
194
+ decisions: '',
195
+ must_haves: '',
196
+ connections: '',
197
+ blockers: '',
198
+ parallelism_decision: '',
199
+ todos: '',
200
+ timestamps: '',
201
+ };
202
+ if (blocks.length === 0) {
203
+ // No recognized blocks at all — everything is preamble; trailer is empty.
204
+ body_preamble = body;
205
+ body_trailer = '';
206
+ } else {
207
+ const firstBlock = blocks[0];
208
+ const lastBlock = blocks[blocks.length - 1];
209
+ if (firstBlock === undefined || lastBlock === undefined) {
210
+ // unreachable — length-checked above — but keeps noUncheckedIndexedAccess happy.
211
+ throw new ParseError('internal: block index inconsistency', 1);
212
+ }
213
+ body_preamble = lines.slice(0, firstBlock.openLine).join('\n');
214
+ if (firstBlock.openLine > 0) body_preamble += '\n';
215
+ body_trailer = lines.slice(lastBlock.closeLine + 1).join('\n');
216
+
217
+ // Populate block_gaps: preceding separator for each block.
218
+ for (let bi = 0; bi < blocks.length; bi++) {
219
+ const cur = blocks[bi];
220
+ if (cur === undefined) continue;
221
+ if (bi === 0) {
222
+ block_gaps[cur.name] = body_preamble;
223
+ } else {
224
+ const prev = blocks[bi - 1];
225
+ if (prev === undefined) continue;
226
+ // Text strictly between prev.closeLine and cur.openLine (exclusive).
227
+ // That's lines[prev.closeLine+1 .. cur.openLine-1] joined by '\n',
228
+ // plus a trailing '\n' if cur.openLine > prev.closeLine + 1 (to
229
+ // separate the gap content from the opening tag).
230
+ const gapLines = lines.slice(prev.closeLine + 1, cur.openLine);
231
+ let gap = gapLines.join('\n');
232
+ if (cur.openLine > prev.closeLine + 1) gap += '\n';
233
+ block_gaps[cur.name] = gap;
234
+ }
235
+ }
236
+ }
237
+
238
+ // --- 4. Parse each block ---------------------------------------------
239
+ const raw_bodies: RawBlockBodies = { ...EMPTY_RAW_BODIES };
240
+ let position: Position | null = null;
241
+ let decisions: Decision[] = [];
242
+ let must_haves: MustHave[] = [];
243
+ let connections: Record<string, ConnectionStatus> = {};
244
+ let blockers: Blocker[] = [];
245
+ let parallelism_decision: string | null = null;
246
+ let todos: string | null = null;
247
+ let timestamps: Record<string, string> = {};
248
+
249
+ for (const blk of blocks) {
250
+ const rawBody: string = lines
251
+ .slice(blk.openLine + 1, blk.closeLine)
252
+ .join('\n');
253
+ raw_bodies[blk.name] = rawBody;
254
+ const fileLineOfBody = lineToFileLine(afterFm, normalized, blk.openLine + 1);
255
+ switch (blk.name) {
256
+ case 'position':
257
+ position = parsePositionBody(rawBody, fileLineOfBody);
258
+ break;
259
+ case 'decisions':
260
+ decisions = parseDecisionsBody(rawBody, fileLineOfBody);
261
+ break;
262
+ case 'must_haves':
263
+ must_haves = parseMustHavesBody(rawBody, fileLineOfBody);
264
+ break;
265
+ case 'connections':
266
+ connections = parseConnectionsBody(rawBody, fileLineOfBody);
267
+ break;
268
+ case 'blockers':
269
+ blockers = parseBlockersBody(rawBody, fileLineOfBody);
270
+ break;
271
+ case 'parallelism_decision':
272
+ parallelism_decision = rawBody;
273
+ break;
274
+ case 'todos':
275
+ todos = rawBody;
276
+ break;
277
+ case 'timestamps':
278
+ timestamps = parseTimestampsBody(rawBody, fileLineOfBody);
279
+ break;
280
+ default: {
281
+ // Exhaustive — TS enforces.
282
+ const _exhaustive: never = blk.name;
283
+ void _exhaustive;
284
+ }
285
+ }
286
+ }
287
+
288
+ // --- 5. Backfill defaults for absent mandatory blocks ----------------
289
+ // `<position>` is the one block we MUST have to be semantically usable,
290
+ // because `mutate()` and `transition()` read/write it. Refuse to parse
291
+ // a STATE.md that lacks it — callers should have run scan first.
292
+ if (position === null) {
293
+ throw new ParseError(
294
+ 'missing required <position> block (run scan to initialize STATE.md)',
295
+ 1,
296
+ );
297
+ }
298
+
299
+ const state: ParsedState = {
300
+ frontmatter,
301
+ position,
302
+ decisions,
303
+ must_haves,
304
+ connections,
305
+ blockers,
306
+ parallelism_decision,
307
+ todos,
308
+ timestamps,
309
+ body_preamble,
310
+ body_trailer,
311
+ };
312
+
313
+ return {
314
+ state,
315
+ raw_bodies,
316
+ raw_frontmatter: fmText,
317
+ block_gaps,
318
+ line_ending,
319
+ trailing_newline,
320
+ };
321
+ }
322
+
323
+ /** --- helpers --- */
324
+
325
+ function lineToFileLine(bodyStartOffset: number, normalized: string, bodyLineIdx: number): number {
326
+ // Count newlines from 0 to bodyStartOffset plus the body line index.
327
+ const prefix: string = normalized.slice(0, bodyStartOffset);
328
+ const prefixLines: number = prefix.split('\n').length - 1;
329
+ return prefixLines + bodyLineIdx + 1; // 1-indexed
330
+ }
331
+
332
+ function parseFrontmatter(raw: string): Frontmatter {
333
+ const out: Record<string, unknown> = {};
334
+ const lines = raw.split('\n');
335
+ for (const line of lines) {
336
+ const trimmed = line.trim();
337
+ if (trimmed === '' || trimmed.startsWith('#')) continue;
338
+ const idx = line.indexOf(':');
339
+ if (idx === -1) continue;
340
+ const key = line.slice(0, idx).trim();
341
+ let value: string = line.slice(idx + 1).trim();
342
+ if (
343
+ (value.startsWith('"') && value.endsWith('"')) ||
344
+ (value.startsWith("'") && value.endsWith("'"))
345
+ ) {
346
+ value = value.slice(1, -1);
347
+ }
348
+ // Numeric coercion for `wave` only (template-defined numeric field).
349
+ if (key === 'wave') {
350
+ const n = Number(value);
351
+ out[key] = Number.isFinite(n) ? n : value;
352
+ } else {
353
+ out[key] = value;
354
+ }
355
+ }
356
+ // Fill required keys with sensible defaults if missing (tolerant parse —
357
+ // stages should have created a valid frontmatter via the template).
358
+ const fm: Frontmatter = {
359
+ pipeline_state_version: String(out['pipeline_state_version'] ?? '1.0'),
360
+ stage: String(out['stage'] ?? ''),
361
+ cycle: String(out['cycle'] ?? ''),
362
+ wave: typeof out['wave'] === 'number' ? (out['wave'] as number) : 1,
363
+ started_at: String(out['started_at'] ?? ''),
364
+ last_checkpoint: String(out['last_checkpoint'] ?? ''),
365
+ };
366
+ // Copy any extra keys unchanged.
367
+ for (const [k, v] of Object.entries(out)) {
368
+ if (!(k in fm)) fm[k] = v;
369
+ }
370
+ return fm;
371
+ }
372
+
373
+ function parsePositionBody(body: string, startLine: number): Position {
374
+ const fields: Record<string, string> = {};
375
+ const lines = body.split('\n');
376
+ for (let i = 0; i < lines.length; i++) {
377
+ const line = lines[i] ?? '';
378
+ const trimmed = line.trim();
379
+ if (trimmed === '' || trimmed.startsWith('<!--')) continue;
380
+ const idx = line.indexOf(':');
381
+ if (idx === -1) continue;
382
+ const key = line.slice(0, idx).trim();
383
+ let value = line.slice(idx + 1).trim();
384
+ if (
385
+ (value.startsWith('"') && value.endsWith('"')) ||
386
+ (value.startsWith("'") && value.endsWith("'"))
387
+ ) {
388
+ value = value.slice(1, -1);
389
+ }
390
+ fields[key] = value;
391
+ }
392
+ const stage = fields['stage'] ?? '';
393
+ const waveRaw = fields['wave'] ?? '1';
394
+ const waveNum = Number(waveRaw);
395
+ if (!Number.isFinite(waveNum)) {
396
+ throw new ParseError(`<position> wave not numeric: ${waveRaw}`, startLine);
397
+ }
398
+ return {
399
+ stage,
400
+ wave: waveNum,
401
+ task_progress: fields['task_progress'] ?? '0/0',
402
+ status: fields['status'] ?? 'initialized',
403
+ handoff_source: fields['handoff_source'] ?? '',
404
+ handoff_path: fields['handoff_path'] ?? '',
405
+ skipped_stages: fields['skipped_stages'] ?? '',
406
+ };
407
+ }
408
+
409
+ function parseDecisionsBody(body: string, startLine: number): Decision[] {
410
+ const out: Decision[] = [];
411
+ const lines = body.split('\n');
412
+ // D-NN: text (locked|tentative)
413
+ const re = /^(D-\d+):\s*(.*?)\s*\((locked|tentative)\)\s*$/;
414
+ for (let i = 0; i < lines.length; i++) {
415
+ const line = (lines[i] ?? '').trim();
416
+ if (line === '' || line.startsWith('<!--')) continue;
417
+ const m = line.match(re);
418
+ if (!m) {
419
+ // Non-matching non-comment line — tolerate (may be a stray note).
420
+ continue;
421
+ }
422
+ const id = m[1] ?? '';
423
+ const text = m[2] ?? '';
424
+ const status = m[3] as DecisionStatus;
425
+ if (!isDecisionStatus(status)) {
426
+ throw new ParseError(
427
+ `<decisions> invalid status for ${id}: ${status}`,
428
+ startLine + i,
429
+ );
430
+ }
431
+ out.push({ id, text, status });
432
+ }
433
+ return out;
434
+ }
435
+
436
+ function parseMustHavesBody(body: string, startLine: number): MustHave[] {
437
+ const out: MustHave[] = [];
438
+ const lines = body.split('\n');
439
+ // M-NN: text | status: pending|pass|fail
440
+ const re = /^(M-\d+):\s*(.*?)\s*\|\s*status:\s*(pending|pass|fail)\s*$/;
441
+ for (let i = 0; i < lines.length; i++) {
442
+ const line = (lines[i] ?? '').trim();
443
+ if (line === '' || line.startsWith('<!--')) continue;
444
+ const m = line.match(re);
445
+ if (!m) continue;
446
+ const id = m[1] ?? '';
447
+ const text = m[2] ?? '';
448
+ const status = m[3] as MustHaveStatus;
449
+ if (!isMustHaveStatus(status)) {
450
+ throw new ParseError(
451
+ `<must_haves> invalid status for ${id}: ${status}`,
452
+ startLine + i,
453
+ );
454
+ }
455
+ out.push({ id, text, status });
456
+ }
457
+ return out;
458
+ }
459
+
460
+ function parseConnectionsBody(
461
+ body: string,
462
+ startLine: number,
463
+ ): Record<string, ConnectionStatus> {
464
+ const out: Record<string, ConnectionStatus> = {};
465
+ const lines = body.split('\n');
466
+ for (let i = 0; i < lines.length; i++) {
467
+ const line = lines[i] ?? '';
468
+ const trimmed = line.trim();
469
+ if (trimmed === '' || trimmed.startsWith('<!--')) continue;
470
+ const idx = line.indexOf(':');
471
+ if (idx === -1) continue;
472
+ const key = line.slice(0, idx).trim();
473
+ const value = line.slice(idx + 1).trim();
474
+ if (!isConnectionStatus(value)) {
475
+ throw new ParseError(
476
+ `<connections> invalid status for ${key}: ${value}`,
477
+ startLine + i,
478
+ );
479
+ }
480
+ out[key] = value;
481
+ }
482
+ return out;
483
+ }
484
+
485
+ function parseBlockersBody(body: string, startLine: number): Blocker[] {
486
+ const out: Blocker[] = [];
487
+ const lines = body.split('\n');
488
+ // [stage] [YYYY-MM-DD or ISO]: text
489
+ const re = /^\[([^\]]+)\]\s*\[([^\]]+)\]:\s*(.*)$/;
490
+ for (let i = 0; i < lines.length; i++) {
491
+ const line = (lines[i] ?? '').trim();
492
+ if (line === '' || line.startsWith('<!--')) continue;
493
+ const m = line.match(re);
494
+ if (!m) {
495
+ // Malformed blocker line — throw so operators see it rather than
496
+ // silently dropping a blocker (Rule 1: correctness over tolerance).
497
+ throw new ParseError(
498
+ `<blockers> malformed line: "${line}"`,
499
+ startLine + i,
500
+ );
501
+ }
502
+ out.push({ stage: m[1] ?? '', date: m[2] ?? '', text: m[3] ?? '' });
503
+ }
504
+ return out;
505
+ }
506
+
507
+ function parseTimestampsBody(
508
+ body: string,
509
+ _startLine: number,
510
+ ): Record<string, string> {
511
+ const out: Record<string, string> = {};
512
+ const lines = body.split('\n');
513
+ for (const line of lines) {
514
+ const trimmed = line.trim();
515
+ if (trimmed === '' || trimmed.startsWith('<!--')) continue;
516
+ const idx = line.indexOf(':');
517
+ if (idx === -1) continue;
518
+ const key = line.slice(0, idx).trim();
519
+ const value = line.slice(idx + 1).trim();
520
+ out[key] = value;
521
+ }
522
+ return out;
523
+ }
@@ -0,0 +1,179 @@
1
+ // scripts/lib/gdd-state/types.ts — typed shape of a parsed STATE.md.
2
+ //
3
+ // Plan 20-01 (SDK-01/02): canonical type surface consumed by the parser,
4
+ // mutator, and public read/mutate/transition API. Everything here is
5
+ // erasable so Node 22 `--experimental-strip-types` runs the module
6
+ // without a bundler step.
7
+ //
8
+ // The shape mirrors the blocks declared in reference/STATE-TEMPLATE.md:
9
+ // <position>, <decisions>, <must_haves>, <connections>, <blockers>,
10
+ // <parallelism_decision>, <todos>, <timestamps>
11
+ // plus leading YAML frontmatter and the verbatim `body_preamble` /
12
+ // `body_trailer` spans that surround the typed blocks.
13
+
14
+ /**
15
+ * Pipeline stage identifiers, per reference/STATE-TEMPLATE.md.
16
+ * The pipeline also recognizes `scan` as a transitional/initial stage name
17
+ * emitted by the installer; it is NOT in the Plan 20-01 `Stage` contract
18
+ * and callers that receive `scan` from a parsed STATE.md must treat it
19
+ * with care (see parser tolerance below).
20
+ */
21
+ export type Stage = 'brief' | 'explore' | 'plan' | 'design' | 'verify';
22
+
23
+ /** Lifecycle status of the active `<position>` block. */
24
+ export type PositionStatus =
25
+ | 'initialized'
26
+ | 'in_progress'
27
+ | 'completed'
28
+ | 'blocked';
29
+
30
+ /** Availability classification for one entry in `<connections>`. */
31
+ export type ConnectionStatus = 'available' | 'unavailable' | 'not_configured';
32
+
33
+ /** Lock/tentative state for a `<decisions>` entry. */
34
+ export type DecisionStatus = 'locked' | 'tentative';
35
+
36
+ /** Verification state for a `<must_haves>` entry. */
37
+ export type MustHaveStatus = 'pending' | 'pass' | 'fail';
38
+
39
+ /**
40
+ * Frontmatter block (between leading `---` fences). STATE.md frontmatter is
41
+ * flat `key: value` — we parse it with a tiny hand-rolled reader (no YAML
42
+ * dep). Unknown keys are preserved via the string-indexed fall-through so
43
+ * downstream plans (e.g. 20-02 adding `model_profile`) can read/write new
44
+ * fields without a parser change.
45
+ */
46
+ export interface Frontmatter {
47
+ pipeline_state_version: string;
48
+ /**
49
+ * Raw stage string as it appeared in the file. Kept as a broad `string`
50
+ * because the template permits `scan` pre-brief, which is not part of
51
+ * the tight `Stage` union. Use `toStage()` helpers to narrow.
52
+ */
53
+ stage: string;
54
+ cycle: string;
55
+ wave: number;
56
+ started_at: string;
57
+ last_checkpoint: string;
58
+ [k: string]: unknown;
59
+ }
60
+
61
+ /** Parsed `<position>` block. Strings preserve whatever the file held. */
62
+ export interface Position {
63
+ stage: string;
64
+ wave: number;
65
+ task_progress: string;
66
+ status: string;
67
+ handoff_source: string;
68
+ handoff_path: string;
69
+ skipped_stages: string;
70
+ }
71
+
72
+ /** Single entry of `<decisions>`. */
73
+ export interface Decision {
74
+ id: string;
75
+ text: string;
76
+ status: DecisionStatus;
77
+ }
78
+
79
+ /** Single entry of `<must_haves>`. */
80
+ export interface MustHave {
81
+ id: string;
82
+ text: string;
83
+ status: MustHaveStatus;
84
+ }
85
+
86
+ /** Single entry of `<blockers>` (append-only log). */
87
+ export interface Blocker {
88
+ stage: string;
89
+ date: string;
90
+ text: string;
91
+ }
92
+
93
+ /**
94
+ * Canonical parsed shape of a STATE.md file. Consumers mutate this in-place
95
+ * inside `mutate(path, fn)`, then the serializer projects it back to
96
+ * markdown.
97
+ *
98
+ * `body_preamble` captures the span between the closing frontmatter `---`
99
+ * and the first recognized block; `body_trailer` captures everything after
100
+ * the last recognized block. Both are preserved verbatim to guarantee
101
+ * byte-identical round-trips for files that hold user-authored content
102
+ * (titles, decorative headings) in those regions.
103
+ */
104
+ export interface ParsedState {
105
+ frontmatter: Frontmatter;
106
+ position: Position;
107
+ decisions: Decision[];
108
+ must_haves: MustHave[];
109
+ connections: Record<string, ConnectionStatus>;
110
+ blockers: Blocker[];
111
+ parallelism_decision: string | null;
112
+ /**
113
+ * Body of the `<todos>` block, verbatim (without the opening/closing
114
+ * tags). `null` when the block is absent. The template ships this block
115
+ * with illustrative comments, so most fresh files carry a non-null body.
116
+ */
117
+ todos: string | null;
118
+ timestamps: Record<string, string>;
119
+ /** Verbatim span between frontmatter end and the first recognized block. */
120
+ body_preamble: string;
121
+ /** Verbatim span after the last recognized block. */
122
+ body_trailer: string;
123
+ }
124
+
125
+ /** Raw shape of a transition gate response (Plan 20-02 supplies the body). */
126
+ export interface GateResult {
127
+ pass: boolean;
128
+ blockers: string[];
129
+ }
130
+
131
+ /** Result of a successful `transition()` call. */
132
+ export interface TransitionResult extends GateResult {
133
+ state: ParsedState;
134
+ }
135
+
136
+ // Error classes migrated to the unified GDDError taxonomy in Plan 20-04.
137
+ // Re-exported here so existing consumers (tests, downstream modules) keep
138
+ // importing from `gdd-state/types.ts` unchanged.
139
+ //
140
+ // * TransitionGateFailed — StateConflictError subclass; retryable
141
+ // * LockAcquisitionError — StateConflictError subclass; retryable
142
+ // * ParseError — ValidationError subclass; fix your STATE.md
143
+ //
144
+ // See `scripts/lib/gdd-errors/index.ts` for the taxonomy definition.
145
+ export {
146
+ TransitionGateFailed,
147
+ LockAcquisitionError,
148
+ ParseError,
149
+ } from '../gdd-errors/index.ts';
150
+
151
+ /** Type-guard for `Stage`. */
152
+ export function isStage(value: unknown): value is Stage {
153
+ return (
154
+ value === 'brief' ||
155
+ value === 'explore' ||
156
+ value === 'plan' ||
157
+ value === 'design' ||
158
+ value === 'verify'
159
+ );
160
+ }
161
+
162
+ /** Type-guard for `ConnectionStatus`. */
163
+ export function isConnectionStatus(value: unknown): value is ConnectionStatus {
164
+ return (
165
+ value === 'available' ||
166
+ value === 'unavailable' ||
167
+ value === 'not_configured'
168
+ );
169
+ }
170
+
171
+ /** Type-guard for `DecisionStatus`. */
172
+ export function isDecisionStatus(value: unknown): value is DecisionStatus {
173
+ return value === 'locked' || value === 'tentative';
174
+ }
175
+
176
+ /** Type-guard for `MustHaveStatus`. */
177
+ export function isMustHaveStatus(value: unknown): value is MustHaveStatus {
178
+ return value === 'pending' || value === 'pass' || value === 'fail';
179
+ }