@hegemonart/get-design-done 1.24.2 → 1.26.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 (60) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +87 -0
  4. package/README.de.md +679 -0
  5. package/README.fr.md +679 -0
  6. package/README.it.md +679 -0
  7. package/README.ja.md +679 -0
  8. package/README.ko.md +679 -0
  9. package/README.md +399 -728
  10. package/README.zh-CN.md +480 -133
  11. package/SKILL.md +2 -0
  12. package/agents/README.md +60 -0
  13. package/agents/design-reflector.md +43 -0
  14. package/agents/gdd-intel-updater.md +34 -1
  15. package/agents/prototype-gate.md +122 -0
  16. package/agents/quality-gate-runner.md +125 -0
  17. package/hooks/budget-enforcer.ts +275 -11
  18. package/hooks/gdd-decision-injector.js +183 -3
  19. package/hooks/gdd-turn-closeout.js +238 -0
  20. package/hooks/hooks.json +10 -0
  21. package/package.json +5 -5
  22. package/reference/STATE-TEMPLATE.md +41 -0
  23. package/reference/config-schema.md +30 -0
  24. package/reference/model-prices.md +40 -19
  25. package/reference/prices/antigravity.md +21 -0
  26. package/reference/prices/augment.md +21 -0
  27. package/reference/prices/claude.md +42 -0
  28. package/reference/prices/cline.md +23 -0
  29. package/reference/prices/codebuddy.md +21 -0
  30. package/reference/prices/codex.md +25 -0
  31. package/reference/prices/copilot.md +21 -0
  32. package/reference/prices/cursor.md +21 -0
  33. package/reference/prices/gemini.md +25 -0
  34. package/reference/prices/kilo.md +21 -0
  35. package/reference/prices/opencode.md +23 -0
  36. package/reference/prices/qwen.md +25 -0
  37. package/reference/prices/trae.md +23 -0
  38. package/reference/prices/windsurf.md +21 -0
  39. package/reference/registry.json +107 -1
  40. package/reference/runtime-models.md +446 -0
  41. package/reference/schemas/runtime-models.schema.json +123 -0
  42. package/scripts/install.cjs +8 -0
  43. package/scripts/lib/budget-enforcer.cjs +446 -0
  44. package/scripts/lib/cost-arbitrage.cjs +294 -0
  45. package/scripts/lib/gdd-state/mutator.ts +454 -0
  46. package/scripts/lib/gdd-state/parser.ts +351 -1
  47. package/scripts/lib/gdd-state/types.ts +193 -0
  48. package/scripts/lib/install/installer.cjs +188 -11
  49. package/scripts/lib/install/parse-runtime-models.cjs +267 -0
  50. package/scripts/lib/install/runtimes.cjs +43 -0
  51. package/scripts/lib/quality-gate-detect.cjs +126 -0
  52. package/scripts/lib/runtime-detect.cjs +96 -0
  53. package/scripts/lib/tier-resolver.cjs +311 -0
  54. package/scripts/validate-frontmatter.ts +138 -1
  55. package/skills/quality-gate/SKILL.md +222 -0
  56. package/skills/router/SKILL.md +79 -10
  57. package/skills/sketch-wrap-up/SKILL.md +47 -2
  58. package/skills/spike-wrap-up/SKILL.md +41 -2
  59. package/skills/turn-closeout/SKILL.md +115 -0
  60. package/skills/verify/SKILL.md +22 -0
