@hegemonart/get-design-done 1.24.2 → 1.25.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.
@@ -31,6 +31,9 @@ import {
31
31
  isConnectionStatus,
32
32
  isDecisionStatus,
33
33
  isMustHaveStatus,
34
+ isPrototypingEntryStatus,
35
+ isQualityGateStatus,
36
+ isSpikeVerdict,
34
37
  type Blocker,
35
38
  type ConnectionStatus,
36
39
  type Decision,
@@ -40,13 +43,34 @@ import {
40
43
  type MustHaveStatus,
41
44
  type ParsedState,
42
45
  type Position,
46
+ type PrototypingBlock,
47
+ type PrototypingEntryStatus,
48
+ type QualityGateBlock,
49
+ type QualityGateRun,
50
+ type QualityGateStatus,
51
+ type SketchEntry,
52
+ type SkippedEntry,
53
+ type SpikeEntry,
54
+ type SpikeVerdict,
43
55
  } from './types.ts';
44
56
 
45
- /** Block names recognized by the parser in canonical order. */
57
+ /** Block names recognized by the parser in canonical order.
58
+ *
59
+ * Phase 25 Plan 25-01 inserted `prototyping` between `must_haves` and
60
+ * `connections` (matches the position chosen for STATE-TEMPLATE.md). The
61
+ * block is OPTIONAL — most fresh files don't carry it, so the serializer
62
+ * omits the block entirely when `state.prototyping === null`.
63
+ *
64
+ * Phase 25 Plan 25-03 inserted `quality_gate` immediately after
65
+ * `prototyping` (the two are conceptually related — both are
66
+ * checkpoint-style blocks that surface mid-pipeline gate outcomes). Same
67
+ * optionality rules as `prototyping`. */
46
68
  export const BLOCK_ORDER = [
47
69
  'position',
48
70
  'decisions',
49
71
  'must_haves',
72
+ 'prototyping',
73
+ 'quality_gate',
50
74
  'connections',
51
75
  'blockers',
52
76
  'parallelism_decision',
@@ -62,6 +86,8 @@ export interface RawBlockBodies {
62
86
  position: string | null;
63
87
  decisions: string | null;
64
88
  must_haves: string | null;
89
+ prototyping: string | null;
90
+ quality_gate: string | null;
65
91
  connections: string | null;
66
92
  blockers: string | null;
67
93
  parallelism_decision: string | null;
@@ -77,6 +103,8 @@ export interface BlockGaps {
77
103
  position: string;
78
104
  decisions: string;
79
105
  must_haves: string;
106
+ prototyping: string;
107
+ quality_gate: string;
80
108
  connections: string;
81
109
  blockers: string;
82
110
  parallelism_decision: string;
@@ -106,6 +134,8 @@ const EMPTY_RAW_BODIES: RawBlockBodies = {
106
134
  position: null,
107
135
  decisions: null,
108
136
  must_haves: null,
137
+ prototyping: null,
138
+ quality_gate: null,
109
139
  connections: null,
110
140
  blockers: null,
111
141
  parallelism_decision: null,
@@ -193,6 +223,8 @@ export function parse(raw: string): ParseResult {
193
223
  position: '',
194
224
  decisions: '',
195
225
  must_haves: '',
226
+ prototyping: '',
227
+ quality_gate: '',
196
228
  connections: '',
197
229
  blockers: '',
198
230
  parallelism_decision: '',
@@ -244,6 +276,8 @@ export function parse(raw: string): ParseResult {
244
276
  let blockers: Blocker[] = [];
245
277
  let parallelism_decision: string | null = null;
246
278
  let todos: string | null = null;
279
+ let prototyping: PrototypingBlock | null = null;
280
+ let quality_gate: QualityGateBlock | null = null;
247
281
  let timestamps: Record<string, string> = {};
248
282
 
249
283
  for (const blk of blocks) {
@@ -262,6 +296,12 @@ export function parse(raw: string): ParseResult {
262
296
  case 'must_haves':
263
297
  must_haves = parseMustHavesBody(rawBody, fileLineOfBody);
264
298
  break;
299
+ case 'prototyping':
300
+ prototyping = parsePrototypingBody(rawBody, fileLineOfBody);
301
+ break;
302
+ case 'quality_gate':
303
+ quality_gate = parseQualityGateBody(rawBody, fileLineOfBody);
304
+ break;
265
305
  case 'connections':
266
306
  connections = parseConnectionsBody(rawBody, fileLineOfBody);
267
307
  break;
@@ -305,6 +345,8 @@ export function parse(raw: string): ParseResult {
305
345
  blockers,
306
346
  parallelism_decision,
307
347
  todos,
348
+ prototyping,
349
+ quality_gate,
308
350
  timestamps,
309
351
  body_preamble,
310
352
  body_trailer,
@@ -504,6 +546,314 @@ function parseBlockersBody(body: string, startLine: number): Blocker[] {
504
546
  return out;
505
547
  }
506
548
 
549
+ /**
550
+ * Parse the body of a `<prototyping>` block (Phase 25 Plan 25-01 / D-01).
551
+ *
552
+ * Recognized self-closing children (each on its own line, attribute order
553
+ * tolerant, unknown attributes preserved into `extra_attrs` for forward
554
+ * compat):
555
+ * <sketch slug=… cycle=… decision=D-XX status=resolved/>
556
+ * <spike slug=… cycle=… decision=D-XX verdict=yes|no|partial status=resolved/>
557
+ * <skipped at=… cycle=… reason=…/>
558
+ *
559
+ * Lines that are blank, HTML comments, or unrecognized self-closing tags
560
+ * are tolerated (forward-compat). Required attributes that are missing or
561
+ * carry an invalid enum value throw `ParseError` so operators see a real
562
+ * problem instead of a silent drop.
563
+ */
564
+ function parsePrototypingBody(body: string, startLine: number): PrototypingBlock {
565
+ const sketches: SketchEntry[] = [];
566
+ const spikes: SpikeEntry[] = [];
567
+ const skipped: SkippedEntry[] = [];
568
+ const lines = body.split('\n');
569
+ // Match an XML-ish self-closing tag: `<name attr=value attr="quoted"/>`.
570
+ // We capture the tag name and the attribute span; attributes are split
571
+ // by `parsePrototypingAttrs` (handles both quoted and bare values).
572
+ const selfClose = /^<([a-z_]+)(\s+[^>]*?)?\s*\/>\s*$/;
573
+ for (let i = 0; i < lines.length; i++) {
574
+ const line = (lines[i] ?? '').trim();
575
+ if (line === '' || line.startsWith('<!--')) continue;
576
+ const m = line.match(selfClose);
577
+ if (!m) {
578
+ // Unknown shape inside the block — tolerate (forward-compat). Don't
579
+ // throw; downstream sketch/spike-wrap-up flows may add new child
580
+ // tags before the parser learns about them.
581
+ continue;
582
+ }
583
+ const tag = m[1] ?? '';
584
+ const attrs = parsePrototypingAttrs(m[2] ?? '');
585
+ const fileLine = startLine + i;
586
+ if (tag === 'sketch') {
587
+ sketches.push(buildSketchEntry(attrs, fileLine));
588
+ } else if (tag === 'spike') {
589
+ spikes.push(buildSpikeEntry(attrs, fileLine));
590
+ } else if (tag === 'skipped') {
591
+ skipped.push(buildSkippedEntry(attrs, fileLine));
592
+ }
593
+ // else: unknown self-closing tag inside <prototyping> — ignore.
594
+ }
595
+ return { sketches, spikes, skipped };
596
+ }
597
+
598
+ /**
599
+ * Split a self-closing-tag attribute span into a `{name → value}` map.
600
+ * Handles double-quoted, single-quoted, and bare (no-whitespace) values.
601
+ * Unknown to whitespace tokenization is fine here because attribute names
602
+ * in our schema are simple identifiers.
603
+ */
604
+ function parsePrototypingAttrs(span: string): Record<string, string> {
605
+ const out: Record<string, string> = {};
606
+ // Token shape: `name="dq"`, `name='sq'`, or `name=bare`. Bare values run
607
+ // until the next whitespace or EOS. Greedy regex with three alternatives
608
+ // walks the span position-by-position.
609
+ const re = /([a-zA-Z_][\w-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s/>]+))/g;
610
+ let m: RegExpExecArray | null;
611
+ while ((m = re.exec(span)) !== null) {
612
+ const key = m[1] ?? '';
613
+ const value: string =
614
+ (m[2] !== undefined ? m[2] : undefined) ??
615
+ (m[3] !== undefined ? m[3] : undefined) ??
616
+ m[4] ??
617
+ '';
618
+ if (key !== '') out[key] = value;
619
+ }
620
+ return out;
621
+ }
622
+
623
+ function buildSketchEntry(
624
+ attrs: Record<string, string>,
625
+ fileLine: number,
626
+ ): SketchEntry {
627
+ const slug = attrs['slug'];
628
+ const cycle = attrs['cycle'];
629
+ const decision = attrs['decision'];
630
+ const status = attrs['status'] ?? 'resolved';
631
+ if (slug === undefined) {
632
+ throw new ParseError('<sketch/> missing required attribute slug', fileLine);
633
+ }
634
+ if (cycle === undefined) {
635
+ throw new ParseError('<sketch/> missing required attribute cycle', fileLine);
636
+ }
637
+ if (decision === undefined) {
638
+ throw new ParseError(
639
+ '<sketch/> missing required attribute decision',
640
+ fileLine,
641
+ );
642
+ }
643
+ if (!isPrototypingEntryStatus(status)) {
644
+ throw new ParseError(
645
+ `<sketch/> invalid status: ${status}`,
646
+ fileLine,
647
+ );
648
+ }
649
+ return {
650
+ slug,
651
+ cycle,
652
+ decision,
653
+ status: status as PrototypingEntryStatus,
654
+ extra_attrs: extractExtraAttrs(attrs, ['slug', 'cycle', 'decision', 'status']),
655
+ };
656
+ }
657
+
658
+ function buildSpikeEntry(
659
+ attrs: Record<string, string>,
660
+ fileLine: number,
661
+ ): SpikeEntry {
662
+ const slug = attrs['slug'];
663
+ const cycle = attrs['cycle'];
664
+ const decision = attrs['decision'];
665
+ const verdict = attrs['verdict'];
666
+ const status = attrs['status'] ?? 'resolved';
667
+ if (slug === undefined) {
668
+ throw new ParseError('<spike/> missing required attribute slug', fileLine);
669
+ }
670
+ if (cycle === undefined) {
671
+ throw new ParseError('<spike/> missing required attribute cycle', fileLine);
672
+ }
673
+ if (decision === undefined) {
674
+ throw new ParseError(
675
+ '<spike/> missing required attribute decision',
676
+ fileLine,
677
+ );
678
+ }
679
+ if (verdict === undefined) {
680
+ throw new ParseError(
681
+ '<spike/> missing required attribute verdict',
682
+ fileLine,
683
+ );
684
+ }
685
+ if (!isSpikeVerdict(verdict)) {
686
+ throw new ParseError(
687
+ `<spike/> invalid verdict: ${verdict}`,
688
+ fileLine,
689
+ );
690
+ }
691
+ if (!isPrototypingEntryStatus(status)) {
692
+ throw new ParseError(
693
+ `<spike/> invalid status: ${status}`,
694
+ fileLine,
695
+ );
696
+ }
697
+ return {
698
+ slug,
699
+ cycle,
700
+ decision,
701
+ verdict: verdict as SpikeVerdict,
702
+ status: status as PrototypingEntryStatus,
703
+ extra_attrs: extractExtraAttrs(attrs, [
704
+ 'slug',
705
+ 'cycle',
706
+ 'decision',
707
+ 'verdict',
708
+ 'status',
709
+ ]),
710
+ };
711
+ }
712
+
713
+ function buildSkippedEntry(
714
+ attrs: Record<string, string>,
715
+ fileLine: number,
716
+ ): SkippedEntry {
717
+ const at = attrs['at'];
718
+ const cycle = attrs['cycle'];
719
+ const reason = attrs['reason'];
720
+ if (at === undefined) {
721
+ throw new ParseError('<skipped/> missing required attribute at', fileLine);
722
+ }
723
+ if (cycle === undefined) {
724
+ throw new ParseError(
725
+ '<skipped/> missing required attribute cycle',
726
+ fileLine,
727
+ );
728
+ }
729
+ if (reason === undefined) {
730
+ throw new ParseError(
731
+ '<skipped/> missing required attribute reason',
732
+ fileLine,
733
+ );
734
+ }
735
+ return {
736
+ at,
737
+ cycle,
738
+ reason,
739
+ extra_attrs: extractExtraAttrs(attrs, ['at', 'cycle', 'reason']),
740
+ };
741
+ }
742
+
743
+ function extractExtraAttrs(
744
+ all: Record<string, string>,
745
+ known: readonly string[],
746
+ ): Record<string, string> {
747
+ const out: Record<string, string> = {};
748
+ for (const [k, v] of Object.entries(all)) {
749
+ if (!known.includes(k)) out[k] = v;
750
+ }
751
+ return out;
752
+ }
753
+
754
+ /**
755
+ * Parse the body of a `<quality_gate>` block (Phase 25 Plan 25-03).
756
+ *
757
+ * The block houses at most one self-closing `<run/>` element. Multiple
758
+ * `<run/>` lines are not part of the v1.25 schema — append-mode would
759
+ * be overkill (the SKILL overwrites on every gate completion). If the
760
+ * source carries multiple, we accept the LAST one (most recent wins) so
761
+ * a hand-edit that adds a newer entry above an older one still parses
762
+ * sensibly. Lines that are blank or HTML comments are tolerated.
763
+ *
764
+ * Required attributes: `started_at`, `completed_at`, `status`,
765
+ * `iteration`, `commands_run`. Missing attributes throw `ParseError` so
766
+ * operators see the problem (mirrors prototyping behavior). `iteration`
767
+ * must parse as a finite non-negative integer.
768
+ */
769
+ function parseQualityGateBody(body: string, startLine: number): QualityGateBlock {
770
+ let run: QualityGateRun | null = null;
771
+ const lines = body.split('\n');
772
+ const selfClose = /^<([a-z_]+)(\s+[^>]*?)?\s*\/>\s*$/;
773
+ for (let i = 0; i < lines.length; i++) {
774
+ const line = (lines[i] ?? '').trim();
775
+ if (line === '' || line.startsWith('<!--')) continue;
776
+ const m = line.match(selfClose);
777
+ if (!m) {
778
+ // Forward-compat: tolerate unknown shapes inside the block.
779
+ continue;
780
+ }
781
+ const tag = m[1] ?? '';
782
+ if (tag !== 'run') continue; // forward-compat for unknown child tags
783
+ const attrs = parsePrototypingAttrs(m[2] ?? '');
784
+ run = buildQualityGateRun(attrs, startLine + i);
785
+ }
786
+ return { run };
787
+ }
788
+
789
+ function buildQualityGateRun(
790
+ attrs: Record<string, string>,
791
+ fileLine: number,
792
+ ): QualityGateRun {
793
+ const started_at = attrs['started_at'];
794
+ const completed_at = attrs['completed_at'];
795
+ const status = attrs['status'];
796
+ const iterationRaw = attrs['iteration'];
797
+ const commands_run = attrs['commands_run'];
798
+ if (started_at === undefined) {
799
+ throw new ParseError(
800
+ '<quality_gate> <run/> missing required attribute started_at',
801
+ fileLine,
802
+ );
803
+ }
804
+ if (completed_at === undefined) {
805
+ throw new ParseError(
806
+ '<quality_gate> <run/> missing required attribute completed_at',
807
+ fileLine,
808
+ );
809
+ }
810
+ if (status === undefined) {
811
+ throw new ParseError(
812
+ '<quality_gate> <run/> missing required attribute status',
813
+ fileLine,
814
+ );
815
+ }
816
+ if (!isQualityGateStatus(status)) {
817
+ throw new ParseError(
818
+ `<quality_gate> <run/> invalid status: ${status}`,
819
+ fileLine,
820
+ );
821
+ }
822
+ if (iterationRaw === undefined) {
823
+ throw new ParseError(
824
+ '<quality_gate> <run/> missing required attribute iteration',
825
+ fileLine,
826
+ );
827
+ }
828
+ const iteration = Number(iterationRaw);
829
+ if (!Number.isFinite(iteration) || !Number.isInteger(iteration) || iteration < 0) {
830
+ throw new ParseError(
831
+ `<quality_gate> <run/> iteration not a non-negative integer: ${iterationRaw}`,
832
+ fileLine,
833
+ );
834
+ }
835
+ if (commands_run === undefined) {
836
+ throw new ParseError(
837
+ '<quality_gate> <run/> missing required attribute commands_run',
838
+ fileLine,
839
+ );
840
+ }
841
+ return {
842
+ started_at,
843
+ completed_at,
844
+ status: status as QualityGateStatus,
845
+ iteration,
846
+ commands_run,
847
+ extra_attrs: extractExtraAttrs(attrs, [
848
+ 'started_at',
849
+ 'completed_at',
850
+ 'status',
851
+ 'iteration',
852
+ 'commands_run',
853
+ ]),
854
+ };
855
+ }
856
+
507
857
  function parseTimestampsBody(
508
858
  body: string,
509
859
  _startLine: number,
@@ -36,6 +36,23 @@ export type DecisionStatus = 'locked' | 'tentative';
36
36
  /** Verification state for a `<must_haves>` entry. */
37
37
  export type MustHaveStatus = 'pending' | 'pass' | 'fail';
38
38
 
39
+ /**
40
+ * Verdict for a `<spike>` entry — the answer the spike produced.
41
+ * Phase 25 Plan 25-01: spikes resolve a "can this work?" question with one
42
+ * of three outcomes. `partial` means the spike answered for some cases but
43
+ * not all (e.g., works on one platform, not another).
44
+ */
45
+ export type SpikeVerdict = 'yes' | 'no' | 'partial';
46
+
47
+ /**
48
+ * Resolution status for a sketch or spike entry. Phase 25 keeps the surface
49
+ * minimal — `resolved` is the only value v1.25 writes (sketches and spikes
50
+ * either complete and produce a D-XX, or they get a `<skipped/>` entry).
51
+ * Kept as a string union to leave room for `pending`/`abandoned` later
52
+ * without a parser change.
53
+ */
54
+ export type PrototypingEntryStatus = 'resolved';
55
+
39
56
  /**
40
57
  * Frontmatter block (between leading `---` fences). STATE.md frontmatter is
41
58
  * flat `key: value` — we parse it with a tiny hand-rolled reader (no YAML
@@ -90,6 +107,77 @@ export interface Blocker {
90
107
  text: string;
91
108
  }
92
109
 
110
+ /**
111
+ * Single `<sketch/>` child entry inside the `<prototyping>` block.
112
+ *
113
+ * Phase 25 Plan 25-01 (D-01): a sketch records a resolved exploration of a
114
+ * visual / direction question. The wrap-up flow (Plan 25-05) writes the
115
+ * resolution as a D-XX decision AND appends a `<sketch slug=… cycle=…
116
+ * decision=D-XX status=resolved/>` entry here so the decision-injector
117
+ * (Plan 25-06) can surface prior sketch outcomes to downstream agents.
118
+ *
119
+ * Unknown attributes seen by the parser are preserved in `extra_attrs` so
120
+ * forward-compat additions (e.g., a future `confidence=` attribute) round-
121
+ * trip through parse → serialize without loss.
122
+ */
123
+ export interface SketchEntry {
124
+ slug: string;
125
+ cycle: string;
126
+ decision: string;
127
+ status: PrototypingEntryStatus;
128
+ /** Verbatim copy of any attributes the parser did not recognize. Keys
129
+ * are attribute names; values are the unquoted attribute strings. */
130
+ extra_attrs: Record<string, string>;
131
+ }
132
+
133
+ /**
134
+ * Single `<spike/>` child entry inside the `<prototyping>` block.
135
+ *
136
+ * Phase 25 Plan 25-01 (D-01): a spike records a resolved feasibility
137
+ * probe. The `verdict` field captures the answer (`yes` / `no` /
138
+ * `partial`); the `decision` field links to the D-XX written by
139
+ * `spike-wrap-up` (Plan 25-05).
140
+ */
141
+ export interface SpikeEntry {
142
+ slug: string;
143
+ cycle: string;
144
+ decision: string;
145
+ verdict: SpikeVerdict;
146
+ status: PrototypingEntryStatus;
147
+ /** Forward-compat passthrough — same semantics as on `SketchEntry`. */
148
+ extra_attrs: Record<string, string>;
149
+ }
150
+
151
+ /**
152
+ * Single `<skipped/>` child entry inside the `<prototyping>` block.
153
+ *
154
+ * Phase 25 Plan 25-01 (D-02): cycle-scoped suppression of further prototype
155
+ * gate prompts. `at` is the firing point that was skipped (typically
156
+ * `explore` or `plan`); `cycle` mirrors the active cycle id; `reason` is a
157
+ * short free-form string captured at skip time.
158
+ */
159
+ export interface SkippedEntry {
160
+ at: string;
161
+ cycle: string;
162
+ reason: string;
163
+ /** Forward-compat passthrough — same semantics as on `SketchEntry`. */
164
+ extra_attrs: Record<string, string>;
165
+ }
166
+
167
+ /**
168
+ * Parsed `<prototyping>` block. `null` on `ParsedState` when the block is
169
+ * absent; an instance with all three arrays empty represents a present-but-
170
+ * empty block (rare — wrap-up flows always append something).
171
+ *
172
+ * The three arrays preserve insertion order — round-trip serialization
173
+ * emits children in the same order they appeared in the source file.
174
+ */
175
+ export interface PrototypingBlock {
176
+ sketches: SketchEntry[];
177
+ spikes: SpikeEntry[];
178
+ skipped: SkippedEntry[];
179
+ }
180
+
93
181
  /**
94
182
  * Canonical parsed shape of a STATE.md file. Consumers mutate this in-place
95
183
  * inside `mutate(path, fn)`, then the serializer projects it back to
@@ -115,6 +203,24 @@ export interface ParsedState {
115
203
  * with illustrative comments, so most fresh files carry a non-null body.
116
204
  */
117
205
  todos: string | null;
206
+ /**
207
+ * Parsed `<prototyping>` block (Phase 25 Plan 25-01 / D-01). `null` when
208
+ * the block is absent in the source — the serializer omits the block
209
+ * entirely in that case rather than emitting an empty `<prototyping>`
210
+ * pair. A non-null instance with all three arrays empty is permitted
211
+ * but only emitted when the source already had a present-but-empty
212
+ * block (preserves byte-identical round-trip).
213
+ */
214
+ prototyping: PrototypingBlock | null;
215
+ /**
216
+ * Parsed `<quality_gate>` block (Phase 25 Plan 25-03 / D-06..D-09).
217
+ * `null` when the block is absent in the source — the serializer omits
218
+ * the block entirely in that case rather than emitting an empty
219
+ * `<quality_gate>` pair. A non-null instance with `run === null` is
220
+ * permitted but only emitted when the source already had a present-but-
221
+ * empty block (preserves byte-identical round-trip).
222
+ */
223
+ quality_gate: QualityGateBlock | null;
118
224
  timestamps: Record<string, string>;
119
225
  /** Verbatim span between frontmatter end and the first recognized block. */
120
226
  body_preamble: string;
@@ -177,3 +283,90 @@ export function isDecisionStatus(value: unknown): value is DecisionStatus {
177
283
  export function isMustHaveStatus(value: unknown): value is MustHaveStatus {
178
284
  return value === 'pending' || value === 'pass' || value === 'fail';
179
285
  }
286
+
287
+ /** Type-guard for `SpikeVerdict`. */
288
+ export function isSpikeVerdict(value: unknown): value is SpikeVerdict {
289
+ return value === 'yes' || value === 'no' || value === 'partial';
290
+ }
291
+
292
+ /** Type-guard for `PrototypingEntryStatus`. */
293
+ export function isPrototypingEntryStatus(
294
+ value: unknown,
295
+ ): value is PrototypingEntryStatus {
296
+ return value === 'resolved';
297
+ }
298
+
299
+ /**
300
+ * Status of a `<quality_gate>` run (Phase 25 Plan 25-03 / D-06..D-09).
301
+ *
302
+ * - `pass` — every detected command exited 0 within the timeout budget.
303
+ * - `fail` — at least one command failed AND the fix loop reached
304
+ * `max_iters` without producing a clean run. Verify entry
305
+ * refuses on this status (Plan 25-07 territory).
306
+ * - `timeout` — the parallel command run exceeded
307
+ * `quality_gate.timeout_seconds`. Treated as a non-blocking
308
+ * warning per D-07 — verify entry warns, does not refuse.
309
+ * - `skipped` — the detection chain (D-06) resolved zero commands. The
310
+ * gate emits a notice and continues; verify entry does not
311
+ * block on `skipped`.
312
+ */
313
+ export type QualityGateStatus = 'pass' | 'fail' | 'timeout' | 'skipped';
314
+
315
+ /**
316
+ * Single resolved run captured in the `<quality_gate>` block (Phase 25
317
+ * Plan 25-03 / D-06..D-09). Append-mode would be overkill — only the most
318
+ * recent run is retained. The wrap-up flow (the SKILL's Step 5) overwrites
319
+ * this entry on every gate completion.
320
+ *
321
+ * Shape mirrors the corresponding `<run …/>` self-closing tag attribute set:
322
+ * `<run started_at=… completed_at=… status=… iteration=N commands_run="lint,typecheck,test"/>`
323
+ *
324
+ * `commands_run` is a comma-separated list rather than a `string[]` so the
325
+ * STATE block stays a single self-closing tag — no nested children, no
326
+ * order ambiguity in serialization.
327
+ *
328
+ * Unknown attributes seen on the `<run/>` tag are preserved in `extra_attrs`
329
+ * for forwards-compat (mirrors the prototyping pattern).
330
+ */
331
+ export interface QualityGateRun {
332
+ /** ISO 8601 timestamp at which Step 2 (parallel run) entered. */
333
+ started_at: string;
334
+ /** ISO 8601 timestamp at which the gate produced its terminal status. */
335
+ completed_at: string;
336
+ /** Terminal status emitted by Step 6 (event emission). */
337
+ status: QualityGateStatus;
338
+ /**
339
+ * Loop count from Step 4 (fix loop). `1` = single clean pass; `2..N` =
340
+ * required at least one fixer iteration; `N === max_iters` with
341
+ * `status === 'fail'` = bounded exhaustion.
342
+ */
343
+ iteration: number;
344
+ /**
345
+ * Comma-separated list of command names actually executed in Step 2 —
346
+ * e.g. `"lint,typecheck,test"`. Empty string when `status === 'skipped'`.
347
+ */
348
+ commands_run: string;
349
+ /** Forward-compat passthrough — same semantics as on `SketchEntry`. */
350
+ extra_attrs: Record<string, string>;
351
+ }
352
+
353
+ /**
354
+ * Parsed `<quality_gate>` block. The block houses a single most-recent
355
+ * `<run/>` entry; `null` on `ParsedState` means the block is absent in the
356
+ * source (no gate has run yet on this STATE.md). `run === null` inside a
357
+ * non-null block represents a present-but-empty block (rare — the SKILL
358
+ * always writes a `<run/>` before closing).
359
+ */
360
+ export interface QualityGateBlock {
361
+ run: QualityGateRun | null;
362
+ }
363
+
364
+ /** Type-guard for `QualityGateStatus`. */
365
+ export function isQualityGateStatus(value: unknown): value is QualityGateStatus {
366
+ return (
367
+ value === 'pass' ||
368
+ value === 'fail' ||
369
+ value === 'timeout' ||
370
+ value === 'skipped'
371
+ );
372
+ }