@tekyzinc/gsd-t 4.4.11 → 4.6.11

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 CHANGED
@@ -2,16 +2,27 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
- ## [4.4.11] - 2026-06-10 (Playwright No-Focus-Steal — patch)
5
+ ## [4.6.11] - 2026-06-16 (Output Stylesix named conciseness tics + backlog #33 — patch)
6
6
 
7
- ### AddedE2E tests must never steal keyboard focus (all projects)
7
+ ### Changedtightened the CONCISE Output Style rule with six named anti-patterns
8
8
 
9
- Headed Playwright runs on macOS ACTIVATE the browser app and yank keyboard focus from the terminal regardless of window position, so the old "off-screen window" mitigation stopped screen takeover but not focus theft (reported live from binvoice: extension specs commandeered the cursor on every spec). Root cause of the prior "new-headless can't load MV3 extensions" verdict was a binary mix-up: `headless: true` alone launches the chromium_headless_shell (OLD headless, silently no extensions) and a raw `--headless=new` arg fights it. The fix: `channel: 'chromium'` + `headless: true` selects the FULL build's new headless — extensions load, service workers register, zero windows (binvoice: 21/21 in 9.8s).
9
+ User feedback that replies stayed wordy despite the existing CONCISE rule. Extracted six specific ticsthree from live examples, three from the user's own wordy→concise rewrites (binvoice `Wordy Example 1/2.txt`, a labeled before/after set where ~70 wordy lines collapsed to ~25 with identical information) and added a litmus test. Synced the template to the live global CLAUDE.md (blocks kept identical ripple invariant). Also added backlog #33 from the binvoice FB-modal debug-loop retrospective.
10
10
 
11
- - `templates/CLAUDE-global.md`: new "Playwright No-Focus-Steal Invariant" sectionheadless default everywhere, one launch helper owns visibility, `HEADED=1` opt-in only, the channel pitfall documented; mirrored to the live `~/.claude/CLAUDE.md`.
12
- - `templates/test-helpers/launch-extension.ts`: NEW generalized MV3-extension launch helper (newheadless default / offscreen fallback / HEADED=1), proven in binvoice (commit 87e3233 there).
11
+ - `templates/CLAUDE-global.md`: Output Style block gains six rules no process narration, no answer sandwich, no affirmation throat-clearing, no honesty theater, a table replaces its prose (never repeats it), ask once — plus a litmus test ("delete any sentence that survives deletion without info loss").
12
+ - `.gsd-t/backlog.md`: #33 firing debug-cycle circuit-breaker + repro-fixture-on-regression + anchor-last scraping stack rule (completes #31/TD-294 from the loop-governance side).
13
13
 
14
- No code-path changes; propagates via `gsd-t update-all` (CLAUDE-global section sync).
14
+ Behavioral/doc-only no test changes. Suite: 1603/1607 pass, 0 fail.
15
+
16
+ ## [4.6.10] - 2026-06-15 (Installer wiring for status line + low-context cue — minor)
17
+
18
+ ### Added — installer now copies and wires the status line + ctx-cue Stop hook
19
+
20
+ Both `statusline-command.sh` (the M85 two-line GSD-T status bar) and `scripts/hooks/gsd-t-ctx-cue.sh` (the M85 low-context red banner) were canonical sources but had NO installer copy/wire path — dead source that never reached projects on `install` / `update-all` (the global-bin-propagation-gap pattern). This release wires both into `bin/gsd-t.js` so they propagate, ships the M85/M86 template sync (`templates/CLAUDE-global.md` now matches the live tightened global CLAUDE.md, adding the Output Style + Git Worktree Location sections), and commits the per-project `.gsd-t/model-profile.json` default (`{profile: standard}` — Fable 5 not used; all 6 high-leverage stages run on Opus/Sonnet).
21
+
22
+ - `bin/gsd-t.js`: `IN_SESSION_HOOKS` gains a `runner` field (`node` default, `bash` for `.sh` hooks); `gsd-t-ctx-cue.sh` registered as a SYNCHRONOUS Stop hook (its banner stdout must reach the terminal — async would swallow it). New `installStatusLine()` copies `statusline-command.sh` to `~/.claude/` and sets `settings.statusLine` only when absent or already ours (never clobbers a custom status line). Both wired into the shared `doInstall` path, so `update` / `update-all` propagate them.
23
+ - `templates/CLAUDE-global.md`: synced to the live global CLAUDE.md (Output Style default-CONCISE + Git Worktree Location MANDATORY sections added; verbose Update-Notices block condensed).
24
+ - `.gsd-t/model-profile.json`: committed default `{profile: standard}`.
25
+ - `test/m86-installer-statusline-ctxcue.test.js`: NEW black-box regression — runs the real installer against a sandbox HOME and asserts statusLine wiring, the bash-runner ctx-cue Stop hook, its synchronous registration, and both file copies.
15
26
 