@@ -72,6 +72,38 @@ function resolveHookPath(): string {
72
72
  const nodeRequire = createRequire(resolveHookPath());
73
73
  const rateGuard = nodeRequire('../scripts/lib/rate-guard.cjs') as typeof import('../scripts/lib/rate-guard.cjs');
74
74
  const iterationBudget = nodeRequire('../scripts/lib/iteration-budget.cjs') as typeof import('../scripts/lib/iteration-budget.cjs');
75
+ // Plan 26-05: shared cost-computation backend for the resolved_models
76
+ // consumer path. Pure module — takes (model_id, runtime, token_counts) →
77
+ // cost_usd by reading per-runtime price tables under reference/prices/.
78
+ // See scripts/lib/budget-enforcer.cjs for the lookup chain.
79
+ interface BudgetEnforcerBackend {
80
+ computeCost(args: {
81
+ model_id?: string | null;
82
+ tier?: string | null;
83
+ runtime: string;
84
+ tokens_in: number;
85
+ tokens_out: number;
86
+ cache_hit?: boolean;
87
+ }): {
88
+ cost_usd: number | null;
89
+ model: string | null;
90
+ tier: string | null;
91
+ runtime_used: string | null;
92
+ fallback: boolean;
93
+ reason: string | null;
94
+ };
95
+ modelFromResolved(resolved: unknown, agent: string): string | null;
96
+ }
97
+ const budgetBackend = nodeRequire('../scripts/lib/budget-enforcer.cjs') as BudgetEnforcerBackend;
98
+ // Plan 26-05: runtime detection for the cost-event runtime tag. Returns
99
+ // 'claude' for the CC hook context (CLAUDE_CONFIG_DIR is set when CC is
100
+ // the host), null when running outside any of the 14 runtime envs (e.g.
101
+ // CI matrix). The hook defaults the null case to 'claude' since the .ts
102
+ // hook only runs inside CC anyway.
103
+ interface RuntimeDetectModule {
104
+ detect(): string | null;
105
+ }
106
+ const runtimeDetect = nodeRequire('../scripts/lib/runtime-detect.cjs') as RuntimeDetectModule;
75
107
 
76
108
  // ── Types ───────────────────────────────────────────────────────────────────
77
109
 
@@ -80,6 +112,45 @@ const iterationBudget = nodeRequire('../scripts/lib/iteration-budget.cjs') as ty
80
112
  * for every hook invocation. The tool_input shape is tool-specific;
81
113
  * this hook only consumes Agent-shaped tool_input so we narrow here.
82
114
  */
115
+ /** Phase 25 / D-04, D-05: router complexity-class enum. */
116
+ export type ComplexityClass = 'S' | 'M' | 'L' | 'XL';
117
+
118
+ /**
119
+ * Phase 25 / D-05: router decision payload as surfaced on
120
+ * tool_input.context.router_decision. Only the fields this hook reads
121
+ * are typed; the router emits more (model_tier_overrides,
122
+ * estimated_cost_usd, cache_hits) but they are not consumed here.
123
+ */
124
+ interface RouterDecision {
125
+ path?: 'fast' | 'quick' | 'full';
126
+ complexity_class?: ComplexityClass;
127
+ /**
128
+ * Phase 26 / D-07: per-agent concrete model name resolved by the
129
+ * router via `scripts/lib/tier-resolver.cjs`. Strict superset of
130
+ * `model_tier_overrides` — existing consumers still read tier names
131
+ * from `model_tier_overrides`; new consumers read `resolved_models`
132
+ * for runtime-correct cost lookup.
133
+ */
134
+ resolved_models?: Record<string, string>;
135
+ /**
136
+ * Phase 26 / D-08: runtime ID the router computed `resolved_models`
137
+ * against. Optional; the hook falls back to `runtime-detect.cjs`
138
+ * when absent.
139
+ */
140
+ runtime?: string;
141
+ /**
142
+ * Phase 25 back-compat: tier-name overrides per agent. Phase 26 keeps
143
+ * this as the legacy fallback path when `resolved_models` is absent.
144
+ */
145
+ model_tier_overrides?: Record<string, string>;
146
+ [key: string]: unknown;
147
+ }
148
+
149
+ interface ToolInputContext {
150
+ router_decision?: RouterDecision;
151
+ [key: string]: unknown;
152
+ }
153
+
83
154
  interface ToolInput {
84
155
  subagent_type?: string;
85
156
  agent?: string;
@@ -91,6 +162,7 @@ interface ToolInput {
91
162
  _default_tier?: string;
92
163
  _tier_downgraded?: boolean;
93
164
  lazy_skipped?: boolean;
165
+ context?: ToolInputContext;
94
166
  [key: string]: unknown;
95
167
  }
96
168
 
@@ -199,6 +271,46 @@ const BUDGET_DEFAULTS: Required<
199
271
  enforcement_mode: 'enforce',
200
272
  };
201
273
 
274
+ /**
275
+ * Phase 25 / D-05: optional per-class cap map on .design/budget.json.
276
+ * Documented in reference/config-schema.md as `class_caps_usd?: { S?: number; M?: number; L?: number; XL?: number }`.
277
+ * Read through the BudgetSchema index signature so we don't have to
278
+ * regenerate the schema for an additive optional field.
279
+ */
280
+ type ClassCapsUsd = Partial<Record<ComplexityClass, number>>;
281
+
282
+ function readClassCaps(budget: BudgetSchema): ClassCapsUsd | undefined {
283
+ const raw = (budget as { class_caps_usd?: unknown }).class_caps_usd;
284
+ if (raw === undefined || raw === null || typeof raw !== 'object') {
285
+ return undefined;
286
+ }
287
+ const out: ClassCapsUsd = {};
288
+ for (const k of ['S', 'M', 'L', 'XL'] as const) {
289
+ const v = (raw as Record<string, unknown>)[k];
290
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0) {
291
+ out[k] = v;
292
+ }
293
+ }
294
+ return out;
295
+ }
296
+
297
+ /**
298
+ * Phase 25 / D-05: resolve the per-spawn cap. If the router decision
299
+ * payload contains a `complexity_class` AND `.design/budget.json#class_caps_usd[class]`
300
+ * is defined, use that. Otherwise fall back to `per_task_cap_usd`.
301
+ */
302
+ function resolvePerSpawnCap(
303
+ budget: ResolvedBudget,
304
+ complexityClass: ComplexityClass | undefined,
305
+ ): number {
306
+ if (complexityClass !== undefined) {
307
+ const caps = readClassCaps(budget);
308
+ const classCap = caps?.[complexityClass];
309
+ if (classCap !== undefined) return classCap;
310
+ }
311
+ return budget.per_task_cap_usd;
312
+ }
313
+
202
314
  /**
203
315
  * Concrete budget shape after defaults-merge. Every field becomes
204
316
  * non-optional so downstream branches don't have to null-guard. Defined
@@ -459,6 +571,53 @@ function emitHookFired(decision: HookDecision, cycle?: string): void {
459
571
  }
460
572
  }
461
573
 
574
+ /**
575
+ * Plan 26-05 / D-08: emit a `cost_recorded` event with runtime tag,
576
+ * concrete model, tier, token counts, and computed cost. Cost-aggregator
577
+ * downstream rolls these up per-runtime AND per-tier so reflector class-
578
+ * specific cost analysis (Phase 26-06) can compare apples-to-apples
579
+ * across runtimes.
580
+ *
581
+ * The event uses the BaseEvent envelope shape (free-form `type` per
582
+ * Phase 22 events.jsonl contract). Fail-open like every other emit in
583
+ * this hook — never block the spawn on a telemetry failure.
584
+ */
585
+ function emitCostRecorded(
586
+ payload: {
587
+ runtime: string;
588
+ agent: string;
589
+ model_id: string | null;
590
+ tier: string | null;
591
+ tokens_in: number;
592
+ tokens_out: number;
593
+ cost_usd: number | null;
594
+ },
595
+ cycle?: string,
596
+ ): void {
597
+ const ev = {
598
+ type: 'cost_recorded',
599
+ timestamp: new Date().toISOString(),
600
+ sessionId: getSessionId(),
601
+ ...(cycle !== undefined && cycle !== 'unknown' ? { cycle } : {}),
602
+ payload: {
603
+ runtime: payload.runtime,
604
+ agent: payload.agent,
605
+ model_id: payload.model_id,
606
+ tier: payload.tier,
607
+ tokens_in: payload.tokens_in,
608
+ tokens_out: payload.tokens_out,
609
+ cost_usd: payload.cost_usd,
610
+ },
611
+ };
612
+ try {
613
+ // BaseEvent shape; cost_recorded is a free-form subtype (the
614
+ // Phase 22 events stream is structurally validated, not enum-locked).
615
+ appendEvent(ev as unknown as HookFiredEvent);
616
+ } catch {
617
+ // Fail open.
618
+ }
619
+ }
620
+
462
621
  // ── main ────────────────────────────────────────────────────────────────────
