@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,574 @@
|
|
|
1
|
+
// scripts/lib/gdd-state/mutator.ts — serializer + `apply(raw, fn)` mutator.
|
|
2
|
+
//
|
|
3
|
+
// Two guarantees:
|
|
4
|
+
// 1. `serialize(parse(raw).state, parse(raw).raw_bodies)` === `raw`
|
|
5
|
+
// for well-formed input (byte-identical round-trip).
|
|
6
|
+
// 2. `apply(raw, fn)` = `serialize(fn(clone(state)), raw_bodies)` with
|
|
7
|
+
// one twist — when `fn` mutates a block's typed representation, the
|
|
8
|
+
// serializer detects the semantic change and emits canonical form
|
|
9
|
+
// for that block (dropping any HTML comments or idiosyncratic
|
|
10
|
+
// whitespace). Unchanged blocks keep their raw body.
|
|
11
|
+
//
|
|
12
|
+
// The "semantic change" detection re-parses the raw body and compares
|
|
13
|
+
// against the current typed value. This is cheap (blocks are small)
|
|
14
|
+
// and gives us "preserve comments when the user didn't touch this block"
|
|
15
|
+
// behavior without asking consumers to mark blocks dirty.
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
parse,
|
|
19
|
+
BLOCK_ORDER,
|
|
20
|
+
type BlockGaps,
|
|
21
|
+
type BlockName,
|
|
22
|
+
type RawBlockBodies,
|
|
23
|
+
} from './parser.ts';
|
|
24
|
+
import {
|
|
25
|
+
type Blocker,
|
|
26
|
+
type ConnectionStatus,
|
|
27
|
+
type Decision,
|
|
28
|
+
type Frontmatter,
|
|
29
|
+
type MustHave,
|
|
30
|
+
type ParsedState,
|
|
31
|
+
type Position,
|
|
32
|
+
} from './types.ts';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Optional fidelity hints from a prior `parse()` call. When provided and
|
|
36
|
+
* the typed representation is semantically unchanged, the serializer
|
|
37
|
+
* emits each component verbatim — yielding byte-identical round-trip
|
|
38
|
+
* for unchanged blocks while still allowing targeted edits.
|
|
39
|
+
*/
|
|
40
|
+
export interface SerializeFidelity {
|
|
41
|
+
raw_frontmatter?: string;
|
|
42
|
+
raw_bodies?: RawBlockBodies;
|
|
43
|
+
block_gaps?: BlockGaps;
|
|
44
|
+
line_ending?: '\n' | '\r\n';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Serialize a `ParsedState` back to STATE.md text.
|
|
49
|
+
*
|
|
50
|
+
* @param state the parsed state (possibly mutated)
|
|
51
|
+
* @param fidelity optional fidelity hints from `parse()` — preserve
|
|
52
|
+
* original formatting for untouched regions.
|
|
53
|
+
*/
|
|
54
|
+
export function serialize(
|
|
55
|
+
state: ParsedState,
|
|
56
|
+
fidelity: SerializeFidelity = {},
|
|
57
|
+
): string {
|
|
58
|
+
const {
|
|
59
|
+
raw_frontmatter,
|
|
60
|
+
raw_bodies,
|
|
61
|
+
block_gaps,
|
|
62
|
+
line_ending = '\n',
|
|
63
|
+
} = fidelity;
|
|
64
|
+
|
|
65
|
+
const out: string[] = [];
|
|
66
|
+
|
|
67
|
+
// --- frontmatter ---
|
|
68
|
+
out.push('---\n');
|
|
69
|
+
out.push(emitFrontmatter(state.frontmatter, raw_frontmatter));
|
|
70
|
+
out.push('---\n');
|
|
71
|
+
|
|
72
|
+
// --- blocks (canonical order, each preceded by its gap) ---
|
|
73
|
+
for (const name of BLOCK_ORDER) {
|
|
74
|
+
const rawBody = raw_bodies?.[name] ?? null;
|
|
75
|
+
const emitted = emitBlock(name, state, rawBody);
|
|
76
|
+
if (emitted === null) {
|
|
77
|
+
// Block absent — do NOT emit a gap either (gaps belong to present blocks).
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// Prepend gap if we have one; otherwise fall back to a single '\n'
|
|
81
|
+
// separator between consecutive blocks for canonical output.
|
|
82
|
+
const gap =
|
|
83
|
+
block_gaps?.[name] ?? (out.length > 0 && isFirstEmission(out) ? '' : '\n');
|
|
84
|
+
out.push(gap);
|
|
85
|
+
out.push(`<${name}>\n`);
|
|
86
|
+
out.push(emitted);
|
|
87
|
+
if (!emitted.endsWith('\n')) out.push('\n');
|
|
88
|
+
out.push(`</${name}>\n`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- trailer (verbatim) ---
|
|
92
|
+
out.push(state.body_trailer);
|
|
93
|
+
|
|
94
|
+
const joined = out.join('');
|
|
95
|
+
return line_ending === '\r\n' ? joined.replace(/\n/g, '\r\n') : joined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Helper: detect whether the current push would be the first block
|
|
99
|
+
* emission (`out` ends at the `---\n` fence). Used when `block_gaps` is
|
|
100
|
+
* absent — we preserve `state.body_preamble` for the first block and
|
|
101
|
+
* use a single '\n' between subsequent blocks. */
|
|
102
|
+
function isFirstEmission(out: string[]): boolean {
|
|
103
|
+
return out.length <= 3; // ['---\n', frontmatter, '---\n']
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Pure mutator. Parses, applies `fn`, serializes. Throws `ParseError`
|
|
108
|
+
* on structurally invalid input.
|
|
109
|
+
*/
|
|
110
|
+
export function apply(
|
|
111
|
+
raw: string,
|
|
112
|
+
fn: (s: ParsedState) => ParsedState,
|
|
113
|
+
): string {
|
|
114
|
+
const { state, raw_bodies, raw_frontmatter, block_gaps, line_ending } =
|
|
115
|
+
parse(raw);
|
|
116
|
+
// Deep-clone so `fn` cannot accidentally mutate the original parsed
|
|
117
|
+
// result (which callers of `parse()` may also hold a reference to).
|
|
118
|
+
const clone = structuredClone(state);
|
|
119
|
+
const next = fn(clone);
|
|
120
|
+
return serialize(next, {
|
|
121
|
+
raw_frontmatter,
|
|
122
|
+
raw_bodies,
|
|
123
|
+
block_gaps,
|
|
124
|
+
line_ending,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** --- helpers --- */
|
|
129
|
+
|
|
130
|
+
function emitFrontmatter(
|
|
131
|
+
fm: Frontmatter,
|
|
132
|
+
raw_frontmatter?: string,
|
|
133
|
+
): string {
|
|
134
|
+
// Fidelity path: if the caller supplied the original raw frontmatter
|
|
135
|
+
// and the parsed fm (after fn()) semantically equals a reparse of that
|
|
136
|
+
// raw, emit the raw verbatim. This preserves quoting (e.g., `cycle: ""`
|
|
137
|
+
// round-trips as `cycle: ""`, not `cycle: `) and author key ordering.
|
|
138
|
+
if (raw_frontmatter !== undefined) {
|
|
139
|
+
const reparsed = tryReparseFrontmatter(raw_frontmatter);
|
|
140
|
+
if (reparsed !== null && frontmatterEqual(reparsed, fm)) {
|
|
141
|
+
return raw_frontmatter + '\n';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return canonicalFrontmatter(fm);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function tryReparseFrontmatter(raw: string): Frontmatter | null {
|
|
148
|
+
try {
|
|
149
|
+
const out: Record<string, unknown> = {};
|
|
150
|
+
for (const line of raw.split('\n')) {
|
|
151
|
+
const trimmed = line.trim();
|
|
152
|
+
if (trimmed === '' || trimmed.startsWith('#')) continue;
|
|
153
|
+
const idx = line.indexOf(':');
|
|
154
|
+
if (idx === -1) continue;
|
|
155
|
+
const key = line.slice(0, idx).trim();
|
|
156
|
+
let value: string = line.slice(idx + 1).trim();
|
|
157
|
+
if (
|
|
158
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
159
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
160
|
+
) {
|
|
161
|
+
value = value.slice(1, -1);
|
|
162
|
+
}
|
|
163
|
+
if (key === 'wave') {
|
|
164
|
+
const n = Number(value);
|
|
165
|
+
out[key] = Number.isFinite(n) ? n : value;
|
|
166
|
+
} else {
|
|
167
|
+
out[key] = value;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const fm: Frontmatter = {
|
|
171
|
+
pipeline_state_version: String(out['pipeline_state_version'] ?? '1.0'),
|
|
172
|
+
stage: String(out['stage'] ?? ''),
|
|
173
|
+
cycle: String(out['cycle'] ?? ''),
|
|
174
|
+
wave: typeof out['wave'] === 'number' ? (out['wave'] as number) : 1,
|
|
175
|
+
started_at: String(out['started_at'] ?? ''),
|
|
176
|
+
last_checkpoint: String(out['last_checkpoint'] ?? ''),
|
|
177
|
+
};
|
|
178
|
+
for (const [k, v] of Object.entries(out)) {
|
|
179
|
+
if (!(k in fm)) fm[k] = v;
|
|
180
|
+
}
|
|
181
|
+
return fm;
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function frontmatterEqual(a: Frontmatter, b: Frontmatter): boolean {
|
|
188
|
+
const ak = Object.keys(a);
|
|
189
|
+
const bk = Object.keys(b);
|
|
190
|
+
if (ak.length !== bk.length) return false;
|
|
191
|
+
for (const k of ak) {
|
|
192
|
+
if (!(k in b)) return false;
|
|
193
|
+
// Cheap comparison; strings & numbers only in this surface.
|
|
194
|
+
if (a[k] !== b[k]) {
|
|
195
|
+
// Handle the string/number coerce edge for `wave`.
|
|
196
|
+
if (String(a[k]) !== String(b[k])) return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function canonicalFrontmatter(fm: Frontmatter): string {
|
|
203
|
+
// Emit in a stable order: template-defined keys first, then anything
|
|
204
|
+
// else in insertion order. This keeps fresh → serialize byte-stable.
|
|
205
|
+
const fixed = [
|
|
206
|
+
'pipeline_state_version',
|
|
207
|
+
'stage',
|
|
208
|
+
'cycle',
|
|
209
|
+
'wave',
|
|
210
|
+
'started_at',
|
|
211
|
+
'last_checkpoint',
|
|
212
|
+
];
|
|
213
|
+
const lines: string[] = [];
|
|
214
|
+
const emitted = new Set<string>();
|
|
215
|
+
for (const k of fixed) {
|
|
216
|
+
if (k in fm) {
|
|
217
|
+
lines.push(`${k}: ${formatFrontmatterValue(fm[k])}`);
|
|
218
|
+
emitted.add(k);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
for (const k of Object.keys(fm)) {
|
|
222
|
+
if (emitted.has(k)) continue;
|
|
223
|
+
lines.push(`${k}: ${formatFrontmatterValue(fm[k])}`);
|
|
224
|
+
}
|
|
225
|
+
return lines.join('\n') + '\n';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function formatFrontmatterValue(v: unknown): string {
|
|
229
|
+
if (v === null || v === undefined) return '';
|
|
230
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
231
|
+
if (typeof v === 'string') return v;
|
|
232
|
+
// For arrays/objects, fall back to JSON (shouldn't occur in current template).
|
|
233
|
+
return JSON.stringify(v);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Emit a block's body (WITHOUT the open/close tags). Returns null to
|
|
238
|
+
* signal "skip this block entirely" — only used when both the raw body
|
|
239
|
+
* and the parsed value are absent.
|
|
240
|
+
*/
|
|
241
|
+
function emitBlock(
|
|
242
|
+
name: BlockName,
|
|
243
|
+
state: ParsedState,
|
|
244
|
+
rawBody: string | null,
|
|
245
|
+
): string | null {
|
|
246
|
+
switch (name) {
|
|
247
|
+
case 'position':
|
|
248
|
+
return emitPosition(state.position, rawBody);
|
|
249
|
+
case 'decisions':
|
|
250
|
+
return emitDecisions(state.decisions, rawBody);
|
|
251
|
+
case 'must_haves':
|
|
252
|
+
return emitMustHaves(state.must_haves, rawBody);
|
|
253
|
+
case 'connections':
|
|
254
|
+
return emitConnections(state.connections, rawBody);
|
|
255
|
+
case 'blockers':
|
|
256
|
+
return emitBlockers(state.blockers, rawBody);
|
|
257
|
+
case 'parallelism_decision':
|
|
258
|
+
// parallelism_decision is free text — if null and no raw, skip entirely.
|
|
259
|
+
if (rawBody !== null) {
|
|
260
|
+
if (state.parallelism_decision === rawBody) return rawBody;
|
|
261
|
+
return state.parallelism_decision ?? '';
|
|
262
|
+
}
|
|
263
|
+
return state.parallelism_decision;
|
|
264
|
+
case 'todos':
|
|
265
|
+
if (rawBody !== null) {
|
|
266
|
+
if (state.todos === rawBody) return rawBody;
|
|
267
|
+
return state.todos ?? '';
|
|
268
|
+
}
|
|
269
|
+
return state.todos;
|
|
270
|
+
case 'timestamps':
|
|
271
|
+
return emitTimestamps(state.timestamps, rawBody);
|
|
272
|
+
default: {
|
|
273
|
+
const _exhaustive: never = name;
|
|
274
|
+
void _exhaustive;
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function emitPosition(pos: Position, rawBody: string | null): string {
|
|
281
|
+
if (rawBody !== null) {
|
|
282
|
+
const reparsed = tryReparsePosition(rawBody);
|
|
283
|
+
if (reparsed !== null && positionEqual(reparsed, pos)) return rawBody;
|
|
284
|
+
}
|
|
285
|
+
// Canonical form.
|
|
286
|
+
return [
|
|
287
|
+
`stage: ${pos.stage}`,
|
|
288
|
+
`wave: ${pos.wave}`,
|
|
289
|
+
`task_progress: ${pos.task_progress}`,
|
|
290
|
+
`status: ${pos.status}`,
|
|
291
|
+
`handoff_source: ${quoteIfEmpty(pos.handoff_source)}`,
|
|
292
|
+
`handoff_path: ${quoteIfEmpty(pos.handoff_path)}`,
|
|
293
|
+
`skipped_stages: ${quoteIfEmpty(pos.skipped_stages)}`,
|
|
294
|
+
].join('\n');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function quoteIfEmpty(v: string): string {
|
|
298
|
+
return v === '' ? '""' : v;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function emitDecisions(decisions: Decision[], rawBody: string | null): string {
|
|
302
|
+
if (rawBody !== null) {
|
|
303
|
+
const reparsed = tryReparseDecisions(rawBody);
|
|
304
|
+
if (reparsed !== null && decisionsEqual(reparsed, decisions)) return rawBody;
|
|
305
|
+
}
|
|
306
|
+
if (decisions.length === 0) return ''; // empty block
|
|
307
|
+
return decisions
|
|
308
|
+
.map((d) => `${d.id}: ${d.text} (${d.status})`)
|
|
309
|
+
.join('\n');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function emitMustHaves(mh: MustHave[], rawBody: string | null): string {
|
|
313
|
+
if (rawBody !== null) {
|
|
314
|
+
const reparsed = tryReparseMustHaves(rawBody);
|
|
315
|
+
if (reparsed !== null && mustHavesEqual(reparsed, mh)) return rawBody;
|
|
316
|
+
}
|
|
317
|
+
if (mh.length === 0) return '';
|
|
318
|
+
return mh.map((m) => `${m.id}: ${m.text} | status: ${m.status}`).join('\n');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function emitConnections(
|
|
322
|
+
conns: Record<string, ConnectionStatus>,
|
|
323
|
+
rawBody: string | null,
|
|
324
|
+
): string {
|
|
325
|
+
if (rawBody !== null) {
|
|
326
|
+
const reparsed = tryReparseConnections(rawBody);
|
|
327
|
+
if (reparsed !== null && connectionsEqual(reparsed, conns)) return rawBody;
|
|
328
|
+
}
|
|
329
|
+
const keys = Object.keys(conns);
|
|
330
|
+
if (keys.length === 0) return '';
|
|
331
|
+
return keys.map((k) => `${k}: ${conns[k]}`).join('\n');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function emitBlockers(blockers: Blocker[], rawBody: string | null): string {
|
|
335
|
+
if (rawBody !== null) {
|
|
336
|
+
const reparsed = tryReparseBlockers(rawBody);
|
|
337
|
+
if (reparsed !== null && blockersEqual(reparsed, blockers)) return rawBody;
|
|
338
|
+
}
|
|
339
|
+
if (blockers.length === 0) return '';
|
|
340
|
+
return blockers.map((b) => `[${b.stage}] [${b.date}]: ${b.text}`).join('\n');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function emitTimestamps(
|
|
344
|
+
ts: Record<string, string>,
|
|
345
|
+
rawBody: string | null,
|
|
346
|
+
): string {
|
|
347
|
+
if (rawBody !== null) {
|
|
348
|
+
const reparsed = tryReparseTimestamps(rawBody);
|
|
349
|
+
if (reparsed !== null && recordsEqual(reparsed, ts)) return rawBody;
|
|
350
|
+
}
|
|
351
|
+
const keys = Object.keys(ts);
|
|
352
|
+
if (keys.length === 0) return '';
|
|
353
|
+
return keys.map((k) => `${k}: ${ts[k]}`).join('\n');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/* --- semantic equality helpers --- */
|
|
357
|
+
|
|
358
|
+
function positionEqual(a: Position, b: Position): boolean {
|
|
359
|
+
return (
|
|
360
|
+
a.stage === b.stage &&
|
|
361
|
+
a.wave === b.wave &&
|
|
362
|
+
a.task_progress === b.task_progress &&
|
|
363
|
+
a.status === b.status &&
|
|
364
|
+
a.handoff_source === b.handoff_source &&
|
|
365
|
+
a.handoff_path === b.handoff_path &&
|
|
366
|
+
a.skipped_stages === b.skipped_stages
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function decisionsEqual(a: Decision[], b: Decision[]): boolean {
|
|
371
|
+
if (a.length !== b.length) return false;
|
|
372
|
+
for (let i = 0; i < a.length; i++) {
|
|
373
|
+
const x = a[i];
|
|
374
|
+
const y = b[i];
|
|
375
|
+
if (x === undefined || y === undefined) return false;
|
|
376
|
+
if (x.id !== y.id || x.text !== y.text || x.status !== y.status) return false;
|
|
377
|
+
}
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function mustHavesEqual(a: MustHave[], b: MustHave[]): boolean {
|
|
382
|
+
if (a.length !== b.length) return false;
|
|
383
|
+
for (let i = 0; i < a.length; i++) {
|
|
384
|
+
const x = a[i];
|
|
385
|
+
const y = b[i];
|
|
386
|
+
if (x === undefined || y === undefined) return false;
|
|
387
|
+
if (x.id !== y.id || x.text !== y.text || x.status !== y.status) return false;
|
|
388
|
+
}
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function connectionsEqual(
|
|
393
|
+
a: Record<string, ConnectionStatus>,
|
|
394
|
+
b: Record<string, ConnectionStatus>,
|
|
395
|
+
): boolean {
|
|
396
|
+
const ak = Object.keys(a);
|
|
397
|
+
const bk = Object.keys(b);
|
|
398
|
+
if (ak.length !== bk.length) return false;
|
|
399
|
+
for (let i = 0; i < ak.length; i++) {
|
|
400
|
+
if (ak[i] !== bk[i]) return false;
|
|
401
|
+
const key = ak[i]!;
|
|
402
|
+
if (a[key] !== b[key]) return false;
|
|
403
|
+
}
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function blockersEqual(a: Blocker[], b: Blocker[]): boolean {
|
|
408
|
+
if (a.length !== b.length) return false;
|
|
409
|
+
for (let i = 0; i < a.length; i++) {
|
|
410
|
+
const x = a[i];
|
|
411
|
+
const y = b[i];
|
|
412
|
+
if (x === undefined || y === undefined) return false;
|
|
413
|
+
if (x.stage !== y.stage || x.date !== y.date || x.text !== y.text) return false;
|
|
414
|
+
}
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function recordsEqual(
|
|
419
|
+
a: Record<string, string>,
|
|
420
|
+
b: Record<string, string>,
|
|
421
|
+
): boolean {
|
|
422
|
+
const ak = Object.keys(a);
|
|
423
|
+
const bk = Object.keys(b);
|
|
424
|
+
if (ak.length !== bk.length) return false;
|
|
425
|
+
for (let i = 0; i < ak.length; i++) {
|
|
426
|
+
if (ak[i] !== bk[i]) return false;
|
|
427
|
+
const key = ak[i]!;
|
|
428
|
+
if (a[key] !== b[key]) return false;
|
|
429
|
+
}
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/* --- reparse helpers (small, self-contained — avoid importing the file-
|
|
434
|
+
level parse() to prevent re-running frontmatter parsing) --- */
|
|
435
|
+
|
|
436
|
+
function tryReparsePosition(raw: string): Position | null {
|
|
437
|
+
try {
|
|
438
|
+
const fields: Record<string, string> = {};
|
|
439
|
+
for (const line of raw.split('\n')) {
|
|
440
|
+
const trimmed = line.trim();
|
|
441
|
+
if (trimmed === '' || trimmed.startsWith('<!--')) continue;
|
|
442
|
+
const idx = line.indexOf(':');
|
|
443
|
+
if (idx === -1) continue;
|
|
444
|
+
const key = line.slice(0, idx).trim();
|
|
445
|
+
let value = line.slice(idx + 1).trim();
|
|
446
|
+
if (
|
|
447
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
448
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
449
|
+
) {
|
|
450
|
+
value = value.slice(1, -1);
|
|
451
|
+
}
|
|
452
|
+
fields[key] = value;
|
|
453
|
+
}
|
|
454
|
+
const waveNum = Number(fields['wave'] ?? '1');
|
|
455
|
+
if (!Number.isFinite(waveNum)) return null;
|
|
456
|
+
return {
|
|
457
|
+
stage: fields['stage'] ?? '',
|
|
458
|
+
wave: waveNum,
|
|
459
|
+
task_progress: fields['task_progress'] ?? '0/0',
|
|
460
|
+
status: fields['status'] ?? 'initialized',
|
|
461
|
+
handoff_source: fields['handoff_source'] ?? '',
|
|
462
|
+
handoff_path: fields['handoff_path'] ?? '',
|
|
463
|
+
skipped_stages: fields['skipped_stages'] ?? '',
|
|
464
|
+
};
|
|
465
|
+
} catch {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function tryReparseDecisions(raw: string): Decision[] | null {
|
|
471
|
+
try {
|
|
472
|
+
const out: Decision[] = [];
|
|
473
|
+
const re = /^(D-\d+):\s*(.*?)\s*\((locked|tentative)\)\s*$/;
|
|
474
|
+
for (const line of raw.split('\n')) {
|
|
475
|
+
const t = line.trim();
|
|
476
|
+
if (t === '' || t.startsWith('<!--')) continue;
|
|
477
|
+
const m = t.match(re);
|
|
478
|
+
if (!m) continue;
|
|
479
|
+
out.push({
|
|
480
|
+
id: m[1] ?? '',
|
|
481
|
+
text: m[2] ?? '',
|
|
482
|
+
status: m[3] as 'locked' | 'tentative',
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
return out;
|
|
486
|
+
} catch {
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function tryReparseMustHaves(raw: string): MustHave[] | null {
|
|
492
|
+
try {
|
|
493
|
+
const out: MustHave[] = [];
|
|
494
|
+
const re = /^(M-\d+):\s*(.*?)\s*\|\s*status:\s*(pending|pass|fail)\s*$/;
|
|
495
|
+
for (const line of raw.split('\n')) {
|
|
496
|
+
const t = line.trim();
|
|
497
|
+
if (t === '' || t.startsWith('<!--')) continue;
|
|
498
|
+
const m = t.match(re);
|
|
499
|
+
if (!m) continue;
|
|
500
|
+
out.push({
|
|
501
|
+
id: m[1] ?? '',
|
|
502
|
+
text: m[2] ?? '',
|
|
503
|
+
status: m[3] as 'pending' | 'pass' | 'fail',
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
return out;
|
|
507
|
+
} catch {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function tryReparseConnections(
|
|
513
|
+
raw: string,
|
|
514
|
+
): Record<string, ConnectionStatus> | null {
|
|
515
|
+
try {
|
|
516
|
+
const out: Record<string, ConnectionStatus> = {};
|
|
517
|
+
for (const line of raw.split('\n')) {
|
|
518
|
+
const trimmed = line.trim();
|
|
519
|
+
if (trimmed === '' || trimmed.startsWith('<!--')) continue;
|
|
520
|
+
const idx = line.indexOf(':');
|
|
521
|
+
if (idx === -1) continue;
|
|
522
|
+
const key = line.slice(0, idx).trim();
|
|
523
|
+
const value = line.slice(idx + 1).trim() as ConnectionStatus;
|
|
524
|
+
if (
|
|
525
|
+
value !== 'available' &&
|
|
526
|
+
value !== 'unavailable' &&
|
|
527
|
+
value !== 'not_configured'
|
|
528
|
+
) {
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
out[key] = value;
|
|
532
|
+
}
|
|
533
|
+
return out;
|
|
534
|
+
} catch {
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function tryReparseBlockers(raw: string): Blocker[] | null {
|
|
540
|
+
try {
|
|
541
|
+
const out: Blocker[] = [];
|
|
542
|
+
const re = /^\[([^\]]+)\]\s*\[([^\]]+)\]:\s*(.*)$/;
|
|
543
|
+
for (const line of raw.split('\n')) {
|
|
544
|
+
const t = line.trim();
|
|
545
|
+
if (t === '' || t.startsWith('<!--')) continue;
|
|
546
|
+
const m = t.match(re);
|
|
547
|
+
if (!m) return null;
|
|
548
|
+
out.push({ stage: m[1] ?? '', date: m[2] ?? '', text: m[3] ?? '' });
|
|
549
|
+
}
|
|
550
|
+
return out;
|
|
551
|
+
} catch {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function tryReparseTimestamps(
|
|
557
|
+
raw: string,
|
|
558
|
+
): Record<string, string> | null {
|
|
559
|
+
try {
|
|
560
|
+
const out: Record<string, string> = {};
|
|
561
|
+
for (const line of raw.split('\n')) {
|
|
562
|
+
const trimmed = line.trim();
|
|
563
|
+
if (trimmed === '' || trimmed.startsWith('<!--')) continue;
|
|
564
|
+
const idx = line.indexOf(':');
|
|
565
|
+
if (idx === -1) continue;
|
|
566
|
+
const key = line.slice(0, idx).trim();
|
|
567
|
+
const value = line.slice(idx + 1).trim();
|
|
568
|
+
out[key] = value;
|
|
569
|
+
}
|
|
570
|
+
return out;
|
|
571
|
+
} catch {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
}
|