16
27
  ## [4.4.10] - 2026-06-09 (M85 Model-Tier Policy + Fable 5 — minor)
17
28
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v4.4.10** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v4.5.10** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
4
4
 
5
5
  **Eliminates context rot** — task-level fresh dispatch (one subagent per task, ~10-20% context each) means compaction never triggers.
6
6
  **Compaction-proof debug loops** — `gsd-t headless --debug-loop` runs test-fix-retest cycles as separate `claude -p` sessions. A JSONL debug ledger persists all hypothesis/fix/learning history across fresh sessions. Anti-repetition preamble injection prevents retrying failed hypotheses. Escalation tiers (sonnet → opus → human) and a hard iteration ceiling enforced externally.
@@ -18,7 +18,7 @@
18
18
  **Rigorous User-Journey Coverage + Anti-Drift Test Quality** — `bin/journey-coverage.cjs` regex listener detector + `gsd-t check-coverage` CLI + `scripts/hooks/pre-commit-journey-coverage` commit gate blocks viewer-source commits when uncovered listeners exist. Journey specs in `e2e/journeys/` use functional assertions (zero `toBeVisible`-only tests) per the E2E Test Quality Standard in CLAUDE.md.
19
19
  **Universal Playwright Bootstrap + Deterministic UI Enforcement (M50)** — three executable enforcement layers: (1) `bin/playwright-bootstrap.cjs` + `bin/ui-detection.cjs` - idempotent installer detects package manager, installs `@playwright/test` + chromium, scaffolds `e2e/`; (2) Workflow runtime runs `playwright-bootstrap.cjs::installPlaywright()` before any E2E stage when `hasUI && !hasPlaywright`; install failure halts with `blocked-needs-human`; (3) `scripts/hooks/pre-commit-playwright-gate` (opt-in via `gsd-t doctor --install-hooks`) blocks viewer-source commits when staged files are newer than `.gsd-t/.last-playwright-pass`. The `gsd-t setup-playwright [path]` subcommand handles manual install.
20
20
  **Visualizer (`/gsd-t-visualize`)** — launches a real-time browser dashboard with dual-pane view: top pane streams the main session, bottom pane streams whichever spawn the user clicks. Left rail shows Live Spawns and Completed (last 100 spawns, status-badged, collapsible). Right rail shows Spawn Plan / Parallelism / Tool Cost. Powered by `gsd-t-stream-feed-server.js` + `gsd-t-dashboard.html`.
