gsd-pi 2.18.0 → 2.19.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/dist/resources/extensions/gsd/auto-dashboard.ts +14 -2
- package/dist/resources/extensions/gsd/auto-prompts.ts +45 -15
- package/dist/resources/extensions/gsd/auto.ts +276 -19
- package/dist/resources/extensions/gsd/captures.ts +384 -0
- package/dist/resources/extensions/gsd/commands.ts +139 -3
- package/dist/resources/extensions/gsd/complexity-classifier.ts +322 -0
- package/dist/resources/extensions/gsd/dashboard-overlay.ts +10 -0
- package/dist/resources/extensions/gsd/metrics.ts +48 -0
- package/dist/resources/extensions/gsd/model-cost-table.ts +65 -0
- package/dist/resources/extensions/gsd/model-router.ts +256 -0
- package/dist/resources/extensions/gsd/post-unit-hooks.ts +2 -1
- package/dist/resources/extensions/gsd/preferences.ts +73 -0
- package/dist/resources/extensions/gsd/prompt-loader.ts +45 -9
- package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
- package/dist/resources/extensions/gsd/prompts/replan-slice.md +8 -0
- package/dist/resources/extensions/gsd/prompts/triage-captures.md +62 -0
- package/dist/resources/extensions/gsd/tests/captures.test.ts +438 -0
- package/dist/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
- package/dist/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
- package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
- package/dist/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
- package/dist/resources/extensions/gsd/tests/model-router.test.ts +167 -0
- package/dist/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
- package/dist/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
- package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
- package/dist/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
- package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
- package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
- package/dist/resources/extensions/gsd/triage-resolution.ts +200 -0
- package/dist/resources/extensions/gsd/triage-ui.ts +175 -0
- package/dist/resources/extensions/gsd/visualizer-data.ts +154 -0
- package/dist/resources/extensions/gsd/visualizer-overlay.ts +193 -0
- package/dist/resources/extensions/gsd/visualizer-views.ts +293 -0
- package/dist/resources/extensions/remote-questions/discord-adapter.ts +33 -0
- package/dist/resources/extensions/remote-questions/format.ts +12 -6
- package/dist/resources/extensions/remote-questions/manager.ts +8 -0
- package/package.json +1 -1
- package/src/resources/extensions/gsd/auto-dashboard.ts +14 -2
- package/src/resources/extensions/gsd/auto-prompts.ts +45 -15
- package/src/resources/extensions/gsd/auto.ts +276 -19
- package/src/resources/extensions/gsd/captures.ts +384 -0
- package/src/resources/extensions/gsd/commands.ts +139 -3
- package/src/resources/extensions/gsd/complexity-classifier.ts +322 -0
- package/src/resources/extensions/gsd/dashboard-overlay.ts +10 -0
- package/src/resources/extensions/gsd/metrics.ts +48 -0
- package/src/resources/extensions/gsd/model-cost-table.ts +65 -0
- package/src/resources/extensions/gsd/model-router.ts +256 -0
- package/src/resources/extensions/gsd/post-unit-hooks.ts +2 -1
- package/src/resources/extensions/gsd/preferences.ts +73 -0
- package/src/resources/extensions/gsd/prompt-loader.ts +45 -9
- package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
- package/src/resources/extensions/gsd/prompts/replan-slice.md +8 -0
- package/src/resources/extensions/gsd/prompts/triage-captures.md +62 -0
- package/src/resources/extensions/gsd/tests/captures.test.ts +438 -0
- package/src/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
- package/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
- package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
- package/src/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
- package/src/resources/extensions/gsd/tests/model-router.test.ts +167 -0
- package/src/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
- package/src/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
- package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
- package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
- package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
- package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
- package/src/resources/extensions/gsd/triage-resolution.ts +200 -0
- package/src/resources/extensions/gsd/triage-ui.ts +175 -0
- package/src/resources/extensions/gsd/visualizer-data.ts +154 -0
- package/src/resources/extensions/gsd/visualizer-overlay.ts +193 -0
- package/src/resources/extensions/gsd/visualizer-views.ts +293 -0
- package/src/resources/extensions/remote-questions/discord-adapter.ts +33 -0
- package/src/resources/extensions/remote-questions/format.ts +12 -6
- package/src/resources/extensions/remote-questions/manager.ts +8 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// GSD Extension — Complexity Classifier
|
|
2
|
+
// Classifies unit complexity for dynamic model routing.
|
|
3
|
+
// Pure heuristics + adaptive learning — no LLM calls. Sub-millisecond classification.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { gsdRoot } from "./paths.js";
|
|
8
|
+
import { getAdaptiveTierAdjustment } from "./routing-history.js";
|
|
9
|
+
|
|
10
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export type ComplexityTier = "light" | "standard" | "heavy";
|
|
13
|
+
|
|
14
|
+
export interface ClassificationResult {
|
|
15
|
+
tier: ComplexityTier;
|
|
16
|
+
reason: string;
|
|
17
|
+
downgraded: boolean; // true if budget pressure lowered the tier
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TaskMetadata {
|
|
21
|
+
fileCount?: number;
|
|
22
|
+
dependencyCount?: number;
|
|
23
|
+
isNewFile?: boolean;
|
|
24
|
+
tags?: string[];
|
|
25
|
+
estimatedLines?: number;
|
|
26
|
+
codeBlockCount?: number; // number of fenced code blocks in plan
|
|
27
|
+
complexityKeywords?: string[]; // detected complexity signals
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Unit Type → Default Tier Mapping ────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const UNIT_TYPE_TIERS: Record<string, ComplexityTier> = {
|
|
33
|
+
// Tier 1 — Light: structured summaries, completion, UAT
|
|
34
|
+
"complete-slice": "light",
|
|
35
|
+
"run-uat": "light",
|
|
36
|
+
|
|
37
|
+
// Tier 2 — Standard: research, routine planning
|
|
38
|
+
"research-milestone": "standard",
|
|
39
|
+
"research-slice": "standard",
|
|
40
|
+
"plan-milestone": "standard",
|
|
41
|
+
"plan-slice": "standard",
|
|
42
|
+
|
|
43
|
+
// Tier 3 — Heavy: execution, replanning (requires deep reasoning)
|
|
44
|
+
"execute-task": "standard", // default standard, upgraded by metadata
|
|
45
|
+
"replan-slice": "heavy",
|
|
46
|
+
"reassess-roadmap": "heavy",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Classify unit complexity to determine which model tier to use.
|
|
53
|
+
*
|
|
54
|
+
* @param unitType The type of unit being dispatched
|
|
55
|
+
* @param unitId The unit ID (e.g. "M001/S01/T01")
|
|
56
|
+
* @param basePath Project base path (for reading task plans)
|
|
57
|
+
* @param budgetPct Current budget usage as fraction (0.0-1.0+), or undefined if no budget
|
|
58
|
+
* @param metadata Optional pre-parsed task metadata
|
|
59
|
+
*/
|
|
60
|
+
export function classifyUnitComplexity(
|
|
61
|
+
unitType: string,
|
|
62
|
+
unitId: string,
|
|
63
|
+
basePath: string,
|
|
64
|
+
budgetPct?: number,
|
|
65
|
+
metadata?: TaskMetadata,
|
|
66
|
+
): ClassificationResult {
|
|
67
|
+
// Hook units default to light
|
|
68
|
+
if (unitType.startsWith("hook/")) {
|
|
69
|
+
const result: ClassificationResult = { tier: "light", reason: "hook unit", downgraded: false };
|
|
70
|
+
return applyBudgetPressure(result, budgetPct);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Start with the default tier for this unit type
|
|
74
|
+
let tier = UNIT_TYPE_TIERS[unitType] ?? "standard";
|
|
75
|
+
let reason = `unit type: ${unitType}`;
|
|
76
|
+
|
|
77
|
+
// For execute-task, analyze task metadata for complexity signals
|
|
78
|
+
if (unitType === "execute-task") {
|
|
79
|
+
const taskAnalysis = analyzeTaskComplexity(unitId, basePath, metadata);
|
|
80
|
+
tier = taskAnalysis.tier;
|
|
81
|
+
reason = taskAnalysis.reason;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// For plan-slice, check if the slice has many tasks (complex planning)
|
|
85
|
+
if (unitType === "plan-slice" || unitType === "plan-milestone") {
|
|
86
|
+
const planAnalysis = analyzePlanComplexity(unitId, basePath);
|
|
87
|
+
if (planAnalysis) {
|
|
88
|
+
tier = planAnalysis.tier;
|
|
89
|
+
reason = planAnalysis.reason;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Adaptive learning: check if history suggests bumping the tier
|
|
94
|
+
const tags = metadata?.tags ?? extractTaskMetadata(unitId, basePath).tags;
|
|
95
|
+
const adaptiveAdjustment = getAdaptiveTierAdjustment(unitType, tier, tags);
|
|
96
|
+
if (adaptiveAdjustment && tierOrdinal(adaptiveAdjustment) > tierOrdinal(tier)) {
|
|
97
|
+
reason = `${reason} (adaptive: high failure rate at ${tier})`;
|
|
98
|
+
tier = adaptiveAdjustment;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const result: ClassificationResult = { tier, reason, downgraded: false };
|
|
102
|
+
return applyBudgetPressure(result, budgetPct);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get a short label for the tier (for dashboard display).
|
|
107
|
+
*/
|
|
108
|
+
export function tierLabel(tier: ComplexityTier): string {
|
|
109
|
+
switch (tier) {
|
|
110
|
+
case "light": return "L";
|
|
111
|
+
case "standard": return "S";
|
|
112
|
+
case "heavy": return "H";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get the tier ordering value (for comparison).
|
|
118
|
+
*/
|
|
119
|
+
export function tierOrdinal(tier: ComplexityTier): number {
|
|
120
|
+
switch (tier) {
|
|
121
|
+
case "light": return 0;
|
|
122
|
+
case "standard": return 1;
|
|
123
|
+
case "heavy": return 2;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Task Complexity Analysis ────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
interface TaskAnalysis {
|
|
130
|
+
tier: ComplexityTier;
|
|
131
|
+
reason: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function analyzeTaskComplexity(
|
|
135
|
+
unitId: string,
|
|
136
|
+
basePath: string,
|
|
137
|
+
metadata?: TaskMetadata,
|
|
138
|
+
): TaskAnalysis {
|
|
139
|
+
// Try to read task plan for complexity signals
|
|
140
|
+
const meta = metadata ?? extractTaskMetadata(unitId, basePath);
|
|
141
|
+
|
|
142
|
+
// Heavy signals
|
|
143
|
+
if (meta.dependencyCount && meta.dependencyCount >= 3) {
|
|
144
|
+
return { tier: "heavy", reason: `${meta.dependencyCount} dependencies` };
|
|
145
|
+
}
|
|
146
|
+
if (meta.fileCount && meta.fileCount >= 6) {
|
|
147
|
+
return { tier: "heavy", reason: `${meta.fileCount} files to modify` };
|
|
148
|
+
}
|
|
149
|
+
if (meta.estimatedLines && meta.estimatedLines >= 500) {
|
|
150
|
+
return { tier: "heavy", reason: `~${meta.estimatedLines} lines estimated` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Heavy signals from complexity keywords (Phase 4)
|
|
154
|
+
if (meta.complexityKeywords && meta.complexityKeywords.length >= 2) {
|
|
155
|
+
return { tier: "heavy", reason: `complex: ${meta.complexityKeywords.join(", ")}` };
|
|
156
|
+
}
|
|
157
|
+
if (meta.codeBlockCount && meta.codeBlockCount >= 5) {
|
|
158
|
+
return { tier: "heavy", reason: `${meta.codeBlockCount} code blocks in plan` };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Standard signals from single complexity keyword
|
|
162
|
+
if (meta.complexityKeywords && meta.complexityKeywords.length === 1) {
|
|
163
|
+
return { tier: "standard", reason: `${meta.complexityKeywords[0]} task` };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Light signals (simple tasks)
|
|
167
|
+
if (meta.tags?.some(t => /^(docs?|readme|comment|config|typo|rename)$/i.test(t))) {
|
|
168
|
+
return { tier: "light", reason: `simple task: ${meta.tags.join(", ")}` };
|
|
169
|
+
}
|
|
170
|
+
if (meta.fileCount !== undefined && meta.fileCount <= 1 && !meta.isNewFile) {
|
|
171
|
+
return { tier: "light", reason: "single file modification" };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Standard by default
|
|
175
|
+
return { tier: "standard", reason: "standard execution task" };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function analyzePlanComplexity(
|
|
179
|
+
unitId: string,
|
|
180
|
+
basePath: string,
|
|
181
|
+
): TaskAnalysis | null {
|
|
182
|
+
// Check if this is a milestone-level plan (more complex) vs single slice
|
|
183
|
+
const parts = unitId.split("/");
|
|
184
|
+
if (parts.length === 1) {
|
|
185
|
+
// Milestone-level planning is always at least standard
|
|
186
|
+
return { tier: "standard", reason: "milestone-level planning" };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// For slice planning, try to read the context/research to gauge complexity
|
|
190
|
+
// If research exists and is large, bump to heavy
|
|
191
|
+
const [mid, sid] = parts;
|
|
192
|
+
const researchPath = join(gsdRoot(basePath), mid, "slices", sid, "RESEARCH.md");
|
|
193
|
+
try {
|
|
194
|
+
if (existsSync(researchPath)) {
|
|
195
|
+
const content = readFileSync(researchPath, "utf-8");
|
|
196
|
+
const lineCount = content.split("\n").length;
|
|
197
|
+
if (lineCount > 200) {
|
|
198
|
+
return { tier: "heavy", reason: `complex slice: ${lineCount}-line research` };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
// Non-fatal
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return null; // Use default tier
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Extract task metadata from the task plan file on disk.
|
|
210
|
+
*/
|
|
211
|
+
function extractTaskMetadata(unitId: string, basePath: string): TaskMetadata {
|
|
212
|
+
const meta: TaskMetadata = {};
|
|
213
|
+
const parts = unitId.split("/");
|
|
214
|
+
if (parts.length !== 3) return meta;
|
|
215
|
+
|
|
216
|
+
const [mid, sid, tid] = parts;
|
|
217
|
+
const taskPlanPath = join(gsdRoot(basePath), mid, "slices", sid, "tasks", `${tid}-PLAN.md`);
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
if (!existsSync(taskPlanPath)) return meta;
|
|
221
|
+
const content = readFileSync(taskPlanPath, "utf-8");
|
|
222
|
+
const lines = content.split("\n");
|
|
223
|
+
|
|
224
|
+
// Count files mentioned in "Files:" or "- Files:" lines
|
|
225
|
+
const fileLines = lines.filter(l => /^\s*-?\s*files?\s*:/i.test(l));
|
|
226
|
+
if (fileLines.length > 0) {
|
|
227
|
+
// Count comma-separated or bullet-pointed files
|
|
228
|
+
const allFiles = new Set<string>();
|
|
229
|
+
for (const line of fileLines) {
|
|
230
|
+
const filesStr = line.replace(/^\s*-?\s*files?\s*:\s*/i, "");
|
|
231
|
+
const files = filesStr.split(/[,;]/).map(f => f.trim()).filter(Boolean);
|
|
232
|
+
files.forEach(f => allFiles.add(f));
|
|
233
|
+
}
|
|
234
|
+
meta.fileCount = allFiles.size;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Check for "new file" or "create" keywords
|
|
238
|
+
meta.isNewFile = lines.some(l => /\b(create|new file|scaffold|bootstrap)\b/i.test(l));
|
|
239
|
+
|
|
240
|
+
// Look for tags/labels in frontmatter or content
|
|
241
|
+
const tags: string[] = [];
|
|
242
|
+
if (content.match(/\b(refactor|migration|architect)/i)) tags.push("refactor");
|
|
243
|
+
if (content.match(/\b(test|spec|coverage)\b/i)) tags.push("test");
|
|
244
|
+
if (content.match(/\b(doc|readme|comment|jsdoc)\b/i)) tags.push("docs");
|
|
245
|
+
if (content.match(/\b(config|env|setting)\b/i)) tags.push("config");
|
|
246
|
+
if (content.match(/\b(rename|typo|spelling)\b/i)) tags.push("rename");
|
|
247
|
+
meta.tags = tags;
|
|
248
|
+
|
|
249
|
+
// Try to extract estimated lines from content
|
|
250
|
+
const estimateMatch = content.match(/~?\s*(\d+)\s*lines?\b/i);
|
|
251
|
+
if (estimateMatch) {
|
|
252
|
+
meta.estimatedLines = parseInt(estimateMatch[1], 10);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Phase 4: Deeper introspection signals
|
|
256
|
+
|
|
257
|
+
// Count fenced code blocks (```) — more code blocks = more complex implementation
|
|
258
|
+
const codeBlockMatches = content.match(/^```/gm);
|
|
259
|
+
meta.codeBlockCount = codeBlockMatches ? Math.floor(codeBlockMatches.length / 2) : 0;
|
|
260
|
+
|
|
261
|
+
// Detect complexity keywords that suggest harder tasks
|
|
262
|
+
const complexityKeywords: string[] = [];
|
|
263
|
+
if (content.match(/\b(migration|migrate|schema change)\b/i)) complexityKeywords.push("migration");
|
|
264
|
+
if (content.match(/\b(architect|design pattern|system design)\b/i)) complexityKeywords.push("architecture");
|
|
265
|
+
if (content.match(/\b(security|auth|encrypt|credential|vulnerability)\b/i)) complexityKeywords.push("security");
|
|
266
|
+
if (content.match(/\b(performance|optimize|cache|index)\b/i)) complexityKeywords.push("performance");
|
|
267
|
+
if (content.match(/\b(concurrent|parallel|race condition|mutex|lock)\b/i)) complexityKeywords.push("concurrency");
|
|
268
|
+
if (content.match(/\b(backward.?compat|breaking change|deprecat)\b/i)) complexityKeywords.push("compatibility");
|
|
269
|
+
meta.complexityKeywords = complexityKeywords;
|
|
270
|
+
} catch {
|
|
271
|
+
// Non-fatal — metadata extraction is best-effort
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return meta;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ─── Budget Pressure ─────────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Apply budget pressure to a classification result.
|
|
281
|
+
* As budget usage increases, more aggressively downgrade tiers.
|
|
282
|
+
*
|
|
283
|
+
* - <50%: Normal classification (no change)
|
|
284
|
+
* - 50-75%: Tier 2 → Tier 1 where possible
|
|
285
|
+
* - 75-90%: Only heavy tasks keep configured model
|
|
286
|
+
* - >90%: Everything except replan-slice gets cheapest model
|
|
287
|
+
*/
|
|
288
|
+
function applyBudgetPressure(
|
|
289
|
+
result: ClassificationResult,
|
|
290
|
+
budgetPct?: number,
|
|
291
|
+
): ClassificationResult {
|
|
292
|
+
if (budgetPct === undefined || budgetPct < 0.5) return result;
|
|
293
|
+
|
|
294
|
+
const original = result.tier;
|
|
295
|
+
|
|
296
|
+
if (budgetPct >= 0.9) {
|
|
297
|
+
// >90%: almost everything goes to light
|
|
298
|
+
if (result.tier !== "heavy") {
|
|
299
|
+
result.tier = "light";
|
|
300
|
+
} else {
|
|
301
|
+
// Even heavy gets downgraded to standard
|
|
302
|
+
result.tier = "standard";
|
|
303
|
+
}
|
|
304
|
+
} else if (budgetPct >= 0.75) {
|
|
305
|
+
// 75-90%: only heavy stays, everything else goes to light
|
|
306
|
+
if (result.tier === "standard") {
|
|
307
|
+
result.tier = "light";
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
// 50-75%: standard → light
|
|
311
|
+
if (result.tier === "standard") {
|
|
312
|
+
result.tier = "light";
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (result.tier !== original) {
|
|
317
|
+
result.downgraded = true;
|
|
318
|
+
result.reason = `${result.reason} (budget pressure: ${Math.round(budgetPct * 100)}%)`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
@@ -39,6 +39,9 @@ function unitLabel(type: string): string {
|
|
|
39
39
|
case "execute-task": return "Execute";
|
|
40
40
|
case "complete-slice": return "Complete";
|
|
41
41
|
case "reassess-roadmap": return "Reassess";
|
|
42
|
+
case "triage-captures": return "Triage";
|
|
43
|
+
case "quick-task": return "Quick Task";
|
|
44
|
+
case "replan-slice": return "Replan";
|
|
42
45
|
default: return type;
|
|
43
46
|
}
|
|
44
47
|
}
|
|
@@ -345,6 +348,13 @@ export class GSDDashboardOverlay {
|
|
|
345
348
|
lines.push(blank());
|
|
346
349
|
}
|
|
347
350
|
|
|
351
|
+
// Pending captures badge — only shown when captures are waiting for triage
|
|
352
|
+
if (this.dashData.pendingCaptureCount > 0) {
|
|
353
|
+
const count = this.dashData.pendingCaptureCount;
|
|
354
|
+
lines.push(row(th.fg("warning", `📌 ${count} pending capture${count === 1 ? "" : "s"} awaiting triage`)));
|
|
355
|
+
lines.push(blank());
|
|
356
|
+
}
|
|
357
|
+
|
|
348
358
|
if (this.loading) {
|
|
349
359
|
lines.push(centered(th.fg("dim", "Loading dashboard…")));
|
|
350
360
|
return lines;
|
|
@@ -39,6 +39,8 @@ export interface UnitMetrics {
|
|
|
39
39
|
toolCalls: number;
|
|
40
40
|
assistantMessages: number;
|
|
41
41
|
userMessages: number;
|
|
42
|
+
tier?: string; // complexity tier (light/standard/heavy) if dynamic routing active
|
|
43
|
+
modelDowngraded?: boolean; // true if dynamic routing used a cheaper model
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export interface MetricsLedger {
|
|
@@ -104,6 +106,7 @@ export function snapshotUnitMetrics(
|
|
|
104
106
|
unitId: string,
|
|
105
107
|
startedAt: number,
|
|
106
108
|
model: string,
|
|
109
|
+
extras?: { tier?: string; modelDowngraded?: boolean },
|
|
107
110
|
): UnitMetrics | null {
|
|
108
111
|
if (!ledger) return null;
|
|
109
112
|
|
|
@@ -156,6 +159,8 @@ export function snapshotUnitMetrics(
|
|
|
156
159
|
toolCalls,
|
|
157
160
|
assistantMessages,
|
|
158
161
|
userMessages,
|
|
162
|
+
...(extras?.tier ? { tier: extras.tier } : {}),
|
|
163
|
+
...(extras?.modelDowngraded !== undefined ? { modelDowngraded: extras.modelDowngraded } : {}),
|
|
159
164
|
};
|
|
160
165
|
|
|
161
166
|
ledger.units.push(unit);
|
|
@@ -294,6 +299,49 @@ export function getProjectTotals(units: UnitMetrics[]): ProjectTotals {
|
|
|
294
299
|
return totals;
|
|
295
300
|
}
|
|
296
301
|
|
|
302
|
+
// ─── Tier Aggregation ────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
export interface TierAggregate {
|
|
305
|
+
tier: string;
|
|
306
|
+
units: number;
|
|
307
|
+
tokens: TokenCounts;
|
|
308
|
+
cost: number;
|
|
309
|
+
downgraded: number; // units that were downgraded by dynamic routing
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function aggregateByTier(units: UnitMetrics[]): TierAggregate[] {
|
|
313
|
+
const map = new Map<string, TierAggregate>();
|
|
314
|
+
for (const u of units) {
|
|
315
|
+
const tier = u.tier ?? "unknown";
|
|
316
|
+
let agg = map.get(tier);
|
|
317
|
+
if (!agg) {
|
|
318
|
+
agg = { tier, units: 0, tokens: emptyTokens(), cost: 0, downgraded: 0 };
|
|
319
|
+
map.set(tier, agg);
|
|
320
|
+
}
|
|
321
|
+
agg.units++;
|
|
322
|
+
agg.tokens = addTokens(agg.tokens, u.tokens);
|
|
323
|
+
agg.cost += u.cost;
|
|
324
|
+
if (u.modelDowngraded) agg.downgraded++;
|
|
325
|
+
}
|
|
326
|
+
const order = ["light", "standard", "heavy", "unknown"];
|
|
327
|
+
return order.map(t => map.get(t)).filter((a): a is TierAggregate => !!a);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Format a summary of savings from dynamic routing.
|
|
332
|
+
* Returns empty string if no units were downgraded.
|
|
333
|
+
*/
|
|
334
|
+
export function formatTierSavings(units: UnitMetrics[]): string {
|
|
335
|
+
const downgraded = units.filter(u => u.modelDowngraded);
|
|
336
|
+
if (downgraded.length === 0) return "";
|
|
337
|
+
|
|
338
|
+
const downgradedCost = downgraded.reduce((sum, u) => sum + u.cost, 0);
|
|
339
|
+
const totalUnits = units.filter(u => u.tier).length;
|
|
340
|
+
const pct = totalUnits > 0 ? Math.round((downgraded.length / totalUnits) * 100) : 0;
|
|
341
|
+
|
|
342
|
+
return `Dynamic routing: ${downgraded.length}/${totalUnits} units downgraded (${pct}%), cost: ${formatCost(downgradedCost)}`;
|
|
343
|
+
}
|
|
344
|
+
|
|
297
345
|
// ─── Formatting helpers ───────────────────────────────────────────────────────
|
|
298
346
|
|
|
299
347
|
export function formatCost(cost: number): string {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// GSD Extension — Model Cost Table
|
|
2
|
+
// Static cost reference for known models, used by the dynamic router
|
|
3
|
+
// for cross-provider cost comparison.
|
|
4
|
+
//
|
|
5
|
+
// Costs are approximate per-1K-token rates in USD (input tokens).
|
|
6
|
+
// Updated with GSD releases. Users can override via preferences.
|
|
7
|
+
|
|
8
|
+
export interface ModelCostEntry {
|
|
9
|
+
/** Model ID (bare, without provider prefix) */
|
|
10
|
+
id: string;
|
|
11
|
+
/** Approximate cost per 1K input tokens in USD */
|
|
12
|
+
inputPer1k: number;
|
|
13
|
+
/** Approximate cost per 1K output tokens in USD */
|
|
14
|
+
outputPer1k: number;
|
|
15
|
+
/** Last updated date */
|
|
16
|
+
updatedAt: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Bundled cost table for known models.
|
|
21
|
+
* Updated periodically with GSD releases.
|
|
22
|
+
*/
|
|
23
|
+
export const BUNDLED_COST_TABLE: ModelCostEntry[] = [
|
|
24
|
+
// Anthropic
|
|
25
|
+
{ id: "claude-opus-4-6", inputPer1k: 0.015, outputPer1k: 0.075, updatedAt: "2025-03-15" },
|
|
26
|
+
{ id: "claude-sonnet-4-6", inputPer1k: 0.003, outputPer1k: 0.015, updatedAt: "2025-03-15" },
|
|
27
|
+
{ id: "claude-haiku-4-5", inputPer1k: 0.0008, outputPer1k: 0.004, updatedAt: "2025-03-15" },
|
|
28
|
+
{ id: "claude-sonnet-4-5-20250514", inputPer1k: 0.003, outputPer1k: 0.015, updatedAt: "2025-03-15" },
|
|
29
|
+
{ id: "claude-3-5-sonnet-latest", inputPer1k: 0.003, outputPer1k: 0.015, updatedAt: "2025-03-15" },
|
|
30
|
+
{ id: "claude-3-5-haiku-latest", inputPer1k: 0.0008, outputPer1k: 0.004, updatedAt: "2025-03-15" },
|
|
31
|
+
{ id: "claude-3-opus-latest", inputPer1k: 0.015, outputPer1k: 0.075, updatedAt: "2025-03-15" },
|
|
32
|
+
|
|
33
|
+
// OpenAI
|
|
34
|
+
{ id: "gpt-4o", inputPer1k: 0.0025, outputPer1k: 0.01, updatedAt: "2025-03-15" },
|
|
35
|
+
{ id: "gpt-4o-mini", inputPer1k: 0.00015, outputPer1k: 0.0006, updatedAt: "2025-03-15" },
|
|
36
|
+
{ id: "o1", inputPer1k: 0.015, outputPer1k: 0.06, updatedAt: "2025-03-15" },
|
|
37
|
+
{ id: "o3", inputPer1k: 0.015, outputPer1k: 0.06, updatedAt: "2025-03-15" },
|
|
38
|
+
{ id: "gpt-4-turbo", inputPer1k: 0.01, outputPer1k: 0.03, updatedAt: "2025-03-15" },
|
|
39
|
+
|
|
40
|
+
// Google
|
|
41
|
+
{ id: "gemini-2.0-flash", inputPer1k: 0.0001, outputPer1k: 0.0004, updatedAt: "2025-03-15" },
|
|
42
|
+
{ id: "gemini-flash-2.0", inputPer1k: 0.0001, outputPer1k: 0.0004, updatedAt: "2025-03-15" },
|
|
43
|
+
{ id: "gemini-2.5-pro", inputPer1k: 0.00125, outputPer1k: 0.005, updatedAt: "2025-03-15" },
|
|
44
|
+
|
|
45
|
+
// DeepSeek
|
|
46
|
+
{ id: "deepseek-chat", inputPer1k: 0.00014, outputPer1k: 0.00028, updatedAt: "2025-03-15" },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Lookup cost for a model ID. Returns undefined if not found.
|
|
51
|
+
*/
|
|
52
|
+
export function lookupModelCost(modelId: string): ModelCostEntry | undefined {
|
|
53
|
+
const bareId = modelId.includes("/") ? modelId.split("/").pop()! : modelId;
|
|
54
|
+
return BUNDLED_COST_TABLE.find(e => e.id === bareId)
|
|
55
|
+
?? BUNDLED_COST_TABLE.find(e => bareId.includes(e.id) || e.id.includes(bareId));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Compare two models by input cost. Returns negative if a is cheaper.
|
|
60
|
+
*/
|
|
61
|
+
export function compareModelCost(modelIdA: string, modelIdB: string): number {
|
|
62
|
+
const costA = lookupModelCost(modelIdA)?.inputPer1k ?? 999;
|
|
63
|
+
const costB = lookupModelCost(modelIdB)?.inputPer1k ?? 999;
|
|
64
|
+
return costA - costB;
|
|
65
|
+
}
|