@tekyzinc/gsd-t 2.74.12 → 2.76.10
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/CHANGELOG.md +130 -0
- package/README.md +71 -1
- package/bin/advisor-integration.js +93 -0
- package/bin/check-headless-sessions.js +140 -0
- package/bin/context-meter-config.cjs +101 -0
- package/bin/context-meter-config.test.cjs +101 -0
- package/bin/gsd-t.js +710 -16
- package/bin/headless-auto-spawn.js +290 -0
- package/bin/model-selector.js +224 -0
- package/bin/runway-estimator.js +242 -0
- package/bin/token-budget.js +96 -89
- package/bin/token-optimizer.js +471 -0
- package/bin/token-telemetry.js +246 -0
- package/commands/gsd-t-audit.md +3 -3
- package/commands/gsd-t-backlog-list.md +38 -0
- package/commands/gsd-t-brainstorm.md +3 -3
- package/commands/gsd-t-complete-milestone.md +24 -0
- package/commands/gsd-t-debug.md +124 -7
- package/commands/gsd-t-discuss.md +10 -3
- package/commands/gsd-t-doc-ripple.md +32 -4
- package/commands/gsd-t-execute.md +107 -52
- package/commands/gsd-t-help.md +19 -0
- package/commands/gsd-t-integrate.md +67 -4
- package/commands/gsd-t-optimization-apply.md +91 -0
- package/commands/gsd-t-optimization-reject.md +94 -0
- package/commands/gsd-t-partition.md +7 -0
- package/commands/gsd-t-pause.md +3 -0
- package/commands/gsd-t-plan.md +10 -3
- package/commands/gsd-t-prd.md +3 -3
- package/commands/gsd-t-quick.md +71 -9
- package/commands/gsd-t-reflect.md +3 -7
- package/commands/gsd-t-resume.md +36 -0
- package/commands/gsd-t-status.md +31 -0
- package/commands/gsd-t-test-sync.md +7 -0
- package/commands/gsd-t-verify.md +12 -5
- package/commands/gsd-t-visualize.md +3 -7
- package/commands/gsd-t-wave.md +82 -18
- package/docs/GSD-T-README.md +52 -0
- package/docs/architecture.md +95 -0
- package/docs/infrastructure.md +117 -0
- package/docs/methodology.md +36 -0
- package/docs/prd-harness-evolution.md +51 -37
- package/docs/requirements.md +66 -0
- package/package.json +1 -1
- package/scripts/context-meter/count-tokens-client.js +221 -0
- package/scripts/context-meter/count-tokens-client.test.js +308 -0
- package/scripts/context-meter/test-injector.js +55 -0
- package/scripts/context-meter/threshold.js +88 -0
- package/scripts/context-meter/threshold.test.js +255 -0
- package/scripts/context-meter/transcript-parser.js +252 -0
- package/scripts/context-meter/transcript-parser.test.js +320 -0
- package/scripts/gsd-t-context-meter.e2e.test.js +415 -0
- package/scripts/gsd-t-context-meter.js +350 -0
- package/scripts/gsd-t-context-meter.test.js +417 -0
- package/scripts/gsd-t-heartbeat.js +2 -2
- package/scripts/gsd-t-statusline.js +23 -8
- package/templates/CLAUDE-global.md +5 -1
- package/templates/CLAUDE-project.md +26 -6
- package/templates/context-meter-config.json +10 -0
- package/templates/prompts/README.md +1 -1
- package/bin/task-counter.cjs +0 -161
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GSD-T Headless Auto-Spawn — Detached headless continuation
|
|
5
|
+
*
|
|
6
|
+
* When the runway estimator refuses a run (projected context ≥ stop band),
|
|
7
|
+
* the caller invokes `autoSpawnHeadless()` to hand off to a detached child
|
|
8
|
+
* process running `gsd-t headless {command} --log`. The interactive session
|
|
9
|
+
* never blocks on the child (`child.unref()`), so the user retains their
|
|
10
|
+
* terminal and can work on unrelated tasks. On child completion, a macOS
|
|
11
|
+
* notification fires (T2). The interactive session surfaces the result via
|
|
12
|
+
* a read-back banner on the next `gsd-t-resume` or `gsd-t-status` call (T4).
|
|
13
|
+
*
|
|
14
|
+
* Zero external dependencies (Node.js built-ins only).
|
|
15
|
+
*
|
|
16
|
+
* Contract: .gsd-t/contracts/headless-auto-spawn-contract.md v1.0.0
|
|
17
|
+
* Consumers: commands/gsd-t-execute|wave|integrate|quick|debug.md (via runway
|
|
18
|
+
* estimator handoff), bin/runway-estimator.js (conceptual target).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require("fs");
|
|
22
|
+
const path = require("path");
|
|
23
|
+
const { spawn } = require("child_process");
|
|
24
|
+
|
|
25
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const SESSIONS_DIR_REL = path.join(".gsd-t", "headless-sessions");
|
|
28
|
+
const LOG_DIR_REL = ".gsd-t"; // headless-{id}.log lives directly in .gsd-t
|
|
29
|
+
|
|
30
|
+
// ── Exports ──────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
module.exports = {
|
|
33
|
+
autoSpawnHeadless,
|
|
34
|
+
makeSessionId,
|
|
35
|
+
writeSessionFile,
|
|
36
|
+
writeContinueHereFile,
|
|
37
|
+
markSessionCompleted,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ── autoSpawnHeadless ────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {{
|
|
44
|
+
* command: string,
|
|
45
|
+
* args?: string[],
|
|
46
|
+
* continue_from?: string,
|
|
47
|
+
* projectDir?: string,
|
|
48
|
+
* context?: object
|
|
49
|
+
* }} opts
|
|
50
|
+
* @returns {{ id: string, pid: number, logPath: string, timestamp: string }}
|
|
51
|
+
*/
|
|
52
|
+
function autoSpawnHeadless(opts) {
|
|
53
|
+
const command = opts.command;
|
|
54
|
+
const args = opts.args || [];
|
|
55
|
+
const continue_from = opts.continue_from || ".";
|
|
56
|
+
const projectDir = opts.projectDir || process.cwd();
|
|
57
|
+
const context = opts.context || null;
|
|
58
|
+
|
|
59
|
+
if (!command || typeof command !== "string") {
|
|
60
|
+
throw new Error("autoSpawnHeadless: `command` is required");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const timestamp = new Date().toISOString();
|
|
64
|
+
const id = makeSessionId(command, new Date());
|
|
65
|
+
const logPath = path.join(projectDir, LOG_DIR_REL, `headless-${id}.log`);
|
|
66
|
+
|
|
67
|
+
ensureDir(path.join(projectDir, LOG_DIR_REL));
|
|
68
|
+
ensureDir(path.join(projectDir, SESSIONS_DIR_REL));
|
|
69
|
+
|
|
70
|
+
// Open log file descriptor before spawning — child writes directly.
|
|
71
|
+
const logFd = fs.openSync(logPath, "a");
|
|
72
|
+
|
|
73
|
+
// Headless invocation: `node bin/gsd-t.js headless <command> [args] --log`
|
|
74
|
+
// The `gsd-t` CLI entry point is bin/gsd-t.js relative to projectDir.
|
|
75
|
+
const gsdtCli = path.join(projectDir, "bin", "gsd-t.js");
|
|
76
|
+
const childArgs = [gsdtCli, "headless", stripGsdtPrefix(command), ...args, "--log"];
|
|
77
|
+
|
|
78
|
+
const child = spawn("node", childArgs, {
|
|
79
|
+
cwd: projectDir,
|
|
80
|
+
detached: true,
|
|
81
|
+
stdio: ["ignore", logFd, logFd],
|
|
82
|
+
env: process.env,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
child.unref();
|
|
86
|
+
fs.closeSync(logFd);
|
|
87
|
+
|
|
88
|
+
const pid = child.pid || 0;
|
|
89
|
+
|
|
90
|
+
writeSessionFile(projectDir, {
|
|
91
|
+
id,
|
|
92
|
+
pid,
|
|
93
|
+
logPath: path.relative(projectDir, logPath),
|
|
94
|
+
startTimestamp: timestamp,
|
|
95
|
+
command,
|
|
96
|
+
args,
|
|
97
|
+
status: "running",
|
|
98
|
+
continueFromPath: continue_from,
|
|
99
|
+
surfaced: false,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
writeContinueHereFile(projectDir, id, context);
|
|
103
|
+
|
|
104
|
+
// T2 — install completion watcher. Non-blocking (setImmediate) so the
|
|
105
|
+
// caller's return is not delayed. The watcher uses `child.on('exit')` on
|
|
106
|
+
// a separately-spawned bridge process; here we defer to fs.watchFile for
|
|
107
|
+
// a detached approach that survives even after the parent's `unref()`.
|
|
108
|
+
installCompletionWatcher({ projectDir, id, logPath, pid, startTimestamp: timestamp });
|
|
109
|
+
|
|
110
|
+
return { id, pid, logPath: path.relative(projectDir, logPath), timestamp };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── makeSessionId ────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {string} command
|
|
117
|
+
* @param {Date} [now]
|
|
118
|
+
* @returns {string} e.g., "gsd-t-execute-2026-04-15-01-23-45"
|
|
119
|
+
*/
|
|
120
|
+
function makeSessionId(command, now) {
|
|
121
|
+
const d = now || new Date();
|
|
122
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
123
|
+
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
124
|
+
const time = `${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
|
|
125
|
+
const base = stripGsdtPrefix(command) || command;
|
|
126
|
+
return `gsd-t-${base}-${date}-${time}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── writeSessionFile ─────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
function writeSessionFile(projectDir, session) {
|
|
132
|
+
const fp = path.join(projectDir, SESSIONS_DIR_REL, `${session.id}.json`);
|
|
133
|
+
ensureDir(path.dirname(fp));
|
|
134
|
+
fs.writeFileSync(fp, JSON.stringify(session, null, 2) + "\n");
|
|
135
|
+
return fp;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── writeContinueHereFile ────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
function writeContinueHereFile(projectDir, id, context) {
|
|
141
|
+
const fp = path.join(projectDir, SESSIONS_DIR_REL, `${id}-context.json`);
|
|
142
|
+
const payload = context || buildContextSnapshot(projectDir);
|
|
143
|
+
fs.writeFileSync(fp, JSON.stringify(payload, null, 2) + "\n");
|
|
144
|
+
return fp;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function buildContextSnapshot(projectDir) {
|
|
148
|
+
// Best-effort snapshot of current GSD-T state at handoff time.
|
|
149
|
+
const snap = {
|
|
150
|
+
capturedAt: new Date().toISOString(),
|
|
151
|
+
progress: null,
|
|
152
|
+
currentDomain: null,
|
|
153
|
+
pendingTasks: [],
|
|
154
|
+
lastDecisionLogEntry: null,
|
|
155
|
+
currentWave: null,
|
|
156
|
+
};
|
|
157
|
+
try {
|
|
158
|
+
const progressFp = path.join(projectDir, ".gsd-t", "progress.md");
|
|
159
|
+
if (fs.existsSync(progressFp)) {
|
|
160
|
+
const raw = fs.readFileSync(progressFp, "utf8");
|
|
161
|
+
snap.progress = firstNLines(raw, 20);
|
|
162
|
+
// Pull the last Decision Log entry (last non-empty bullet line).
|
|
163
|
+
const lines = raw.split("\n");
|
|
164
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
165
|
+
const ln = lines[i].trim();
|
|
166
|
+
if (ln.startsWith("- ")) {
|
|
167
|
+
snap.lastDecisionLogEntry = ln;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} catch (_) {
|
|
173
|
+
/* best-effort */
|
|
174
|
+
}
|
|
175
|
+
return snap;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function firstNLines(s, n) {
|
|
179
|
+
return s.split("\n").slice(0, n).join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── markSessionCompleted ─────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* T2 completion hook — updates the session file in place.
|
|
186
|
+
* @param {string} projectDir
|
|
187
|
+
* @param {string} id
|
|
188
|
+
* @param {{ exitCode: number, endTimestamp?: string }} result
|
|
189
|
+
*/
|
|
190
|
+
function markSessionCompleted(projectDir, id, result) {
|
|
191
|
+
const fp = path.join(projectDir, SESSIONS_DIR_REL, `${id}.json`);
|
|
192
|
+
if (!fs.existsSync(fp)) return;
|
|
193
|
+
try {
|
|
194
|
+
const s = JSON.parse(fs.readFileSync(fp, "utf8"));
|
|
195
|
+
s.status = "completed";
|
|
196
|
+
s.exitCode = result.exitCode;
|
|
197
|
+
s.endTimestamp = result.endTimestamp || new Date().toISOString();
|
|
198
|
+
fs.writeFileSync(fp, JSON.stringify(s, null, 2) + "\n");
|
|
199
|
+
} catch (_) {
|
|
200
|
+
/* ignore */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Completion watcher (T2) — macOS notification on exit ─────────────────────
|
|
205
|
+
|
|
206
|
+
function installCompletionWatcher(opts) {
|
|
207
|
+
const { projectDir, id, pid, startTimestamp } = opts;
|
|
208
|
+
if (!pid || pid <= 0) return;
|
|
209
|
+
|
|
210
|
+
// Poll-based watcher. We can't hold a reference to the child (it's unref'd
|
|
211
|
+
// and detached), so we poll `process.kill(pid, 0)` which throws if the
|
|
212
|
+
// process is gone. This is cheap and survives across detachment.
|
|
213
|
+
const POLL_MS = 2000;
|
|
214
|
+
const MAX_WAIT_MS = 60 * 60 * 1000; // 1 hour safety cap
|
|
215
|
+
const startMs = Date.now();
|
|
216
|
+
|
|
217
|
+
const timer = setInterval(() => {
|
|
218
|
+
let alive = false;
|
|
219
|
+
try {
|
|
220
|
+
process.kill(pid, 0);
|
|
221
|
+
alive = true;
|
|
222
|
+
} catch (_) {
|
|
223
|
+
alive = false;
|
|
224
|
+
}
|
|
225
|
+
if (!alive) {
|
|
226
|
+
clearInterval(timer);
|
|
227
|
+
// Exit code is unknown from a signal-based probe. Best-effort: read
|
|
228
|
+
// the log's last lines to guess, otherwise default to 0.
|
|
229
|
+
const exitCode = guessExitCodeFromLog(projectDir, id);
|
|
230
|
+
markSessionCompleted(projectDir, id, {
|
|
231
|
+
exitCode,
|
|
232
|
+
endTimestamp: new Date().toISOString(),
|
|
233
|
+
});
|
|
234
|
+
fireMacNotification({ id, command: extractCommand(id), startTimestamp });
|
|
235
|
+
} else if (Date.now() - startMs > MAX_WAIT_MS) {
|
|
236
|
+
clearInterval(timer);
|
|
237
|
+
}
|
|
238
|
+
}, POLL_MS);
|
|
239
|
+
|
|
240
|
+
// Let the timer not block the parent's exit.
|
|
241
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function guessExitCodeFromLog(projectDir, id) {
|
|
245
|
+
try {
|
|
246
|
+
const fp = path.join(projectDir, LOG_DIR_REL, `headless-${id}.log`);
|
|
247
|
+
if (!fs.existsSync(fp)) return 0;
|
|
248
|
+
const raw = fs.readFileSync(fp, "utf8");
|
|
249
|
+
if (/exit code[: ]+(\d+)/i.test(raw)) {
|
|
250
|
+
const m = raw.match(/exit code[: ]+(\d+)/i);
|
|
251
|
+
return parseInt(m[1], 10);
|
|
252
|
+
}
|
|
253
|
+
return 0;
|
|
254
|
+
} catch (_) {
|
|
255
|
+
return 0;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function extractCommand(id) {
|
|
260
|
+
// id format: gsd-t-{command}-{date}-{time}
|
|
261
|
+
const m = id.match(/^gsd-t-(.+?)-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$/);
|
|
262
|
+
return m ? m[1] : id;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function fireMacNotification({ id, command }) {
|
|
266
|
+
if (process.platform !== "darwin") return;
|
|
267
|
+
try {
|
|
268
|
+
const { spawn } = require("child_process");
|
|
269
|
+
const msg = `GSD-T headless run complete: ${id}`;
|
|
270
|
+
const script = `display notification "${msg}" with title "GSD-T" subtitle "${command}"`;
|
|
271
|
+
const child = spawn("osascript", ["-e", script], {
|
|
272
|
+
detached: true,
|
|
273
|
+
stdio: "ignore",
|
|
274
|
+
});
|
|
275
|
+
child.unref();
|
|
276
|
+
} catch (_) {
|
|
277
|
+
/* graceful degradation */
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
function ensureDir(d) {
|
|
284
|
+
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function stripGsdtPrefix(command) {
|
|
288
|
+
if (typeof command !== "string") return "";
|
|
289
|
+
return command.replace(/^gsd-t-/, "");
|
|
290
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GSD-T Model Selector — surgical per-phase model tier assignment
|
|
5
|
+
*
|
|
6
|
+
* Replaces the v2.x "silent downgrade under context pressure" behavior with
|
|
7
|
+
* declarative per-phase tier assignments. Callers ask `selectModel({phase, ...})`
|
|
8
|
+
* and get back `{model, reason, escalation_hook}` — the tier decision is
|
|
9
|
+
* deterministic, driven by the rules table below, and does NOT depend on
|
|
10
|
+
* session context percentage.
|
|
11
|
+
*
|
|
12
|
+
* Contract: .gsd-t/contracts/model-selection-contract.md v1.0.0 (M35 T4)
|
|
13
|
+
* Findings: .gsd-t/M35-advisor-findings.md (convention-based /advisor fallback)
|
|
14
|
+
*
|
|
15
|
+
* Zero external dependencies.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ── Tiers ───────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const TIERS = Object.freeze({
|
|
21
|
+
HAIKU: "haiku",
|
|
22
|
+
SONNET: "sonnet",
|
|
23
|
+
OPUS: "opus",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const DEFAULT_TIER = TIERS.SONNET;
|
|
27
|
+
|
|
28
|
+
// ── Escalation hook block (convention-based /advisor fallback) ──────────────
|
|
29
|
+
//
|
|
30
|
+
// Per `.gsd-t/M35-advisor-findings.md`, Claude Code's native /advisor has no
|
|
31
|
+
// programmable API at subagent scope. This block is injected into the subagent
|
|
32
|
+
// prompt at declared escalation points on sonnet-tier phases where the
|
|
33
|
+
// orchestrator has flagged a high-stakes sub-decision.
|
|
34
|
+
//
|
|
35
|
+
// Kept as a constant so all consumers (command files, advisor-integration.js,
|
|
36
|
+
// M35-advisor-findings.md) reference the same canonical text.
|
|
37
|
+
|
|
38
|
+
const ESCALATION_HOOK = [
|
|
39
|
+
"## Escalation Hook — /advisor convention-based fallback",
|
|
40
|
+
"",
|
|
41
|
+
"Before finalizing your answer for this phase, stop and consider:",
|
|
42
|
+
"1. Is this decision high-stakes? (architecture, contract design, security boundary,",
|
|
43
|
+
" data-loss risk, cross-module refactor, adversarial QA verdict)",
|
|
44
|
+
"2. Would a more capable model produce a materially better answer?",
|
|
45
|
+
"3. Are you confident in the assumptions you're making?",
|
|
46
|
+
"",
|
|
47
|
+
"If YES to any of the above, do ONE of the following:",
|
|
48
|
+
"- Escalate internally: spend an extra reasoning pass re-examining the decision",
|
|
49
|
+
" from first principles. Document the re-examination in your output.",
|
|
50
|
+
"- Spawn a nested opus subagent: use the Task tool with",
|
|
51
|
+
" `subagent_type: \"general-purpose\"` and include `model: opus` in the spawn.",
|
|
52
|
+
"",
|
|
53
|
+
"Record in your output whether you escalated: set `ESCALATED_VIA_ADVISOR=true` or",
|
|
54
|
+
"`ESCALATED_VIA_ADVISOR=false` on a line by itself near the end of your report.",
|
|
55
|
+
].join("\n");
|
|
56
|
+
|
|
57
|
+
// ── Declarative phase rules table ───────────────────────────────────────────
|
|
58
|
+
//
|
|
59
|
+
// Each rule maps (phase, task_type) → tier. The first matching rule wins.
|
|
60
|
+
// `task_type` is optional — when absent, the rule matches any task within
|
|
61
|
+
// that phase. Order the rules from most-specific to least-specific.
|
|
62
|
+
//
|
|
63
|
+
// Tier assignments mirror `.gsd-t/M35-definition.md` Part B and the Model
|
|
64
|
+
// Assignments section of the GSD-T global CLAUDE template:
|
|
65
|
+
// - haiku: strictly mechanical — test runners, branch guards, file checks,
|
|
66
|
+
// JSON validation, no judgment
|
|
67
|
+
// - sonnet: routine code work — execute step 2, test-sync, doc-ripple wiring,
|
|
68
|
+
// quick fixes, integration wiring, debug fix-apply
|
|
69
|
+
// - opus: high-stakes reasoning — partition, discuss, Red Team, verify
|
|
70
|
+
// judgment, debug root-cause, contract/architecture design
|
|
71
|
+
|
|
72
|
+
const PHASE_RULES = Object.freeze([
|
|
73
|
+
// Phase: execute
|
|
74
|
+
{ phase: "execute", task_type: "test_runner", model: TIERS.HAIKU, reason: "Mechanical test-suite runner — zero judgment" },
|
|
75
|
+
{ phase: "execute", task_type: "branch_guard", model: TIERS.HAIKU, reason: "Mechanical branch-name check — zero judgment" },
|
|
76
|
+
{ phase: "execute", task_type: "file_check", model: TIERS.HAIKU, reason: "Mechanical file-existence check — zero judgment" },
|
|
77
|
+
{ phase: "execute", task_type: "qa", model: TIERS.SONNET, reason: "QA evaluation needs judgment per M31 tier refinement" },
|
|
78
|
+
{ phase: "execute", task_type: "red_team", model: TIERS.OPUS, reason: "Adversarial QA benefits most from top tier" },
|
|
79
|
+
{ phase: "execute", model: TIERS.SONNET, reason: "Routine task execution — sonnet is the M35 default for routine work", hasEscalation: true },
|
|
80
|
+
|
|
81
|
+
// Phase: wave (the wave orchestrator itself)
|
|
82
|
+
{ phase: "wave", model: TIERS.SONNET, reason: "Wave orchestration dispatches per-phase subagents; the orchestrator itself is routine coordination", hasEscalation: true },
|
|
83
|
+
|
|
84
|
+
// Phase: quick
|
|
85
|
+
{ phase: "quick", task_type: "test_runner", model: TIERS.HAIKU, reason: "Mechanical test-suite runner — zero judgment" },
|
|
86
|
+
{ phase: "quick", model: TIERS.SONNET, reason: "Routine one-off task — sonnet default" },
|
|
87
|
+
|
|
88
|
+
// Phase: integrate
|
|
89
|
+
{ phase: "integrate", task_type: "test_runner", model: TIERS.HAIKU, reason: "Mechanical integration test runner — zero judgment" },
|
|
90
|
+
{ phase: "integrate", model: TIERS.SONNET, reason: "Integration wiring is routine coordination work" },
|
|
91
|
+
|
|
92
|
+
// Phase: debug
|
|
93
|
+
{ phase: "debug", task_type: "fix_apply", model: TIERS.SONNET, reason: "Applying a known fix is routine code work" },
|
|
94
|
+
{ phase: "debug", task_type: "root_cause", model: TIERS.OPUS, reason: "Root-cause analysis is high-stakes reasoning" },
|
|
95
|
+
{ phase: "debug", model: TIERS.OPUS, reason: "Debug default is high-stakes — prefer opus unless the task_type says otherwise" },
|
|
96
|
+
|
|
97
|
+
// Phase: partition — high-stakes architectural decomposition
|
|
98
|
+
{ phase: "partition", model: TIERS.OPUS, reason: "Domain partitioning is architectural reasoning — high stakes" },
|
|
99
|
+
|
|
100
|
+
// Phase: discuss — multi-perspective design exploration
|
|
101
|
+
{ phase: "discuss", model: TIERS.OPUS, reason: "Design exploration benefits from top-tier reasoning" },
|
|
102
|
+
|
|
103
|
+
// Phase: plan — task-list authoring
|
|
104
|
+
{ phase: "plan", model: TIERS.SONNET, reason: "Task decomposition is structured work — sonnet with escalation hook", hasEscalation: true },
|
|
105
|
+
|
|
106
|
+
// Phase: verify — final quality judgment before milestone complete
|
|
107
|
+
{ phase: "verify", model: TIERS.OPUS, reason: "Milestone verification is the final quality gate — high stakes" },
|
|
108
|
+
|
|
109
|
+
// Phase: test-sync — keeping tests aligned with code
|
|
110
|
+
{ phase: "test-sync", model: TIERS.SONNET, reason: "Test alignment is routine refactoring work" },
|
|
111
|
+
|
|
112
|
+
// Phase: doc-ripple — downstream document updates
|
|
113
|
+
{ phase: "doc-ripple", model: TIERS.SONNET, reason: "Documentation updates are routine prose editing" },
|
|
114
|
+
|
|
115
|
+
// Phase: red_team — explicit adversarial QA phase (separate from execute task_type)
|
|
116
|
+
{ phase: "red_team", model: TIERS.OPUS, reason: "Adversarial QA — always opus, the incentive is to find bugs" },
|
|
117
|
+
|
|
118
|
+
// Phase: qa — explicit standalone QA phase
|
|
119
|
+
{ phase: "qa", model: TIERS.SONNET, reason: "QA per M31 refinement — sonnet produces fewer false negatives than haiku" },
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
// Complexity-signal overrides. If the caller provides `complexity_signals`,
|
|
123
|
+
// these can bump a sonnet decision to opus regardless of phase rule.
|
|
124
|
+
const COMPLEXITY_OVERRIDES = Object.freeze({
|
|
125
|
+
cross_module_refactor: TIERS.OPUS,
|
|
126
|
+
security_boundary: TIERS.OPUS,
|
|
127
|
+
data_loss_risk: TIERS.OPUS,
|
|
128
|
+
contract_design: TIERS.OPUS,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Select the model tier for a subagent spawn.
|
|
135
|
+
*
|
|
136
|
+
* @param {object} args
|
|
137
|
+
* @param {string} args.phase — required; one of the phase names above
|
|
138
|
+
* @param {string} [args.task_type] — optional task_type for finer-grained rules
|
|
139
|
+
* @param {string} [args.domain_type] — optional, currently unused (reserved for future per-domain rules)
|
|
140
|
+
* @param {object} [args.complexity_signals] — optional object, keys matching COMPLEXITY_OVERRIDES escalate sonnet→opus
|
|
141
|
+
* @returns {{model: string, reason: string, escalation_hook: string|null}}
|
|
142
|
+
*/
|
|
143
|
+
function selectModel(args) {
|
|
144
|
+
if (!args || typeof args !== "object") {
|
|
145
|
+
return {
|
|
146
|
+
model: DEFAULT_TIER,
|
|
147
|
+
reason: "No args provided — default to routine tier (sonnet)",
|
|
148
|
+
escalation_hook: null,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const { phase, task_type, complexity_signals } = args;
|
|
153
|
+
|
|
154
|
+
if (!phase || typeof phase !== "string") {
|
|
155
|
+
return {
|
|
156
|
+
model: DEFAULT_TIER,
|
|
157
|
+
reason: "No phase provided — default to routine tier (sonnet)",
|
|
158
|
+
escalation_hook: null,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// First pass: find the most-specific matching rule.
|
|
163
|
+
let matched = null;
|
|
164
|
+
for (const rule of PHASE_RULES) {
|
|
165
|
+
if (rule.phase !== phase) continue;
|
|
166
|
+
if (rule.task_type && rule.task_type !== task_type) continue;
|
|
167
|
+
matched = rule;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!matched) {
|
|
172
|
+
return {
|
|
173
|
+
model: DEFAULT_TIER,
|
|
174
|
+
reason: `Unknown phase "${phase}" — fallback to routine tier (sonnet)`,
|
|
175
|
+
escalation_hook: null,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let model = matched.model;
|
|
180
|
+
let reason = matched.reason;
|
|
181
|
+
|
|
182
|
+
// Complexity-signal overrides: bump sonnet → opus if any flagged signal is truthy.
|
|
183
|
+
if (model === TIERS.SONNET && complexity_signals && typeof complexity_signals === "object") {
|
|
184
|
+
for (const key of Object.keys(complexity_signals)) {
|
|
185
|
+
if (!complexity_signals[key]) continue;
|
|
186
|
+
const override = COMPLEXITY_OVERRIDES[key];
|
|
187
|
+
if (override && override !== model) {
|
|
188
|
+
model = override;
|
|
189
|
+
reason = `${reason} (escalated to ${override} by complexity signal: ${key})`;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Escalation hook is only injected on sonnet phases flagged as high-stakes-adjacent.
|
|
196
|
+
// Haiku phases have no hook (mechanical, no judgment). Opus phases have no hook
|
|
197
|
+
// (already at top tier — nowhere to escalate).
|
|
198
|
+
let escalation_hook = null;
|
|
199
|
+
if (model === TIERS.SONNET && matched.hasEscalation) {
|
|
200
|
+
escalation_hook = ESCALATION_HOOK;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { model, reason, escalation_hook };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Return the canonical list of phase names the selector knows about.
|
|
208
|
+
* Used by tests and documentation tooling to assert coverage.
|
|
209
|
+
*/
|
|
210
|
+
function listPhases() {
|
|
211
|
+
const seen = new Set();
|
|
212
|
+
for (const rule of PHASE_RULES) seen.add(rule.phase);
|
|
213
|
+
return [...seen].sort();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = {
|
|
217
|
+
selectModel,
|
|
218
|
+
listPhases,
|
|
219
|
+
TIERS,
|
|
220
|
+
DEFAULT_TIER,
|
|
221
|
+
ESCALATION_HOOK,
|
|
222
|
+
PHASE_RULES,
|
|
223
|
+
COMPLEXITY_OVERRIDES,
|
|
224
|
+
};
|