@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +87 -0
- package/README.de.md +679 -0
- package/README.fr.md +679 -0
- package/README.it.md +679 -0
- package/README.ja.md +679 -0
- package/README.ko.md +679 -0
- package/README.md +399 -728
- package/README.zh-CN.md +480 -133
- package/SKILL.md +2 -0
- package/agents/README.md +60 -0
- package/agents/design-reflector.md +43 -0
- package/agents/gdd-intel-updater.md +34 -1
- package/agents/prototype-gate.md +122 -0
- package/agents/quality-gate-runner.md +125 -0
- package/hooks/budget-enforcer.ts +275 -11
- package/hooks/gdd-decision-injector.js +183 -3
- package/hooks/gdd-turn-closeout.js +238 -0
- package/hooks/hooks.json +10 -0
- package/package.json +5 -5
- package/reference/STATE-TEMPLATE.md +41 -0
- package/reference/config-schema.md +30 -0
- package/reference/model-prices.md +40 -19
- package/reference/prices/antigravity.md +21 -0
- package/reference/prices/augment.md +21 -0
- package/reference/prices/claude.md +42 -0
- package/reference/prices/cline.md +23 -0
- package/reference/prices/codebuddy.md +21 -0
- package/reference/prices/codex.md +25 -0
- package/reference/prices/copilot.md +21 -0
- package/reference/prices/cursor.md +21 -0
- package/reference/prices/gemini.md +25 -0
- package/reference/prices/kilo.md +21 -0
- package/reference/prices/opencode.md +23 -0
- package/reference/prices/qwen.md +25 -0
- package/reference/prices/trae.md +23 -0
- package/reference/prices/windsurf.md +21 -0
- package/reference/registry.json +107 -1
- package/reference/runtime-models.md +446 -0
- package/reference/schemas/runtime-models.schema.json +123 -0
- package/scripts/install.cjs +8 -0
- package/scripts/lib/budget-enforcer.cjs +446 -0
- package/scripts/lib/cost-arbitrage.cjs +294 -0
- package/scripts/lib/gdd-state/mutator.ts +454 -0
- package/scripts/lib/gdd-state/parser.ts +351 -1
- package/scripts/lib/gdd-state/types.ts +193 -0
- package/scripts/lib/install/installer.cjs +188 -11
- package/scripts/lib/install/parse-runtime-models.cjs +267 -0
- package/scripts/lib/install/runtimes.cjs +43 -0
- package/scripts/lib/quality-gate-detect.cjs +126 -0
- package/scripts/lib/runtime-detect.cjs +96 -0
- package/scripts/lib/tier-resolver.cjs +311 -0
- package/scripts/validate-frontmatter.ts +138 -1
- package/skills/quality-gate/SKILL.md +222 -0
- package/skills/router/SKILL.md +79 -10
- package/skills/sketch-wrap-up/SKILL.md +47 -2
- package/skills/spike-wrap-up/SKILL.md +41 -2
- package/skills/turn-closeout/SKILL.md +115 -0
- package/skills/verify/SKILL.md +22 -0
package/hooks/budget-enforcer.ts
CHANGED
|
@@ -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%
|
|
594
|
-
if (estCost >=
|
|
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
|
|
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 *
|
|
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 >=
|
|
875
|
+
if (estCost >= perSpawnCap) {
|
|
653
876
|
process.stderr.write(
|
|
654
|
-
`gdd-budget-enforcer WARN: per-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
394
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext },
|
|
215
395
|
}));
|
|
216
396
|
}
|
|
217
397
|
|