@tekyzinc/gsd-t 5.1.13 → 5.2.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.
@@ -21,6 +21,9 @@
21
21
  const fs = require("fs");
22
22
  const path = require("path");
23
23
  const crypto = require("crypto");
24
+ // M102 — the marker-block idempotent doc-writer was extracted here into the ONE
25
+ // shared module both this scaffolder and bin/gsd-t-env-registry.cjs call.
26
+ const { upsertMarkedDocBlock } = require("./gsd-t-doc-marker.cjs");
24
27
 
25
28
  const VALID_BACKENDS = ["db-table", "local-sqlite", "local-jsonl"];
26
29
 
@@ -182,24 +185,9 @@ function writeChoiceToProjectDocs(projectDir, envelope) {
182
185
  const targetPath = candidates.find((p) => fs.existsSync(p)) || candidates[0];
183
186
 
184
187
  const block = docBlock(envelope);
185
- let content = "";
186
- if (fs.existsSync(targetPath)) {
187
- content = fs.readFileSync(targetPath, "utf8");
188
- } else {
189
- const dir = path.dirname(targetPath);
190
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
191
- }
192
-
193
- if (content.includes(DOC_MARKER_START) && content.includes(DOC_MARKER_END)) {
194
- const startIdx = content.indexOf(DOC_MARKER_START);
195
- const endIdx = content.indexOf(DOC_MARKER_END) + DOC_MARKER_END.length;
196
- content = content.slice(0, startIdx) + block + content.slice(endIdx);
197
- } else {
198
- content = content.replace(/\s*$/, "") + "\n\n" + block + "\n";
199
- }
200
-
201
- fs.writeFileSync(targetPath, content, "utf8");
202
- return targetPath;
188
+ // M102 delegate to the ONE shared marker-block writer (byte-identical logic
189
+ // to the former inline replace/append; extracted so env-registry reuses it).
190
+ return upsertMarkedDocBlock(targetPath, DOC_MARKER_START, DOC_MARKER_END, block);
203
191
  }
204
192
 
205
193
  // ─── Seam envelope builder ──────────────────────────────────────────────────
