@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.
- package/.claude-plugin/marketplace.json +4 -4
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +60 -0
- package/README.md +12 -0
- package/agents/design-reflector.md +13 -0
- package/connections/connections.md +3 -0
- package/connections/figma.md +2 -0
- package/connections/gdd-state.md +186 -0
- package/hooks/budget-enforcer.ts +716 -0
- package/hooks/context-exhaustion.ts +251 -0
- package/hooks/gdd-read-injection-scanner.ts +172 -0
- package/hooks/hooks.json +3 -3
- package/package.json +19 -6
- package/reference/config-schema.md +2 -2
- package/reference/error-recovery.md +58 -0
- package/reference/registry.json +7 -0
- package/reference/schemas/budget.schema.json +42 -0
- package/reference/schemas/events.schema.json +55 -0
- package/reference/schemas/generated.d.ts +419 -0
- package/reference/schemas/iteration-budget.schema.json +36 -0
- package/reference/schemas/mcp-gdd-state-tools.schema.json +89 -0
- package/reference/schemas/rate-limits.schema.json +31 -0
- package/scripts/aggregate-agent-metrics.ts +282 -0
- package/scripts/codegen-schema-types.ts +149 -0
- package/scripts/lib/error-classifier.cjs +232 -0
- package/scripts/lib/error-classifier.d.cts +44 -0
- package/scripts/lib/event-stream/emitter.ts +88 -0
- package/scripts/lib/event-stream/index.ts +154 -0
- package/scripts/lib/event-stream/types.ts +127 -0
- package/scripts/lib/event-stream/writer.ts +154 -0
- package/scripts/lib/gdd-errors/classification.ts +124 -0
- package/scripts/lib/gdd-errors/index.ts +218 -0
- package/scripts/lib/gdd-state/gates.ts +216 -0
- package/scripts/lib/gdd-state/index.ts +167 -0
- package/scripts/lib/gdd-state/lockfile.ts +232 -0
- package/scripts/lib/gdd-state/mutator.ts +574 -0
- package/scripts/lib/gdd-state/parser.ts +523 -0
- package/scripts/lib/gdd-state/types.ts +179 -0
- package/scripts/lib/iteration-budget.cjs +205 -0
- package/scripts/lib/iteration-budget.d.cts +32 -0
- package/scripts/lib/jittered-backoff.cjs +112 -0
- package/scripts/lib/jittered-backoff.d.cts +38 -0
- package/scripts/lib/lockfile.cjs +177 -0
- package/scripts/lib/lockfile.d.cts +21 -0
- package/scripts/lib/prompt-sanitizer/index.ts +435 -0
- package/scripts/lib/prompt-sanitizer/patterns.ts +173 -0
- package/scripts/lib/rate-guard.cjs +365 -0
- package/scripts/lib/rate-guard.d.cts +38 -0
- package/scripts/mcp-servers/gdd-state/schemas/add_blocker.schema.json +67 -0
- package/scripts/mcp-servers/gdd-state/schemas/add_decision.schema.json +68 -0
- package/scripts/mcp-servers/gdd-state/schemas/add_must_have.schema.json +68 -0
- package/scripts/mcp-servers/gdd-state/schemas/checkpoint.schema.json +51 -0
- package/scripts/mcp-servers/gdd-state/schemas/frontmatter_update.schema.json +62 -0
- package/scripts/mcp-servers/gdd-state/schemas/get.schema.json +51 -0
- package/scripts/mcp-servers/gdd-state/schemas/probe_connections.schema.json +75 -0
- package/scripts/mcp-servers/gdd-state/schemas/resolve_blocker.schema.json +66 -0
- package/scripts/mcp-servers/gdd-state/schemas/set_status.schema.json +47 -0
- package/scripts/mcp-servers/gdd-state/schemas/transition_stage.schema.json +70 -0
- package/scripts/mcp-servers/gdd-state/schemas/update_progress.schema.json +58 -0
- package/scripts/mcp-servers/gdd-state/server.ts +288 -0
- package/scripts/mcp-servers/gdd-state/tools/add_blocker.ts +72 -0
- package/scripts/mcp-servers/gdd-state/tools/add_decision.ts +89 -0
- package/scripts/mcp-servers/gdd-state/tools/add_must_have.ts +113 -0
- package/scripts/mcp-servers/gdd-state/tools/checkpoint.ts +60 -0
- package/scripts/mcp-servers/gdd-state/tools/frontmatter_update.ts +91 -0
- package/scripts/mcp-servers/gdd-state/tools/get.ts +51 -0
- package/scripts/mcp-servers/gdd-state/tools/index.ts +51 -0
- package/scripts/mcp-servers/gdd-state/tools/probe_connections.ts +73 -0
- package/scripts/mcp-servers/gdd-state/tools/resolve_blocker.ts +84 -0
- package/scripts/mcp-servers/gdd-state/tools/set_status.ts +54 -0
- package/scripts/mcp-servers/gdd-state/tools/shared.ts +194 -0
- package/scripts/mcp-servers/gdd-state/tools/transition_stage.ts +80 -0
- package/scripts/mcp-servers/gdd-state/tools/update_progress.ts +81 -0
- package/scripts/validate-frontmatter.ts +114 -0
- package/scripts/validate-schemas.ts +401 -0
- package/skills/brief/SKILL.md +15 -6
- package/skills/design/SKILL.md +31 -13
- package/skills/explore/SKILL.md +41 -17
- package/skills/health/SKILL.md +15 -4
- package/skills/optimize/SKILL.md +3 -3
- package/skills/pause/SKILL.md +16 -10
- package/skills/plan/SKILL.md +33 -17
- package/skills/progress/SKILL.md +15 -11
- package/skills/resume/SKILL.md +19 -10
- package/skills/settings/SKILL.md +11 -3
- package/skills/todo/SKILL.md +12 -3
- package/skills/verify/SKILL.md +65 -29
- package/hooks/budget-enforcer.js +0 -329
- package/hooks/context-exhaustion.js +0 -127
- package/hooks/gdd-read-injection-scanner.js +0 -39
- package/scripts/aggregate-agent-metrics.js +0 -173
- package/scripts/validate-frontmatter.cjs +0 -68
- 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
|
+
}
|