21
- **Surgical model selection** — `bin/model-selector.js` assigns haiku/sonnet/opus/fable per phase via a declarative rules table; `/advisor` escalation path with convention-based fallback. **M85 single-source tier policy:** `bin/gsd-t-model-tier-policy.cjs` is the SINGLE source of truth for model-tier assignments; the 5 highest-leverage stages (solution-space probe, partition probe, competition judge, pre-mortem, Red Team) run on `fable` (Claude Fable 5, tier above Opus); competition producers stay `opus` (M82 blindness); debug escalates cycle-1→opus, cycle-2→fable. Drift is mechanically enforced by the M71-family lint (`test/m85-workflow-tier-policy-lint.test.js`).
21
+ **Surgical model selection** — `bin/model-selector.js` assigns haiku/sonnet/opus/fable per phase via a declarative rules table; `/advisor` escalation path with convention-based fallback. **M85 single-source tier policy:** `bin/gsd-t-model-tier-policy.cjs` is the SINGLE source of truth for model-tier assignments; the 5 highest-leverage stages (solution-space probe, partition probe, competition judge, pre-mortem, Red Team) run on `fable` (Claude Fable 5, tier above Opus); competition producers stay `opus` (M82 blindness); debug escalates cycle-1→opus, cycle-2→fable. Drift is mechanically enforced by the M71-family lint (`test/m85-workflow-tier-policy-lint.test.js`). **M86 model profiles:** `bin/gsd-t-model-profile.cjs` adds a per-project SECOND dimension — three named profiles (`standard` / `pro` / `premium`) that control which stages run on Fable vs. Opus/Sonnet (see [Model Profiles](#model-profiles) below).
22
22
  **Token Telemetry** — `gsd-t-calibration-hook.js` records token usage per spawn to `.gsd-t/token-metrics.jsonl` (18-field rows). `gsd-t-token-aggregator.js` aggregates across tasks for the `/gsd-t-metrics` view. Use the native Claude Code `/context` command for live in-session context percentage.
23
23
  **Quality North Star** — projects define a `## Quality North Star` section in CLAUDE.md (1–3 sentences, e.g., "This is a published npm library. Every public API must be intuitive and backward-compatible."). `gsd-t-init` auto-detects preset (library/web-app/cli) from package.json signals; `gsd-t-setup` configures it for existing projects. Subagents read it as a quality lens; absent = silent skip (backward compatible).
24
24
  **Design Brief Artifact** — during partition, UI/frontend projects (React, Vue, Svelte, Flutter, Tailwind) automatically get `.gsd-t/contracts/design-brief.md` with color palette, typography, spacing system, component patterns, and tone/voice. Non-UI projects skip silently. User-customized briefs are preserved. Referenced in plan phase for visual consistency.
@@ -124,6 +124,12 @@ gsd-t test-data --list [--run ID] [--json] # M58: list test-data le
124
124
  gsd-t test-data --purge --run ID [--dry-run] [--json] # M58: purge tagged test data after Verify (Step 4.5)
125
125
  gsd-t competition-judge --in SPEC.json [--project-dir P] # M82: generate-and-judge selection oracle (partition / generic)
126
126
  gsd-t traceability-gate --milestone Mxx [--project-dir P] # M83: plan-phase acceptance-traceability gate (AC → path → killing test)
127
+
128
+ # Model Profiles (M86 — per-project tier-spend switch)
129
+ gsd-t model-profile show [--json] # Show active profile + per-stage resolution
130
+ gsd-t model-profile set <standard|pro|premium> # Switch the project profile
131
+ gsd-t model-profile set-stage <stage> <tier> # Per-stage override (M82 blindness clamps enforced)
132
+ gsd-t model-profile resolve --profile <p> [stage] [--json] # Resolve a profile into the overrides envelope
127
133
  ```
128
134
 
129
135
  **Plan Hardening (M83).** The `plan` phase now runs two blocking gates before execute, so a plan can't ship a dead deliverable: a deterministic **acceptance-traceability gate** (`gsd-t traceability-gate` — every AC must bind to a code path + a killing test; the headline capability needs both impl and test) and an adversarial **pre-mortem** agent (opus, fresh-context, predicts edge-case/NFR/dead-deliverable failures and requires a test for each). The temporal dual of the Red Team — attack the design at plan, not just the code at verify. Origin: a build where the headline capability shipped as dead code and burned 4 verify cycles. See `.gsd-t/contracts/plan-hardening-contract.md`.
@@ -233,6 +239,16 @@ This will replace changed command files, back up your CLAUDE.md if customized, a
233
239
  | `/gsd-t-backlog-promote` | Refine, classify, launch GSD-T workflow | Manual |
234
240
  | `/gsd-t-backlog-settings` | Manage types, apps, categories, defaults | Manual |
235
241
 
242
+ ### Model Management
243
+
244
+ | CLI Command | Purpose |
245
+ |-------------|---------|
246
+ | `gsd-t model-profile show [--json]` | Display active profile + per-stage resolution |
247
+ | `gsd-t model-profile set <standard\|pro\|premium>` | Switch the per-project profile |
248
+ | `gsd-t model-profile set-stage <stage> <tier>` | Per-stage override (M82 blindness clamps enforced) |
249
+ | `gsd-t model-profile resolve --profile <p> [stage] [--json]` | Resolve a profile into the overrides envelope |
250
+ | `gsd-t model-tier-policy resolve <stageKey> [--json]` | Resolve a stage key to a concrete model id (M85) |
251
+
236
252
  ### Git Helpers
237
253
 
238
254
  | Command | Purpose | Auto |
@@ -323,6 +339,44 @@ your-project/
323
339
 
324
340
  ---
325
341
 
342
+ ## Model Profiles
343
+
344
+ M86 adds a per-project **tier-spend switch** as a second dimension over the M85 stage-tier policy. Instead of always running the full Fable posture, you can dial back which stages use Fable vs. Opus/Sonnet to match your cost-vs-quality tradeoff.
345
+
346
+ ### Three named profiles
347
+
348
+ | Profile | Fable stages | When to use |
349
+ |---------|--------------|-------------|
350
+ | `standard` | None — pre-M85 posture (probes→opus, judge→sonnet, red-team→opus, pre-mortem→opus, debug both cycles→opus) | CI runs, draft milestones, tight budget |
351
+ | `pro` | red-team + pre-mortem + debug-cycle-2 | Targeted quality gates; production-bound milestones |
352
+ | `premium` | All 6 M85 designated stages (global default) | Full posture — highest quality gates |
353
+
354
+ `competition-producers` is **always `opus`** in every profile (M82 blindness invariant — judge must differ from producers).
355
+
356
+ ### Per-project configuration
357
+
358
+ ```json
359
+ // .gsd-t/model-profile.json
360
+ { "profile": "pro", "stageOverrides": { "competition-judge": "fable" } }
361
+ ```
362
+
363
+ - `profile` ∈ `standard | pro | premium`. Absent file → global default (`premium`), always NAMED in the banner/statusline (SC(f) — no silent degradation).
364
+ - `stageOverrides` (optional) — per-stage tier that beats the profile.
365
+ - Blindness clamps enforced at resolve time: `competition-producers` is not overridable; `competition-judge` cannot be set equal to the producers' model.
366
+
367
+ ### Surfacing
368
+
369
+ The active profile is always surfaced in three places:
370
+ - **Session banner**: `[GSD-T PROFILE] profile: pro` (emitted by the UserPromptSubmit hook every turn)
371
+ - **Statusline**: `│ profile: pro` (in `gsd-t-statusline.js`)
372
+ - **`gsd-t status`**: `Model Profile: pro` (in the status report header)
373
+
374
+ ### Out of scope
375
+
376
+ The session default model (`/model`) is unaffected — profiles govern workflow stages only. The `standard` profile does not disable any workflow step; it only changes which model tier runs them.
377
+
378
+ ---
379
+
326
380
  ## Security
327
381
 
328
382
  - **Wave mode** spawns phase agents with `bypassPermissions` — agents execute without per-action user approval. Use Level 1 or Level 2 autonomy for sensitive projects to review each phase.
@@ -0,0 +1,480 @@
1
+ /**
2
+ * gsd-t-model-profile.cjs
3
+ *
4
+ * Brain for the `gsd-t model-profile` CLI subcommand.
5
+ * Reads/writes `.gsd-t/model-profile.json` and exposes a profile-aware
6
+ * resolver that injects concrete model ids into workflow args.
7
+ *
8
+ * Zero external runtime deps — installer-package invariant.
9
+ * No top-level side effects (require-safe).
10
+ *
11
+ * Contract: .gsd-t/contracts/model-profile-config-contract.md v1.0.0 STABLE
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const path = require('path');
17
+ const fs = require('fs');
18
+ const policy = require('./gsd-t-model-tier-policy.cjs');
19
+
20
+ // Version-skew guard (Red Team M86 r2): if the sibling policy module is an older
21
+ // copy lacking the M86 profile surface (seen live when a pre-M86 propagation pass
22
+ // overwrote it), fail with a STRUCTURED error instead of a raw TypeError at the
23
+ // Object.keys() lines below — the contracted envelope shape even on skew.
24
+ const _missingPolicyExports = ['MODEL_IDS', 'PROFILE_STAGE_TIERS', 'INJECTABLE_STAGES', 'resolveProfile', 'requiresThinkingOmitted']
25
+ .filter((k) => policy[k] === undefined);
26
+ if (_missingPolicyExports.length) {
27
+ const msg = `gsd-t-model-tier-policy.cjs is missing the M86 profile surface (${_missingPolicyExports.join(', ')}) — ` +
28
+ 'version skew: an older policy module is installed alongside this CLI. Reinstall/update @tekyzinc/gsd-t.';
29
+ if (require.main === module) {
30
+ // The guard runs before flag parsing; honor the output convention manually.
31
+ if (process.argv.includes('--json')) {
32
+ process.stdout.write(JSON.stringify({ ok: false, error: msg }) + '\n');
33
+ } else {
34
+ process.stderr.write(msg + '\n');
35
+ }
36
+ process.exit(1);
37
+ }
38
+ throw new Error(msg);
39
+ }
40
+
41
+ const { MODEL_IDS, PROFILE_STAGE_TIERS, INJECTABLE_STAGES, resolveProfile, requiresThinkingOmitted } = policy;
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Constants
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /** The global default profile (SC(f) — named, never blank). */
48
+ const GLOBAL_DEFAULT_PROFILE = 'premium';
49
+
50
+ /** Valid profile names. */
51
+ const VALID_PROFILES = Object.keys(PROFILE_STAGE_TIERS); // ['standard','pro','premium']
52
+
53
+ /** Valid tier names. */
54
+ const VALID_TIERS = Object.keys(MODEL_IDS); // ['opus','fable','sonnet','haiku']
55
+
56
+ /**
57
+ * Stages that CANNOT be overridden (M82 blindness invariant).
58
+ * competition-producers is always opus — not injectable.
59
+ */
60
+ const NON_INJECTABLE_STAGES = ['competition-producers'];
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Config read/write helpers
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /**
67
+ * Read and parse `.gsd-t/model-profile.json` from the given project root.
68
+ * Returns { ok, profile, stageOverrides, configError? } — never throws.
69
+ * Absent file → named global default (SC(f)).
70
+ *
71
+ * @param {string} projectDir
72
+ * @returns {{ ok: boolean, profile: string, stageOverrides: Record<string,string>, configError?: string }}
73
+ */
74
+ function readConfig(projectDir) {
75
+ const configPath = path.join(projectDir, '.gsd-t', 'model-profile.json');
76
+
77
+ if (!fs.existsSync(configPath)) {
78
+ return { ok: true, profile: GLOBAL_DEFAULT_PROFILE, stageOverrides: {} };
79
+ }
80
+
81
+ let raw;
82
+ try {
83
+ raw = fs.readFileSync(configPath, 'utf8');
84
+ } catch (err) {
85
+ return {
86
+ ok: false,
87
+ profile: GLOBAL_DEFAULT_PROFILE,
88
+ stageOverrides: {},
89
+ configError: `Failed to read config file: ${err.message}`,
90
+ };
91
+ }
92
+
93
+ let parsed;
94
+ try {
95
+ parsed = JSON.parse(raw);
96
+ } catch (err) {
97
+ return {
98
+ ok: false,
99
+ profile: GLOBAL_DEFAULT_PROFILE,
100
+ stageOverrides: {},
101
+ configError: `model-profile.json is not valid JSON: ${err.message}`,
102
+ };
103
+ }
104
+
105
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
106
+ return {
107
+ ok: false,
108
+ profile: GLOBAL_DEFAULT_PROFILE,
109
+ stageOverrides: {},
110
+ configError: 'model-profile.json must be a JSON object',
111
+ };
112
+ }
113
+
114
+ // Validate profile field
115
+ let profile = GLOBAL_DEFAULT_PROFILE;
116
+ let configError;
117
+ if ('profile' in parsed) {
118
+ if (typeof parsed.profile !== 'string') {
119
+ configError = `model-profile.json "profile" field must be a string, got ${typeof parsed.profile}`;
120
+ } else if (!VALID_PROFILES.includes(parsed.profile)) {
121
+ configError = `model-profile.json "profile" is unknown: "${parsed.profile}"; defaulting to "${GLOBAL_DEFAULT_PROFILE}"`;
122
+ } else {
123
+ profile = parsed.profile;
124
+ }
125
+ }
126
+
127
+ // Validate stageOverrides field. Per-entry validation is OWN-PROPERTY and
128
+ // membership-based — a string that merely indexes something truthy (e.g.
129
+ // "constructor") is NOT a valid tier (Red Team M86 HIGH: prototype-key tier
130
+ // values produced a clean envelope with the stage silently dropped → the
131
+ // workflow fallback billed premium on a cost-control profile).
132
+ let stageOverrides = {};
133
+ if ('stageOverrides' in parsed) {
134
+ if (parsed.stageOverrides === null || typeof parsed.stageOverrides !== 'object' || Array.isArray(parsed.stageOverrides)) {
135
+ configError = configError || `model-profile.json "stageOverrides" must be an object`;
136
+ } else {
137
+ for (const [k, v] of Object.entries(parsed.stageOverrides)) {
138
+ if (NON_INJECTABLE_STAGES.includes(k)) {
139
+ configError = configError || `model-profile.json stageOverrides["${k}"]: competition-producers is not overridable (M82); entry ignored`;
140
+ } else if (!INJECTABLE_STAGES.includes(k)) {
141
+ configError = configError || `model-profile.json stageOverrides has unknown stage "${k}"; entry ignored`;
142
+ } else if (typeof v !== 'string' || !VALID_TIERS.includes(v)) {
143
+ configError = configError || `model-profile.json stageOverrides["${k}"] has invalid tier ${JSON.stringify(v)}; entry ignored`;
144
+ } else {
145
+ stageOverrides[k] = v;
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ if (configError) {
152
+ return { ok: false, profile, stageOverrides, configError };
153
+ }
154
+
155
+ return { ok: true, profile, stageOverrides };
156
+ }
157
+
158
+ /**
159
+ * Write `.gsd-t/model-profile.json`.
160
+ *
161
+ * @param {string} projectDir
162
+ * @param {{ profile?: string, stageOverrides?: Record<string,string> }} data
163
+ * @returns {{ ok: boolean, error?: string }}
164
+ */
165
+ function writeConfig(projectDir, data) {
166
+ const dir = path.join(projectDir, '.gsd-t');
167
+ const configPath = path.join(dir, 'model-profile.json');
168
+
169
+ try {
170
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
171
+ fs.writeFileSync(configPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
172
+ return { ok: true };
173
+ } catch (err) {
174
+ return { ok: false, error: err.message };
175
+ }
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Blindness clamp validation (enforced at WRITE and at RESOLVE)
180
+ // ---------------------------------------------------------------------------
181
+
182
+ /**
183
+ * Validate that a set-stage operation is permitted.
184
+ * Returns { ok, error? } — does NOT throw.
185
+ *
186
+ * Clamps:
187
+ * - competition-producers: not overridable (M82 blindness invariant)
188
+ * - competition-judge = opus (producers' model): judge ≠ producers (M82)
189
+ *
190
+ * @param {string} stageKey
191
+ * @param {string} tier
192
+ * @returns {{ ok: boolean, error?: string }}
193
+ */
194
+ function validateSetStage(stageKey, tier) {
195
+ if (NON_INJECTABLE_STAGES.includes(stageKey)) {
196
+ return {
197
+ ok: false,
198
+ error: `Stage "${stageKey}" is not overridable (M82 blindness invariant — competition-producers is always held at opus)`,
199
+ };
200
+ }
201
+
202
+ // Unknown stage keys are rejected, not persisted (Red Team M86 MEDIUM: a typo'd
203
+ // stage got a success message + a persisted override that never takes effect,
204
+ // and `show` silently hid it).
205
+ if (!INJECTABLE_STAGES.includes(stageKey)) {
206
+ return {
207
+ ok: false,
208
+ error: `Unknown stage "${stageKey}". Injectable stages: ${INJECTABLE_STAGES.join(', ')}`,
209
+ };
210
+ }
211
+
212
+ if (!VALID_TIERS.includes(tier)) {
213
+ return {
214
+ ok: false,
215
+ error: `Unknown tier "${tier}". Valid tiers: ${VALID_TIERS.join(', ')}`,
216
+ };
217
+ }
218
+
219
+ if (stageKey === 'competition-judge' && MODEL_IDS[tier] === MODEL_IDS.opus) {
220
+ return {
221
+ ok: false,
222
+ error: `competition-judge cannot be set to "${tier}" (resolves to "${MODEL_IDS[tier]}") — judge model must differ from producers' model (M82 blindness invariant)`,
223
+ };
224
+ }
225
+
226
+ return { ok: true };
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Resolve envelope builder (the seam D2/D4 consume)
231
+ // ---------------------------------------------------------------------------
232
+
233
+ /**
234
+ * Build the full resolve envelope for a profile + stageOverrides combo.
235
+ * Enforces blindness clamps at resolve time (hand-edited configs are safe).
236
+ *
237
+ * @param {string} profile
238
+ * @param {Record<string,string>} stageOverrides
239
+ * @param {string} [specificStage] — if given, return single-stage result
240
+ * @param {string} [configError] — propagate from config read
241
+ * @returns {object}
242
+ */
243
+ function buildResolveEnvelope(profile, stageOverrides, specificStage, configError) {
244
+ // Validate profile
245
+ if (!VALID_PROFILES.includes(profile)) {
246
+ return { ok: false, error: `Unknown profile "${profile}". Valid profiles: ${VALID_PROFILES.join(', ')}` };
247
+ }
248
+
249
+ // If a specific stage is requested
250
+ if (specificStage !== undefined) {
251
+ // Unknown stage → explicit error (Red Team M86 MEDIUM: silently returning
252
+ // ok:true sonnet for a typo'd stage regressed the M85 explicit unknown-stage error).
253
+ if (specificStage !== 'competition-producers' && !INJECTABLE_STAGES.includes(specificStage)) {
254
+ return {
255
+ ok: false,
256
+ error: `Unknown stage "${specificStage}". Valid stages: ${INJECTABLE_STAGES.join(', ')}, competition-producers`,
257
+ };
258
+ }
259
+ if (specificStage === 'competition-producers') {
260
+ const modelId = MODEL_IDS.opus;
261
+ const result = { ok: true, profile, stage: specificStage, model: modelId, requiresThinkingOmitted: requiresThinkingOmitted(modelId) };
262
+ if (configError) result.configError = configError;
263
+ return result;
264
+ }
265
+ const r = resolveProfile(specificStage, { profile, stageOverrides });
266
+ const result = { ok: true, profile, stage: specificStage, model: r.model, requiresThinkingOmitted: r.requiresThinkingOmitted };
267
+ if (r.configError) result.configError = r.configError;
268
+ else if (configError) result.configError = configError;
269
+ return result;
270
+ }
271
+
272
+ // Build overrides map for all injectable stages
273
+ const overrides = {};
274
+ const thinkingMap = {};
275
+ let envelopeConfigError = configError;
276
+
277
+ for (const stage of INJECTABLE_STAGES) {
278
+ const r = resolveProfile(stage, { profile, stageOverrides });
279
+ overrides[stage] = r.model;
280
+ thinkingMap[stage] = r.requiresThinkingOmitted;
281
+ if (r.configError && !envelopeConfigError) envelopeConfigError = r.configError;
282
+ }
283
+
284
+ // Verify competition-judge !== producers' model (final assertion)
285
+ if (overrides['competition-judge'] === MODEL_IDS.opus) {
286
+ // This should never reach here (resolveProfile clamps it), but defense in depth
287
+ envelopeConfigError = envelopeConfigError || 'blindness clamp: competition-judge resolved to producers model — using profile fallback';
288
+ const fallbackR = resolveProfile('competition-judge', { profile, stageOverrides: {} });
289
+ overrides['competition-judge'] = fallbackR.model;
290
+ thinkingMap['competition-judge'] = fallbackR.requiresThinkingOmitted;
291
+ }
292
+
293
+ const result = {
294
+ ok: true,
295
+ profile,
296
+ overrides,
297
+ requiresThinkingOmitted: thinkingMap,
298
+ };
299
+ if (envelopeConfigError) result.configError = envelopeConfigError;
300
+ return result;
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // CLI dispatch
305
+ // ---------------------------------------------------------------------------
306
+
307
+ if (require.main === module) {
308
+ const rawArgs = process.argv.slice(2);
309
+ const jsonFlag = rawArgs.includes('--json');
310
+ const positional = rawArgs.filter(a => !a.startsWith('-'));
311
+ const subcommand = positional[0];
312
+
313
+ // Detect project dir: look for .gsd-t relative to cwd
314
+ const projectDir = process.cwd();
315
+
316
+ function emit(obj, exitCode) {
317
+ if (jsonFlag) {
318
+ process.stdout.write(JSON.stringify(obj) + '\n');
319
+ } else {
320
+ if (!obj.ok) {
321
+ process.stderr.write((obj.error || obj.configError || 'Unknown error') + '\n');
322
+ } else {
323
+ // Human-readable output
324
+ if (obj.message) process.stdout.write(obj.message + '\n');
325
+ if (obj.warning) process.stderr.write(`warning: ${obj.warning}\n`);
326
+ if (obj.profile !== undefined && obj.overrides !== undefined) {
327
+ process.stdout.write(`profile: ${obj.profile}\n`);
328
+ for (const [k, v] of Object.entries(obj.overrides)) {
329
+ process.stdout.write(` ${k}: ${v}${obj.requiresThinkingOmitted[k] ? ' (requiresThinkingOmitted)' : ''}\n`);
330
+ }
331
+ if (obj.configError) process.stderr.write(`warning: ${obj.configError}\n`);
332
+ } else if (obj.model !== undefined) {
333
+ process.stdout.write(`model: ${obj.model}\n`);
334
+ if (obj.requiresThinkingOmitted) process.stdout.write(`requiresThinkingOmitted: true\n`);
335
+ if (obj.configError) process.stderr.write(`warning: ${obj.configError}\n`);
336
+ } else {
337
+ process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
338
+ }
339
+ }
340
+ }
341
+ process.exit(exitCode !== undefined ? exitCode : (obj.ok ? 0 : 1));
342
+ }
343
+
344
+ if (subcommand === 'show') {
345
+ const cfg = readConfig(projectDir);
346
+ if (!cfg.ok && cfg.configError) {
347
+ emit({ ok: false, error: cfg.configError, profile: cfg.profile, stageOverrides: cfg.stageOverrides }, 1);
348
+ } else {
349
+ const envelope = buildResolveEnvelope(cfg.profile, cfg.stageOverrides, undefined, cfg.configError);
350
+ emit(envelope);
351
+ }
352
+ } else if (subcommand === 'set') {
353
+ const newProfile = positional[1];
354
+ if (!newProfile) {
355
+ emit({ ok: false, error: 'Usage: gsd-t model-profile set <profile>' }, 1);
356
+ return;
357
+ }
358
+ if (!VALID_PROFILES.includes(newProfile)) {
359
+ emit({ ok: false, error: `Unknown profile "${newProfile}". Valid profiles: ${VALID_PROFILES.join(', ')}` }, 1);
360
+ return;
361
+ }
362
+ const cfg = readConfig(projectDir);
363
+ // Setting the profile is the user's explicit intent — proceed even over a
364
+ // config with errors, but NAME what the rewrite normalizes away (Red Team
365
+ // M86 r2 LOW: a silent rewrite dropped invalid entries with no mention).
366
+ const newData = { profile: newProfile, stageOverrides: cfg.stageOverrides || {} };
367
+ const result = writeConfig(projectDir, newData);
368
+ if (!result.ok) {
369
+ emit({ ok: false, error: result.error }, 1);
370
+ } else {
371
+ const out = { ok: true, profile: newProfile, message: `Profile set to "${newProfile}"` };
372
+ if (cfg.configError) out.warning = `previous config had an error and was normalized: ${cfg.configError}`;
373
+ emit(out);
374
+ }
375
+ } else if (subcommand === 'set-stage') {
376
+ const stage = positional[1];
377
+ const tier = positional[2];
378
+ if (!stage || !tier) {
379
+ emit({ ok: false, error: 'Usage: gsd-t model-profile set-stage <stage> <tier>' }, 1);
380
+ return;
381
+ }
382
+ const valid = validateSetStage(stage, tier);
383
+ if (!valid.ok) {
384
+ emit({ ok: false, error: valid.error }, 1);
385
+ return;
386
+ }
387
+ const cfg = readConfig(projectDir);
388
+ // REFUSE to rewrite over an erroring config (Red Team M86 r2 MEDIUM: the
389
+ // rewrite persisted readConfig's defaulted "premium" over a typo'd
390
+ // standard-intent profile — a stage tweak silently ESCALATING the spend
391
+ // posture — and silently dropped invalid entries). set-stage must never
392
+ // change the profile as a side effect; fix the config (or run `set`) first.
393
+ if (cfg.configError) {
394
+ emit({
395
+ ok: false,
396
+ error: `config has an error — refusing to rewrite (a rewrite would persist normalized values the user never set): ${cfg.configError}. ` +
397
+ `Fix .gsd-t/model-profile.json or run \`gsd-t model-profile set <profile>\` first.`,
398
+ }, 1);
399
+ return;
400
+ }
401
+ const newOverrides = Object.assign({}, cfg.stageOverrides || {}, { [stage]: tier });
402
+ const newData = { profile: cfg.profile, stageOverrides: newOverrides };
403
+ const result = writeConfig(projectDir, newData);
404
+ if (!result.ok) {
405
+ emit({ ok: false, error: result.error }, 1);
406
+ } else {
407
+ emit({ ok: true, stage, tier, model: MODEL_IDS[tier], message: `Stage "${stage}" override set to tier "${tier}" (${MODEL_IDS[tier]})` });
408
+ }
409
+ } else if (subcommand === 'resolve') {
410
+ // Flags: --profile <p> [stage]
411
+ const profileFlagIdx = rawArgs.indexOf('--profile');
412
+ let profileArg;
413
+ if (profileFlagIdx !== -1 && rawArgs[profileFlagIdx + 1] && !rawArgs[profileFlagIdx + 1].startsWith('-')) {
414
+ profileArg = rawArgs[profileFlagIdx + 1];
415
+ }
416
+
417
+ // Determine profile: from --profile flag, or from config file
418
+ let profile, stageOverrides, configError;
419
+ if (profileArg) {
420
+ if (!VALID_PROFILES.includes(profileArg)) {
421
+ emit({ ok: false, error: `Unknown profile "${profileArg}". Valid profiles: ${VALID_PROFILES.join(', ')}` }, 1);
422
+ return;
423
+ }
424
+ profile = profileArg;
425
+ stageOverrides = {};
426
+ } else {
427
+ const cfg = readConfig(projectDir);
428
+ profile = cfg.profile;
429
+ stageOverrides = cfg.stageOverrides;
430
+ configError = cfg.configError;
431
+ if (!cfg.ok) {
432
+ emit({ ok: false, error: configError || 'Failed to read config' }, 1);
433
+ return;
434
+ }
435
+ }
436
+
437
+ // Optional specific stage as a positional arg AFTER 'resolve', excluding
438
+ // the value of --profile <p> from the positional array.
439
+ // rawArgs example: ['resolve', '--profile', 'pro', 'red-team', '--json']
440
+ // We want 'red-team' but not 'pro' (consumed by --profile flag).
441
+ const profileFlagValuesToExclude = new Set();
442
+ if (profileFlagIdx !== -1 && rawArgs[profileFlagIdx + 1] && !rawArgs[profileFlagIdx + 1].startsWith('-')) {
443
+ profileFlagValuesToExclude.add(rawArgs[profileFlagIdx + 1]);
444
+ }
445
+ const resolvePositional = rawArgs.filter((a, i) => {
446
+ if (a.startsWith('-')) return false;
447
+ // exclude the value immediately following --profile
448
+ if (profileFlagIdx !== -1 && i === profileFlagIdx + 1) return false;
449
+ return true;
450
+ });
451
+ // resolvePositional[0] = 'resolve', [1] = optional stage
452
+ const specificStage = resolvePositional[1];
453
+
454
+ const envelope = buildResolveEnvelope(profile, stageOverrides, specificStage, configError);
455
+ emit(envelope);
456
+ } else {
457
+ const usage = 'Usage: gsd-t model-profile <show|set <profile>|set-stage <stage> <tier>|resolve [--profile <p>] [stage]> [--json]';
458
+ if (jsonFlag) {
459
+ process.stdout.write(JSON.stringify({ ok: false, error: usage }) + '\n');
460
+ } else {
461
+ process.stderr.write(usage + '\n');
462
+ }
463
+ process.exit(1);
464
+ }
465
+ }
466
+
467
+ // ---------------------------------------------------------------------------
468
+ // Exports (for require() consumers and tests)
469
+ // ---------------------------------------------------------------------------
470
+
471
+ module.exports = {
472
+ GLOBAL_DEFAULT_PROFILE,
473
+ VALID_PROFILES,
474
+ VALID_TIERS,
475
+ NON_INJECTABLE_STAGES,
476
+ readConfig,
477
+ writeConfig,
478
+ validateSetStage,
479
+ buildResolveEnvelope,
480
+ };