@@ -307,6 +307,8 @@ function _detectDefaultTrack2(projectDir, notes) {
307
307
 
308
308
  plan.push({ id: 'logging-envelope', cmd: 'node', args: [path.join(__dirname, 'gsd-t-logging-envelope-check.cjs'), '--project', projectDir], timeoutMs: 30000 }); // M100 D3: structural trace+audit envelope gate, FAIL-CLOSED
309
309
 
310
+ plan.push({ id: 'env-registry', cmd: 'node', args: [path.join(__dirname, 'gsd-t-env-registry-check.cjs'), '--project', projectDir], timeoutMs: 30000 }); // M102 D3: no-secret-in-registry + rule-without-table gate, FAIL-CLOSED
311
+
310
312
  // secrets — gitleaks (PATH detection deferred to runtime)
311
313
  if (_hasOnPath('gitleaks')) {
312
314
  plan.push({
package/bin/gsd-t.js CHANGED
@@ -2999,6 +2999,16 @@ const PROJECT_BIN_TOOLS = [
2999
2999
  // ship to every project or the consumers can't classify. Same propagation class as
3000
3000
  // gsd-t-graph-store-resolver.cjs above. [[project_global_bin_propagation_gap]]
3001
3001
  "gsd-t-graph-availability.cjs",
3002
+ // M102 — Environment Registry. The shared marker-block doc-writer + the
3003
+ // registry helper (record/lookup/detect/add-permission/ensure-gitignore).
3004
+ // Sandboxed workflows (init/populate + provisioning execute/quick tasks) call
3005
+ // the registry via project-local Bash, and the registry `require`s the shared
3006
+ // writer at load — BOTH must ship or the registry throws / the writer is a
3007
+ // second copy. Same propagation class as the graph tools above.
3008
+ // [[project_global_bin_propagation_gap]]
3009
+ // Plus the verify-gate lint (gsd-t-verify-gate.cjs invokes it via __dirname,
3010
+ // so it must sit alongside the gate in the project's bin/).
3011
+ "gsd-t-doc-marker.cjs", "gsd-t-env-registry.cjs", "gsd-t-env-registry-check.cjs",
3002
3012
  ];
3003
3013
 
3004
3014
  // Files that older versions of this installer copied into project bin/ but
@@ -296,6 +296,12 @@ docs/
296
296
 
297
297
  These are the living documents that persist across milestones and keep institutional knowledge alive. The `infrastructure.md` is especially important — it captures the exact commands for provisioning cloud resources, setting up databases, managing secrets, and deploying, so this knowledge doesn't get lost between sessions.
298
298
 
299
+ The `infrastructure.md` template ships with a marker-delimited `## Environments` table (`<!-- gsd-t-env-registry:start -->` / `:end`) — the committed, **secret-free** connection registry (M102). It is created empty by init and populated by the two triggers:
300
+ - **Greenfield (record-at-create):** whenever this project later BUILDS/PROVISIONS/CONFIGURES an environment (a local test DB, a remote server, credentials, or hands the user setup instructions), record the map row in the SAME pass:
301
+ `node bin/gsd-t-env-registry.cjs record '{"projectDir":".","scope":"local","kind":"postgres","host":"localhost","port":"5432","name":"appdb","authMethod":"password","secretVault":"local (.env)","secretEnvVarName":"DATABASE_URL","fetchCommand":"read $DATABASE_URL from .env","connectCommand":"psql \"$DATABASE_URL\""}'`
302
+ Records only the MAP + vault pointer + env-var NAME + fetch/connect commands — **never a secret VALUE** (a literal password/token in a command is REJECTED). `scope=prod` defaults `read-only=YES`.
303
+ - Also run `node bin/gsd-t-env-registry.cjs ensure-gitignore .` so `.env` is gitignored (auto-add + report; secret-leak precheck).
304
+
299
305
  ## Step 8: Ensure README.md Exists
300
306
 
301
307
  If no `README.md` exists, create one with:
@@ -29,6 +29,13 @@ Scan this codebase and populate the GSD-T documentation. Analyze the actual code
29
29
  - List Credentials and Secrets from .env.example (local) and any secret manager configs
30
30
  - Document Deployment from CI/CD configs, Dockerfiles, cloud configs
31
31
  - Document Logging and Monitoring from any logging setup or dashboard configs
32
+ - **Back-fill the `## Environments` registry (M102 — brownfield capture).** For an existing project whose non-local environments are undocumented, back-fill the map-only Environments table WITHOUT ever reading or writing a secret VALUE:
33
+ 1. `node bin/gsd-t-env-registry.cjs detect .` → proposes the vault + fetch command from `.env.example` var NAMES, connection-string SHAPES, and `vercel.json`/`.vercel` (Vercel) · `cloudbuild.yaml`/`.gcloudignore` (Google Secret Manager) · neon dep (Neon) · plain `.env` (local). Map only — never a value.
34
+ 2. Confirm/fill gaps with the human, then `node bin/gsd-t-env-registry.cjs record '<json>'` per environment (upsert by `(scope, kind)` — a re-provision replaces the stale row).
35
+ 3. `node bin/gsd-t-env-registry.cjs ensure-gitignore .` — auto-add `.env` to `.gitignore` if missing + report.
36
+ - **No-Fallback-Ever:** a genuinely undocumented environment reached for the first time HALTs → detect → ask → record → `add-permission` → proceed. NEVER guess a connection string, NEVER grep transcripts to rediscover.
37
+ - **Leak remediation (secret found in a URL/command):** before recording, run `node bin/gsd-t-env-registry.cjs propose-remediation . '<command>'`. If it reports `needed:true`, STOP and tell the user *what* leaked (`detectedSecret`), then ASK **rotate or move** (`choices.rotate` / `choices.move`) — each time, do not assume. On their answer: store the (rotated or existing) secret into the detected `vault` (directly where possible — e.g. `vercel env add`, write to `.env`; else instruct the user to create/rotate it), then record the row with the `rewrittenCommand` ($VAR form). NEVER commit the literal secret.
38
+ - **Local-literal switch (per project):** by default every row uses `$VAR` (strict). A project may opt in via `.gsd-t/env-registry-config.json` → `{"allowLocalLiteral": true}` to allow a LITERAL secret in `scope=local` rows only (testing convenience). `staging`/`prod` are ALWAYS strict. A relaxed local write returns a one-time "committed file → git history" warning — surface it.
32
39
 
33
40
  ## Graph Structural Slice — who-imports + cluster (M94-D10)
34
41
 
@@ -25,8 +25,8 @@ Spawn path is `autoSpawnHeadless({command, spawnType, projectDir, sessionContext
25
25
 
26
26
  Per `.gsd-t/contracts/model-selection-contract.md` v1.0.0.
27
27
 
28
- - **Default**: `sonnet` (`selectModel({phase: "quick"})`) — routine one-off task.
29
- - **Mechanical subroutines** (demote to `haiku`): test runners (`selectModel({phase: "quick", task_type: "test_runner"})`).
28
+ - **Default**: `sonnet` (per `bin/gsd-t-model-tier-policy.cjs`, phase `quick`) — routine one-off task.
29
+ - **Mechanical subroutines** (demote to `haiku`): test runners.
30
30
  - **Red Team (Step 5.5)**: `opus` — adversarial reasoning always runs at top tier.
31
31
  - **Escalation**: `/advisor` convention-based fallback from `bin/advisor-integration.js` at declared high-stakes sub-decisions (see `.gsd-t/M35-advisor-findings.md`). Never silently downgrade the model or skip Red Team / doc-ripple under context pressure — M35 removed that behavior.
32
32
 
@@ -6,7 +6,7 @@ You are maintaining test coverage as code changes. Your job is to identify stale
6
6
 
7
7
  Per `.gsd-t/contracts/model-selection-contract.md` v1.0.0.
8
8
 
9
- - **Default**: `sonnet` (`selectModel({phase: "test-sync"})`) — test alignment is routine refactoring work.
9
+ - **Default**: `sonnet` (per `bin/gsd-t-model-tier-policy.cjs`, phase `test-sync`) — test alignment is routine refactoring work.
10
10
  - **Escalation**: `/advisor` convention-based fallback from `bin/advisor-integration.js` when a test touches a contract boundary or requires judgment about what "missing coverage" means. Never silently skip test-sync under context pressure — M35 removed that behavior.
11
11
 
12
12
  This command is:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.1.13",
3
+ "version": "5.2.11",
4
4
  "description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
5
5
  "author": "Tekyz, Inc.",
6
6
  "license": "MIT",
@@ -96,9 +96,22 @@ NEED TO UNDERSTAND SOMETHING?
96
96
  ├── Is it about what to build? → Read docs/requirements.md
97
97
  ├── Is it about how to deploy/operate? → Read docs/infrastructure.md
98
98
  ├── Is it about domain interfaces? → Read .gsd-t/contracts/
99
+ ├── Is it about reaching a non-local environment (DB/server URL, creds, perms)? → Read the `## Environments` registry in docs/infrastructure.md
99
100
  └── Not documented? → Research, then DOCUMENT IT
100
101
  ```
101
102
 
103
+ ## Environment Access — read-first, HALT-and-document (M102)
104
+
105
+ **Before reaching ANY non-local environment (a staging/prod DB, a remote server, an external service), READ the `## Environments` registry in `docs/infrastructure.md` FIRST.** It is the committed, **secret-free** map of every environment — host, port, db, auth method, which vault holds the secret, the env-var NAME, and the connect command (secrets referenced as `$VAR`, never as a literal value).
106
+
107
+ - **On a HIT** — use the recorded connect command; the secret is pulled from its vault at runtime via the env-var NAME.
108
+ - **On a MISS (no row) — HALT and document. NEVER guess a connection string, NEVER grep transcripts to rediscover it** (No-Fallback-Ever). Then: `detect` the env → ask the human to confirm/fill → `record` the map row → `add-permission` (broad-glob) → proceed. The registry self-heals staleness because a re-provision upserts by `(scope, kind)`.
109
+ - **Record-at-create (greenfield):** whenever GSD-T BUILDS/PROVISIONS an environment, record its map row in the SAME pass (it has the URL + creds right then).
110
+ - **Never write a secret VALUE into the registry** — only the vault name + env-var NAME + a `$VAR`-referencing command. A literal secret is rejected by `recordEnvironment` and by the `gsd-t-verify` env-registry gate. If a secret is found in a URL/command, the capture flow OFFERS to move it into the vault (rotate-or-move, user's choice) and replace it with a `$VAR`.
111
+ - **Local-literal switch:** a project may opt in via `.gsd-t/env-registry-config.json` `{"allowLocalLiteral": true}` to allow a literal secret in `scope=local` rows only; `staging`/`prod` stay strict.
112
+
113
+ Details: `.gsd-t/contracts/env-registry-contract.md`.
114
+
102
115
 
103
116
 
104
117
  # Versioning
@@ -23,7 +23,7 @@ Contract-driven development methodology for Claude Code. npm package providing s
23
23
 
24
24
  ```
25
25
  bin/ — CLI entry (gsd-t.js) + orchestrators (orchestrator.js, design-orchestrator.js)
26
- + support modules (gsd-t-context-brief.cjs, cli-preflight.cjs, gsd-t-verify-gate.cjs, model-selector.js, …)
26
+ + support modules (gsd-t-context-brief.cjs, cli-preflight.cjs, gsd-t-verify-gate.cjs, gsd-t-model-tier-policy.cjs, …)
27
27
  commands/ — slash commands for Claude Code (GSD-T workflow + utility)
28
28
  templates/ — document + prompt + stack templates
29
29
  CLAUDE-{global,project}.md, requirements.md, architecture.md, workflows.md,
@@ -68,6 +68,46 @@ cp .env.example .env
68
68
  {command}
69
69
  ```
70
70
 
71
+ <!-- gsd-t-env-registry:start -->
72
+ ## Environments
73
+
74
+ > **Map, not secrets.** This table records only WHERE an environment lives and
75
+ > HOW to reach it — host/port/name/auth-method/which-vault-holds-the-secret/the
76
+ > env-var NAME/the fetch + connect commands. It NEVER stores a secret VALUE.
77
+ > The value lives in the vault (`.env` / Vercel / Neon / Google Secret Manager)
78
+ > and is pulled at runtime via the env-var NAME. A missing row → HALT and
79
+ > document (detect → ask → record → proceed); never guess a connection string,
80
+ > never grep transcripts to rediscover.
81
+
82
+ | id | scope | kind | host | port | db/name | auth method | secret vault | secret env-var NAME | fetch command | connect command | access gotchas | read-only default | recorded |
83
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
84
+ <!-- gsd-t-env-registry:end -->
85
+
86
+ <!--
87
+ Column notes:
88
+ scope = local | staging | prod
89
+ kind = postgres | mysql | redis | http-api | ssh-host | ...
90
+ secret vault = local (.env) | Vercel | Neon | Google Secret Manager | ...
91
+ secret env-var NAME = e.g. DATABASE_URL_PROD — the NAME only, NEVER the value
92
+ host = POSITIVE shape: localhost | IPv4 | dotted DNS hostname |
93
+ short lowercase service name (letters+hyphen, no digits, e.g. db, postgres)
94
+ db/name = short lowercase snake identifier (<=16, e.g. binvoice_prod, analytics)
95
+ auth method = ENUMERATED — password | iam | oauth | oauth2 | service-account |
96
+ ssh-key | api-key | none | scram-sha-256 | md5 | trust | token | key | ...
97
+ fetch command = how to pull the secret from its vault (e.g. `vercel env pull`)
98
+ POSITIVE allowlist: a token is accepted ONLY if it IS a $VAR/${VAR} ref,
99
+ a flag, a hostname/IP, a .env dotfile, or a curated CLI/db word.
100
+ Any OTHER bare literal is REJECTED (move it to an env var, reference as $VAR).
101
+ connect command = references the env-var by name: `psql "$DATABASE_URL_PROD"` (same allowlist)
102
+ access gotchas = ENUMERATED — vpn | ip-allowlist | ssh-tunnel | bastion | none,
103
+ optionally `via <hostname>` (e.g. `ssh-tunnel via bastion.example.com`).
104
+ Free prose is FORBIDDEN (nowhere for a secret to hide).
105
+ read-only default = YES for scope=prod unless a human explicitly recorded write-ok
106
+ There is deliberately NO secret-value column — a row is structurally incapable
107
+ of holding a secret. Populated by `gsd-t-env-registry` (record-at-create +
108
+ capture-on-first-need).
109
+ -->
110
+
71
111
  ## Credentials & Secrets
72
112
 
73
113
  ### Local (.env)
@@ -1,234 +0,0 @@
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
- // M85: FABLE tier added alongside HAIKU/SONNET/OPUS.
20
- // Contract: .gsd-t/contracts/model-tier-policy-contract.md v1.0.0 § "Stage Policy"
21
-
22
- const TIERS = Object.freeze({
23
- HAIKU: "haiku",
24
- SONNET: "sonnet",
25
- OPUS: "opus",
26
- FABLE: "fable",
27
- });
28
-
29
- const DEFAULT_TIER = TIERS.SONNET;
30
-
31
- // ── Escalation hook block (convention-based /advisor fallback) ──────────────
32
- //
33
- // Per `.gsd-t/M35-advisor-findings.md`, Claude Code's native /advisor has no
34
- // programmable API at subagent scope. This block is injected into the subagent
35
- // prompt at declared escalation points on sonnet-tier phases where the
36
- // orchestrator has flagged a high-stakes sub-decision.
37
- //
38
- // Kept as a constant so all consumers (command files, advisor-integration.js,
39
- // M35-advisor-findings.md) reference the same canonical text.
40
-
41
- const ESCALATION_HOOK = [
42
- "## Escalation Hook — /advisor convention-based fallback",
43
- "",
44
- "Before finalizing your answer for this phase, stop and consider:",
45
- "1. Is this decision high-stakes? (architecture, contract design, security boundary,",
46
- " data-loss risk, cross-module refactor, adversarial QA verdict)",
47
- "2. Would a more capable model produce a materially better answer?",
48
- "3. Are you confident in the assumptions you're making?",
49
- "",
50
- "If YES to any of the above, do ONE of the following:",
51
- "- Escalate internally: spend an extra reasoning pass re-examining the decision",
52
- " from first principles. Document the re-examination in your output.",
53
- "- Spawn a nested opus subagent: use the Task tool with",
54
- " `subagent_type: \"general-purpose\"` and include `model: opus` in the spawn.",
55
- "",
56
- "Record in your output whether you escalated: set `ESCALATED_VIA_ADVISOR=true` or",
57
- "`ESCALATED_VIA_ADVISOR=false` on a line by itself near the end of your report.",
58
- ].join("\n");
59
-
60
- // ── Declarative phase rules table ───────────────────────────────────────────
61
- //
62
- // Each rule maps (phase, task_type) → tier. The first matching rule wins.
63
- // `task_type` is optional — when absent, the rule matches any task within
64
- // that phase. Order the rules from most-specific to least-specific.
65
- //
66
- // Tier assignments mirror `.gsd-t/M35-definition.md` Part B and the Model
67
- // Assignments section of the GSD-T global CLAUDE template:
68
- // - haiku: strictly mechanical — test runners, branch guards, file checks,
69
- // JSON validation, no judgment
70
- // - sonnet: routine code work — execute step 2, test-sync, doc-ripple wiring,
71
- // quick fixes, integration wiring, debug fix-apply
72
- // - opus: high-stakes reasoning — partition, discuss, Red Team, verify
73
- // judgment, debug root-cause, contract/architecture design
74
-
75
- const PHASE_RULES = Object.freeze([
76
- // Phase: execute
77
- { phase: "execute", task_type: "test_runner", model: TIERS.HAIKU, reason: "Mechanical test-suite runner — zero judgment" },
78
- { phase: "execute", task_type: "branch_guard", model: TIERS.HAIKU, reason: "Mechanical branch-name check — zero judgment" },
79
- { phase: "execute", task_type: "file_check", model: TIERS.HAIKU, reason: "Mechanical file-existence check — zero judgment" },
80
- { phase: "execute", task_type: "qa", model: TIERS.SONNET, reason: "QA evaluation needs judgment per M31 tier refinement" },
81
- { phase: "execute", task_type: "red_team", model: TIERS.OPUS, reason: "Adversarial QA benefits most from top tier" },
82
- { phase: "execute", model: TIERS.SONNET, reason: "Routine task execution — sonnet is the M35 default for routine work", hasEscalation: true },
83
-
84
- // Phase: wave (the wave orchestrator itself)
85
- { phase: "wave", model: TIERS.SONNET, reason: "Wave orchestration dispatches per-phase subagents; the orchestrator itself is routine coordination", hasEscalation: true },
86
-
87
- // Phase: quick
88
- { phase: "quick", task_type: "test_runner", model: TIERS.HAIKU, reason: "Mechanical test-suite runner — zero judgment" },
89
- { phase: "quick", model: TIERS.SONNET, reason: "Routine one-off task — sonnet default" },
90
-
91
- // Phase: integrate
92
- { phase: "integrate", task_type: "test_runner", model: TIERS.HAIKU, reason: "Mechanical integration test runner — zero judgment" },
93
- { phase: "integrate", model: TIERS.SONNET, reason: "Integration wiring is routine coordination work" },
94
-
95
- // Phase: debug
96
- { phase: "debug", task_type: "fix_apply", model: TIERS.SONNET, reason: "Applying a known fix is routine code work" },
97
- { phase: "debug", task_type: "root_cause", model: TIERS.OPUS, reason: "Root-cause analysis is high-stakes reasoning" },
98
- // M85: cycle-2 escalation — when debug cycle-1 (opus) has not resolved the issue,
99
- // cycle-2 escalates to Fable. The debug DEFAULT (cycle-1/general) remains opus —
100
- // no existing rule is altered (AC f, no silent degradation). This is a DOCUMENTED
101
- // MIRROR for Task-based/bin/ callers; the live enforcement is in the debug workflow
102
- // ternary (D3-T3); the D4 lint guards that ternary.
103
- // API shape: selectModel({ phase: "debug", task_type: "cycle_2_escalation" }) → fable
104
- { phase: "debug", task_type: "cycle_2_escalation", model: TIERS.FABLE, reason: "Cycle-2 debug escalation — Fable after opus cycle-1 has not resolved; no existing rule altered (AC f)" },
105
- { phase: "debug", model: TIERS.OPUS, reason: "Debug default is high-stakes — prefer opus unless the task_type says otherwise" },
106
-
107
- // Phase: partition — high-stakes architectural decomposition
108
- { phase: "partition", model: TIERS.OPUS, reason: "Domain partitioning is architectural reasoning — high stakes" },
109
-
110
- // Phase: discuss — multi-perspective design exploration
111
- { phase: "discuss", model: TIERS.OPUS, reason: "Design exploration benefits from top-tier reasoning" },
112
-
113
- // Phase: plan — task-list authoring
114
- { phase: "plan", model: TIERS.SONNET, reason: "Task decomposition is structured work — sonnet with escalation hook", hasEscalation: true },
115
-
116
- // Phase: verify — final quality judgment before milestone complete
117
- { phase: "verify", model: TIERS.OPUS, reason: "Milestone verification is the final quality gate — high stakes" },
118
-
119
- // Phase: test-sync — keeping tests aligned with code
120
- { phase: "test-sync", model: TIERS.SONNET, reason: "Test alignment is routine refactoring work" },
121
-
122
- // Phase: doc-ripple — downstream document updates
123
- { phase: "doc-ripple", model: TIERS.SONNET, reason: "Documentation updates are routine prose editing" },
124
-
125
- // Phase: red_team — explicit adversarial QA phase (separate from execute task_type)
126
- { phase: "red_team", model: TIERS.OPUS, reason: "Adversarial QA — always opus, the incentive is to find bugs" },
127
-
128
- // Phase: qa — explicit standalone QA phase
129
- { phase: "qa", model: TIERS.SONNET, reason: "QA per M31 refinement — sonnet produces fewer false negatives than haiku" },
130
- ]);
131
-
132
- // Complexity-signal overrides. If the caller provides `complexity_signals`,
133
- // these can bump a sonnet decision to opus regardless of phase rule.
134
- const COMPLEXITY_OVERRIDES = Object.freeze({
135
- cross_module_refactor: TIERS.OPUS,
136
- security_boundary: TIERS.OPUS,
137
- data_loss_risk: TIERS.OPUS,
138
- contract_design: TIERS.OPUS,
139
- });
140
-
141
- // ── Public API ──────────────────────────────────────────────────────────────
142
-
143
- /**
144
- * Select the model tier for a subagent spawn.
145
- *
146
- * @param {object} args
147
- * @param {string} args.phase — required; one of the phase names above
148
- * @param {string} [args.task_type] — optional task_type for finer-grained rules
149
- * @param {string} [args.domain_type] — optional, currently unused (reserved for future per-domain rules)
150
- * @param {object} [args.complexity_signals] — optional object, keys matching COMPLEXITY_OVERRIDES escalate sonnet→opus
151
- * @returns {{model: string, reason: string, escalation_hook: string|null}}
152
- */
153
- function selectModel(args) {
154
- if (!args || typeof args !== "object") {
155
- return {
156
- model: DEFAULT_TIER,
157
- reason: "No args provided — default to routine tier (sonnet)",
158
- escalation_hook: null,
159
- };
160
- }
161
-
162
- const { phase, task_type, complexity_signals } = args;
163
-
164
- if (!phase || typeof phase !== "string") {
165
- return {
166
- model: DEFAULT_TIER,
167
- reason: "No phase provided — default to routine tier (sonnet)",
168
- escalation_hook: null,
169
- };
170
- }
171
-
172
- // First pass: find the most-specific matching rule.
173
- let matched = null;
174
- for (const rule of PHASE_RULES) {
175
- if (rule.phase !== phase) continue;
176
- if (rule.task_type && rule.task_type !== task_type) continue;
177
- matched = rule;
178
- break;
179
- }
180
-
181
- if (!matched) {
182
- return {
183
- model: DEFAULT_TIER,
184
- reason: `Unknown phase "${phase}" — fallback to routine tier (sonnet)`,
185
- escalation_hook: null,
186
- };
187
- }
188
-
189
- let model = matched.model;
190
- let reason = matched.reason;
191
-
192
- // Complexity-signal overrides: bump sonnet → opus if any flagged signal is truthy.
193
- if (model === TIERS.SONNET && complexity_signals && typeof complexity_signals === "object") {
194
- for (const key of Object.keys(complexity_signals)) {
195
- if (!complexity_signals[key]) continue;
196
- const override = COMPLEXITY_OVERRIDES[key];
197
- if (override && override !== model) {
198
- model = override;
199
- reason = `${reason} (escalated to ${override} by complexity signal: ${key})`;
200
- break;
201
- }
202
- }
203
- }
204
-
205
- // Escalation hook is only injected on sonnet phases flagged as high-stakes-adjacent.
206
- // Haiku phases have no hook (mechanical, no judgment). Opus phases have no hook
207
- // (already at top tier — nowhere to escalate).
208
- let escalation_hook = null;
209
- if (model === TIERS.SONNET && matched.hasEscalation) {
210
- escalation_hook = ESCALATION_HOOK;
211
- }
212
-
213
- return { model, reason, escalation_hook };
214
- }
215
-
216
- /**
217
- * Return the canonical list of phase names the selector knows about.
218
- * Used by tests and documentation tooling to assert coverage.
219
- */
220
- function listPhases() {
221
- const seen = new Set();
222
- for (const rule of PHASE_RULES) seen.add(rule.phase);
223
- return [...seen].sort();
224
- }
225
-
226
- module.exports = {
227
- selectModel,
228
- listPhases,
229
- TIERS,
230
- DEFAULT_TIER,
231
- ESCALATION_HOOK,
232
- PHASE_RULES,
233
- COMPLEXITY_OVERRIDES,
234
- };