463
622
 
464
623
  async function readStdin(): Promise<string> {
@@ -490,6 +649,27 @@ export async function main(): Promise<void> {
490
649
  const inputHash =
491
650
  typeof toolInput._input_hash === 'string' ? toolInput._input_hash : null;
492
651
 
652
+ // Phase 25 / D-05: extract complexity_class from router decision.
653
+ // Absent payload → legacy per_task_cap behavior (no regression).
654
+ // Present payload with class === 'S' → skip enforcement entirely
655
+ // (defensive: the typical S path is upstream short-circuit where
656
+ // router never ran and this hook still applies legacy caps; an
657
+ // explicit S signal here means a caller bypassed the upstream skip
658
+ // and is asking us to honor the class).
659
+ const routerDecision: RouterDecision | undefined =
660
+ toolInput.context?.router_decision !== undefined &&
661
+ typeof toolInput.context.router_decision === 'object' &&
662
+ toolInput.context.router_decision !== null
663
+ ? toolInput.context.router_decision
664
+ : undefined;
665
+ const complexityClass: ComplexityClass | undefined =
666
+ routerDecision?.complexity_class !== undefined &&
667
+ (['S', 'M', 'L', 'XL'] as const).includes(
668
+ routerDecision.complexity_class as ComplexityClass,
669
+ )
670
+ ? (routerDecision.complexity_class as ComplexityClass)
671
+ : undefined;
672
+
493
673
  const { cycle, phase } = readCycleAndPhase();
494
674
  const cyclePhase = { cycle, phase };
495
675
 
@@ -513,6 +693,38 @@ export async function main(): Promise<void> {
513
693
 
514
694
  const budget = loadBudget();
515
695
 
696
+ // Phase 25 / D-05: explicit S-class short-circuit. The typical S path
697
+ // skips the router entirely and this hook never runs at all (the
698
+ // command's SKILL.md does the deterministic skip upstream). When we
699
+ // DO see complexity_class === 'S' in the payload it means a caller
700
+ // routed an S-class command through the hook anyway — honor the
701
+ // class by skipping enforcement (no cap check, no downgrade) but
702
+ // still write a zero-cost telemetry row + emit an 'allow' event so
703
+ // observability stays consistent.
704
+ if (complexityClass === 'S') {
705
+ writeTelemetry({
706
+ agent,
707
+ tier:
708
+ toolInput._tier_override ??
709
+ toolInput._default_tier ??
710
+ 'haiku',
711
+ tokens_in: Number(toolInput._tokens_in_est ?? 0),
712
+ tokens_out: Number(toolInput._tokens_out_est ?? 0),
713
+ cache_hit: false,
714
+ est_cost_usd: Number(toolInput._est_cost_usd ?? 0),
715
+ enforcement_mode: budget.enforcement_mode,
716
+ _cyclePhase: cyclePhase,
717
+ });
718
+ emitHookFired('allow', cycle);
719
+ const response: ToolOutput = {
720
+ continue: true,
721
+ suppressOutput: true,
722
+ modified_tool_input: toolInput,
723
+ };
724
+ process.stdout.write(JSON.stringify(response));
725
+ return;
726
+ }
727
+
516
728
  // Branch B: cache short-circuit (D-05).
517
729
  if (inputHash !== null) {
518
730
  const cached = cacheLookup(agent, inputHash);
@@ -589,9 +801,15 @@ export async function main(): Promise<void> {
589
801
  const estCost = Number(toolInput._est_cost_usd ?? 0);
590
802
  const phaseSpend = currentPhaseSpend(phase);
591
803
 
804
+ // Phase 25 / D-05: per-spawn cap is class-specific when
805
+ // complexity_class is present and class_caps_usd[class] is defined.
806
+ // Falls back to per_task_cap_usd for backwards compatibility — when
807
+ // no router decision is supplied, behavior is identical to pre-25.
808
+ const perSpawnCap = resolvePerSpawnCap(budget, complexityClass);
809
+
592
810
  if (budget.enforcement_mode === 'enforce') {
593
- // Branch C: 100% per_task cap hard block.
594
- if (estCost >= budget.per_task_cap_usd) {
811
+ // Branch C: 100% per-spawn cap hard block (class-specific or per_task).
812
+ if (estCost >= perSpawnCap) {
595
813
  writeTelemetry({
596
814
  agent,
597
815
  tier:
@@ -607,10 +825,14 @@ export async function main(): Promise<void> {
607
825
  _cyclePhase: cyclePhase,
608
826
  });
609
827
  emitHookFired('block', cycle);
828
+ const capLabel =
829
+ complexityClass !== undefined && perSpawnCap !== budget.per_task_cap_usd
830
+ ? `class_caps_usd.${complexityClass}`
831
+ : 'per-task';
610
832
  const response: ToolOutput = {
611
833
  continue: false,
612
834
  suppressOutput: false,
613
- message: `Budget cap reached for per-task. Estimated: $${estCost.toFixed(4)}, cap: $${budget.per_task_cap_usd.toFixed(2)}. Raise cap in .design/budget.json or retry after next task.`,
835
+ message: `Budget cap reached for ${capLabel}. Estimated: $${estCost.toFixed(4)}, cap: $${perSpawnCap.toFixed(2)}. Raise cap in .design/budget.json or retry after next task.`,
614
836
  };
615
837
  process.stdout.write(JSON.stringify(response));
616
838
  return;
@@ -640,18 +862,19 @@ export async function main(): Promise<void> {
640
862
  process.stdout.write(JSON.stringify(response));
641
863
  return;
642
864
  }
643
- // 80% soft-threshold downgrade (D-03): task-scoped.
865
+ // 80% soft-threshold downgrade (D-03): task-scoped, against the
866
+ // resolved per-spawn cap so class-specific caps participate.
644
867
  if (
645
868
  budget.auto_downgrade_on_cap &&
646
- estCost >= 0.8 * budget.per_task_cap_usd
869
+ estCost >= 0.8 * perSpawnCap
647
870
  ) {
648
871
  toolInput._tier_override = 'haiku';
649
872
  toolInput._tier_downgraded = true;
650
873
  }
651
874
  } else if (budget.enforcement_mode === 'warn') {
652
- if (estCost >= budget.per_task_cap_usd) {
875
+ if (estCost >= perSpawnCap) {
653
876
  process.stderr.write(
654
- `gdd-budget-enforcer WARN: per-task cap will be exceeded ($${estCost.toFixed(4)} >= $${budget.per_task_cap_usd})\n`,
877
+ `gdd-budget-enforcer WARN: per-spawn cap will be exceeded ($${estCost.toFixed(4)} >= $${perSpawnCap})\n`,
655
878
  );
656
879
  }
657
880
  }
@@ -662,13 +885,54 @@ export async function main(): Promise<void> {
662
885
  toolInput._tier_override = budget.tier_overrides[agent];
663
886
  }
664
887
 
888
+ // Plan 26-05 / D-07 + D-08: resolved_models consumer path. When the
889
+ // router decision payload carries a concrete model ID for this agent
890
+ // under `resolved_models`, look up the cost in the per-runtime price
891
+ // table by model ID. Otherwise fall back to the legacy tier-name
892
+ // lookup (which still resolves through claude.md as the default
893
+ // runtime — back-compat with v1.25.x callers).
894
+ const resolvedModelId = budgetBackend.modelFromResolved(
895
+ routerDecision?.resolved_models,
896
+ agent,
897
+ );
898
+ const resolvedTier =
899
+ toolInput._tier_override ?? toolInput._default_tier ?? 'sonnet';
900
+ // Runtime tag: prefer the router's explicit `runtime` (D-08) field;
901
+ // fall back to env-var detection; default to 'claude' since the .ts
902
+ // hook itself only runs inside Claude Code.
903
+ const runtimeId =
904
+ (typeof routerDecision?.runtime === 'string' && routerDecision.runtime.length > 0
905
+ ? routerDecision.runtime
906
+ : runtimeDetect.detect()) ?? 'claude';
907
+
908
+ // Compute runtime-aware cost via the shared backend. Failures return
909
+ // null cost; we emit the event regardless so the cost-aggregator sees
910
+ // the lookup attempt (Phase 22 events.jsonl tagging).
911
+ const costLookup = budgetBackend.computeCost({
912
+ model_id: resolvedModelId,
913
+ tier: resolvedTier,
914
+ runtime: runtimeId,
915
+ tokens_in: Number(toolInput._tokens_in_est ?? 0),
916
+ tokens_out: Number(toolInput._tokens_out_est ?? 0),
917
+ cache_hit: false,
918
+ });
919
+ emitCostRecorded(
920
+ {
921
+ runtime: runtimeId,
922
+ agent,
923
+ model_id: resolvedModelId ?? costLookup.model,
924
+ tier: costLookup.tier ?? resolvedTier,
925
+ tokens_in: Number(toolInput._tokens_in_est ?? 0),
926
+ tokens_out: Number(toolInput._tokens_out_est ?? 0),
927
+ cost_usd: costLookup.cost_usd,
928
+ },
929
+ cycle,
930
+ );
931
+
665
932
  // Branch E: standard spawn-allowed (includes tier-downgraded path).
666
933
  writeTelemetry({
667
934
  agent,
668
- tier:
669
- toolInput._tier_override ??
670
- toolInput._default_tier ??
671
- 'sonnet',
935
+ tier: resolvedTier,
672
936
  tokens_in: Number(toolInput._tokens_in_est ?? 0),
673
937
  tokens_out: Number(toolInput._tokens_out_est ?? 0),
674
938
  cache_hit: false,
@@ -23,6 +23,7 @@ const { spawnSync } = require('child_process');
23
23
 
24
24
  const MIN_BYTES = 1500;
25
25
  const TOP_N = 15;
26
+ const PROTOTYPING_TOP_N = 5;
26
27
  const MATCHER_RE = /[\\/](?:\.design|reference|\.planning)[\\/][^\n]*\.md$/;
27
28
 
28
29
  // Phase 19.5: try FTS5 backend first; fall back to grep silently.
@@ -111,6 +112,174 @@ function sortKeyFor(tag) {
111
112
  return 0;
112
113
  }
113
114
 
115
+ /**
116
+ * Parse a self-closing-tag attribute string ("a=\"x\" b=\"y\"") into a kv map.
117
+ * Self-contained: avoids a TS-parser import to keep the hook hot path JS-only.
118
+ */
119
+ function parseAttrs(attrStr) {
120
+ const out = {};
121
+ if (!attrStr) return out;
122
+ const re = /(\w+)\s*=\s*"([^"]*)"/g;
123
+ let m;
124
+ while ((m = re.exec(attrStr)) !== null) out[m[1]] = m[2];
125
+ return out;
126
+ }
127
+
128
+ /**
129
+ * One-shot read of STATE.md. Returns `{ prototyping, decisionsMap }` where
130
+ * `prototyping` is the inner body of `<prototyping>...</prototyping>` (or '')
131
+ * and `decisionsMap` is a `D-XX -> rationale` lookup parsed from `<decisions>`.
132
+ * Both fields default to safe empties on unreadable file / absent blocks.
133
+ *
134
+ * Single read keeps the hot path tight (STATE.md is small but reading once
135
+ * beats reading twice).
136
+ */
137
+ function readStateForPrototyping(stateFile) {
138
+ const empty = { prototyping: '', decisionsMap: Object.create(null) };
139
+ if (!stateFile) return empty;
140
+ let content;
141
+ try { content = fs.readFileSync(stateFile, 'utf8'); } catch { return empty; }
142
+ const out = { prototyping: '', decisionsMap: Object.create(null) };
143
+ const protoMatch = content.match(/<prototyping>([\s\S]*?)<\/prototyping>/);
144
+ if (protoMatch) out.prototyping = protoMatch[1];
145
+ const decBlock = content.match(/<decisions>([\s\S]*?)<\/decisions>/);
146
+ if (decBlock) {
147
+ const re = /^\s*(D-\d+)\s*:\s*(.+?)\s*$/gm;
148
+ let m;
149
+ while ((m = re.exec(decBlock[1])) !== null) {
150
+ // Strip a trailing `(locked)` / `(tentative)` qualifier if present.
151
+ out.decisionsMap[m[1]] = m[2].replace(/\s*\((?:locked|tentative)\)\s*$/i, '').trim();
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
157
+ /**
158
+ * Parse `<prototyping>` body into typed entries. Skips comments and unknown tags.
159
+ */
160
+ function parsePrototypingEntries(body) {
161
+ const entries = [];
162
+ if (!body) return entries;
163
+ const re = /<(sketch|spike|skipped)\b([^>]*?)\/>/g;
164
+ let m;
165
+ while ((m = re.exec(body)) !== null) {
166
+ const type = m[1];
167
+ const attrs = parseAttrs(m[2]);
168
+ entries.push({ type, attrs });
169
+ }
170
+ return entries;
171
+ }
172
+
173
+ /**
174
+ * Tokenize a slug / basename / path for fuzzy comparison.
175
+ * Splits on hyphens, underscores, dots, and path separators; lowercases;
176
+ * drops common no-signal tokens (`md`, file extensions, single chars).
177
+ */
178
+ function tokenize(s) {
179
+ if (!s) return [];
180
+ const parts = String(s).toLowerCase().split(/[-_./\\\s]+/).filter(Boolean);
181
+ const stop = new Set(['md', 'txt', 'json', 'ts', 'js', 'plan', 'context', 'state']);
182
+ return parts.filter((p) => p.length > 1 && !stop.has(p));
183
+ }
184
+
185
+ /**
186
+ * Score a prototyping entry against the opened file's basename + relPath tokens.
187
+ * Returns the entry's matcher term if any slug-token is shared with a
188
+ * basename/relPath token (case-insensitive). Falls back to plain substring
189
+ * for terms that don't tokenize (e.g., free-form `reason` strings).
190
+ *
191
+ * Symmetric with the D-XX matcher: the existing recall path greps source
192
+ * lines for the opened file's basename; here we surface a prototyping entry
193
+ * whenever it would have grepped successfully — when the entry's slug
194
+ * mentions the same concept the file's name encodes.
195
+ */
196
+ function matchPrototypingEntry(entry, basename, relPath) {
197
+ let term;
198
+ if (entry.type === 'sketch' || entry.type === 'spike') {
199
+ term = entry.attrs.slug;
200
+ } else if (entry.type === 'skipped') {
201
+ term = entry.attrs.reason;
202
+ }
203
+ if (!term) return null;
204
+ const fileTokens = new Set([...tokenize(basename), ...tokenize(relPath)]);
205
+ if (fileTokens.size === 0) return null;
206
+ const termTokens = tokenize(term);
207
+ for (const t of termTokens) {
208
+ if (fileTokens.has(t)) return term;
209
+ }
210
+ // Fallback: plain substring (helps `reason` strings and slugs containing
211
+ // tokens that don't survive the stop-word filter).
212
+ const needle = String(term).toLowerCase();
213
+ if (basename.toLowerCase().includes(needle) || relPath.toLowerCase().includes(needle)) return term;
214
+ return null;
215
+ }
216
+
217
+ /**
218
+ * Format a single prototyping entry for the additionalContext block.
219
+ * Shape: "Prototyping outcome (cycle <cycle>): <type>/<slug> — D-<id> — <verdict-or-status>: <rationale>"
220
+ * Falls back gracefully when fields are missing (e.g., skipped entries lack a D-XX).
221
+ */
222
+ function formatPrototypingEntry(entry, decisionsMap) {
223
+ const a = entry.attrs;
224
+ const cycle = a.cycle || '?';
225
+ const ident = a.slug || a.at || '?';
226
+ const segs = [`Prototyping outcome (cycle ${cycle}): ${entry.type}/${ident}`];
227
+ if (a.decision) {
228
+ const rationale = decisionsMap[a.decision];
229
+ segs.push(rationale ? `${a.decision} — ${rationale}` : a.decision);
230
+ }
231
+ if (entry.type === 'spike' && a.verdict) {
232
+ segs.push(`verdict: ${a.verdict}`);
233
+ } else if (a.status) {
234
+ segs.push(`status: ${a.status}`);
235
+ } else if (entry.type === 'skipped' && a.reason) {
236
+ segs.push(`reason: ${a.reason}`);
237
+ }
238
+ return segs.join(' — ');
239
+ }
240
+
241
+ /**
242
+ * Build the prototyping outcomes block. Returns null when nothing matches so the
243
+ * caller can decide whether to omit the heading entirely.
244
+ *
245
+ * Sort: most recent cycle first (matches the existing sortKeyFor recency bias).
246
+ */
247
+ function buildPrototypingBlock(stateFile, basename, relPath) {
248
+ if (!stateFile) return null;
249
+ const { prototyping, decisionsMap } = readStateForPrototyping(stateFile);
250
+ if (!prototyping) return null;
251
+ const entries = parsePrototypingEntries(prototyping);
252
+ if (!entries.length) return null;
253
+
254
+ const matched = [];
255
+ for (const e of entries) {
256
+ const term = matchPrototypingEntry(e, basename, relPath);
257
+ if (term) matched.push(e);
258
+ }
259
+ if (!matched.length) return null;
260
+
261
+ // Recency: cycle is typically `cycle-N` or `N`; coerce to a number for sorting.
262
+ const cycleNum = (e) => {
263
+ const c = String(e.attrs.cycle || '');
264
+ const m = c.match(/(\d+)/);
265
+ return m ? Number(m[1]) : 0;
266
+ };
267
+ matched.sort((a, b) => cycleNum(b) - cycleNum(a));
268
+ const top = matched.slice(0, PROTOTYPING_TOP_N);
269
+
270
+ const lines = [];
271
+ lines.push('');
272
+ lines.push('### Prior prototyping outcomes');
273
+ for (const e of top) {
274
+ lines.push(`> - ${formatPrototypingEntry(e, decisionsMap)}`);
275
+ }
276
+ if (matched.length > PROTOTYPING_TOP_N) {
277
+ lines.push(`> … (${matched.length - PROTOTYPING_TOP_N} more prototyping entr${matched.length - PROTOTYPING_TOP_N === 1 ? 'y' : 'ies'})`);
278
+ }
279
+ lines.push('');
280
+ return lines.join('\n');
281
+ }
282
+
114
283
  function buildRecallBlock(matches, basename, backendLabel) {
115
284
  if (!matches.length) return null;
116
285
  const uniq = [];
@@ -202,16 +371,27 @@ async function main() {
202
371
 
203
372
  const backendLabel = BACKEND || (useRgGlobal ? 'ripgrep' : 'node-grep');
204
373
  const block = buildRecallBlock(hits, basename, backendLabel);
205
- if (!block) {
374
+
375
+ // Phase 25 (plan 25-06): surface <prototyping> outcomes when an opened
376
+ // planning/design .md ≥1500 bytes shares a slug/reason token with a
377
+ // resolved sketch/spike/skipped entry. STATE.md is the canonical home for
378
+ // the block (D-01); we read it directly here rather than via the TS parser
379
+ // so the hook stays self-contained JS.
380
+ const stateFile = sources.find((p) => p.endsWith(path.sep + 'STATE.md') || p.endsWith('/STATE.md'));
381
+ const protoBlock = buildPrototypingBlock(stateFile, basename, relPath);
382
+
383
+ if (!block && !protoBlock) {
206
384
  try { require('./_hook-emit.js').emitHookFired('gdd-decision-injector', 'no-hits', { backend: backendLabel }); } catch { /* swallow */ }
207
385
  process.stdout.write(JSON.stringify({ continue: true }));
208
386
  return;
209
387
  }
210
388
 
211
- try { require('./_hook-emit.js').emitHookFired('gdd-decision-injector', 'inject', { backend: backendLabel, hit_count: hits.length }); } catch { /* swallow */ }
389
+ const additionalContext = [block, protoBlock].filter(Boolean).join('\n');
390
+
391
+ try { require('./_hook-emit.js').emitHookFired('gdd-decision-injector', 'inject', { backend: backendLabel, hit_count: hits.length, prototyping: !!protoBlock }); } catch { /* swallow */ }
212
392
  process.stdout.write(JSON.stringify({
213
393
  continue: true,
214
- hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: block },
394
+ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext },
215
395
  }));
216
396
  }
217
397