@tekyzinc/gsd-t 5.2.11 → 5.4.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 CHANGED
@@ -2,6 +2,49 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.4.10] - 2026-07-27
6
+
7
+ ### Added — Integer-identity primary keys + case-insensitive domain comparisons (two framework-wide code standards)
8
+
9
+ Two mandatory standards, both previously absent from GSD-T. **(1) Every NEW relational table gets a self-incrementing integer primary key named `id`**; API-exposed tables also get a separate UNIQUE `public_id` UUID. The integer wins inside the database (8 bytes vs 16, insert-ordered so the index stays compact, fast joins, readable in logs); the UUID protects the edge (a sequential id in a URL leaks row counts and lets anyone enumerate `/invoice/1`, `/invoice/2`). `postgresql.md` previously said the opposite — "UUID preferred over serial" — with a UUID GOOD example agents were copying. **(2) Comparing a domain string value against a literal is case-insensitive by default** (status, filter, tab, role, category, email, user-entered text), while identifiers, secrets, hashes, encoded values, Linux file paths, object keys, and env-var names stay case-sensitive — blanket insensitivity on a token or hash is a security defect. Origin: a real shipped bug where `filter !== 'Invoiced'` never matched a lowercase `'invoiced'`, producing two visible UI defects from one casing mismatch.
10
+
11
+ **Scope — NEW tables only.** A survey of all 32 registered projects found ~480 existing tables on UUID/cuid primary keys whose foreign keys already point at those UUIDs; changing an established primary key is a data migration and a Destructive Action Guard item, not an in-passing edit. No distributed-writes escape hatch was added — the same survey found zero multi-node or offline-first writers, so an unused exception path would be a speculative branch.
12
+
13
+ **Firestore and Neo4j are exempt, and the exemption is stated rather than silent.** Firestore has no sequence to increment — forcing one means a counter document that serializes every write in the collection, re-introducing the bottleneck the database exists to avoid. Neo4j has no rows, and its internal `id()` is reused after node deletion. Leaving those files silent would let an agent read the gap as an oversight and invent the counter-document anti-pattern.
14
+
15
+ - `bin/gsd-t-schema-id-check.cjs`: NEW deterministic verify gate (`schema-id`), FAIL-CLOSED. Inspects only schema files new or modified in the working tree, so established UUID-keyed repos pass untouched. Explicit no-op PASS (distinguishable from a wired-but-broken FAIL) when git is unavailable or no schema files changed.
16
+ - `bin/gsd-t-verify-gate.cjs`: wired `schema-id` into the Track 2 default plan.
17
+ - `templates/stacks/_comparison.md`: NEW universal rules file (`_` prefix → auto-injected into every project regardless of stack). Case-insensitive/case-sensitive split, SQL guidance with functional-index note, shared-constant rule, verification checklist.
18
+ - `templates/stacks/postgresql.md`: §1 naming + §2 schema design rewritten; GOOD/BAD examples and checklist updated; explicit scope note on non-retrofit.
19
+ - `templates/stacks/prisma.md`: `id Int @id @default(autoincrement())` mandatory + `publicId`; GOOD/BAD examples inverted (the old BAD example was the new rule).
20
+ - `templates/stacks/supabase.md`: NEW §6.5 — including that a `user_id` column referencing `auth.users` stays UUID because row-level security compares it against `auth.uid()`.
21
+ - `templates/stacks/firebase.md`, `templates/stacks/neo4j.md`: stated exemptions with the counter-document anti-pattern shown as BAD.
22
+ - `templates/stacks/typescript.md`: §6 extended — a fixed value set lives in ONE shared constant so the compiler catches mismatches (case-insensitivity only treats the symptom; a typo'd value still fails silently).
23
+ - `templates/CLAUDE-global.md` + live `~/.claude/CLAUDE.md`: both rules added and kept in sync, so they govern conversational work as well as spawned domain workers.
24
+ - `test/schema-id-check.test.js`: NEW — 11 regression tests covering all three Postgres identity spellings, UUID/text/composite rejection, nested-paren bodies, Prisma models, and the new-tables-only scope.
25
+
26
+ Migration: none required. Existing schemas are unaffected — the gate ignores committed tables, and no existing project is retrofitted.
27
+
28
+ ## [5.3.10] - 2026-07-24
29
+
30
+ ### Changed — Fable 5 removed; Opus tier upgraded to Opus 5 (claude-opus-5)
31
+
32
+ The 4-tier model map (haiku/sonnet/opus/fable) collapses to **3 tiers (haiku/sonnet/opus)** where `opus` = **`claude-opus-5`**. Opus 5 shipped 2026-07-24 at the SAME price as Opus 4.8 ($5/$25 per M tokens) but >2× its coding score (Frontier-Bench) and within 0.5% of Fable 5 at max effort — so Fable's cost premium ($10/$50, double Opus 5) is no longer justified. **Every stage formerly on Fable OR Opus 4.8 now runs Opus 5.** Opus 5's context window is 1M tokens (same as 4.8 — no large-context regression), knowledge cutoff May 2026.
33
+
34
+ The M82 competition judge-blindness invariant is **RELAXED** from "judge model ≠ producers model" to "judge runs in a fresh independent context" — producers AND judge both run Opus 5 (fresh contexts remove memory-bias; the modest residual taste/blind-spot bias is accepted for a stronger judge). The judge≠producers clamp is removed from the tier policy, the profile resolver, and the drift lint.
35
+
36
+ - `bin/gsd-t-model-tier-policy.cjs`: MODEL_IDS `opus`→`claude-opus-5`, `fable` DELETED; all 7 STAGE_TIERS → opus; `requiresThinkingOmitted` now a single-home no-op (no model requires omission post-Fable); judge blindness clamp removed.
37
+ - `bin/gsd-t-model-profile.cjs`: 3 profiles remapped to dial OPUS-vs-SONNET (standard=probes-opus/rest-sonnet, pro=mid, premium=all-opus); blindness clamp removed.
38
+ - `bin/gsd-t-parallel.cjs`: fable alias removed.
39
+ - `templates/workflows/*.workflow.js` (5): every `model: "fable"` / `?? "fable"` → `"opus"`.
40
+ - `templates/prompts/{blind-adversary,research}-subagent.md`: model annotation fable → opus.
41
+ - Drift-enforcer lints (`test/m85-*`, `test/m86-*`, `test/m90-tier-policy-lint`) updated atomically with the policy — fable fixtures/expectations → opus/sonnet; the "cycles must differ" + "judge≠producers" assertions removed.
42
+ - Contracts: `model-tier-policy-contract.md` v2.0.0 (BREAKING), `model-profile-config-contract.md` v1.1.0 — each with a Fable-removed banner.
43
+ - `templates/CLAUDE-global.md` + live `~/.claude/CLAUDE.md`: Model Display doctrine updated.
44
+ - Command files (`gsd-t-help/status/debug/partition`), README, GSD-T-README updated.
45
+
46
+ Full suite: 3019/3019 pass, 13 skipped. Zero executable `fable` literal remains in bin/workflows/lints; producers AND judge both resolve to `claude-opus-5`; all 3 profiles resolve with no fable.
47
+
5
48
  ## [5.2.11] - 2026-07-22
6
49
 
7
50
  ### Changed — architect-overview cleanup (dead tool, shared reader, archived contracts)
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.2.11** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.4.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** — models are assigned haiku/sonnet/opus/fable per phase. **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).
21
+ **Surgical model selection** — models are assigned haiku/sonnet/opus per phase (**Fable removed 2026-07-24**; `opus` = **claude-opus-5**). **Single-source tier policy:** `bin/gsd-t-model-tier-policy.cjs` is the SINGLE source of truth; every high-stakes stage (solution-space probe, partition probe, competition judge, pre-mortem, Red Team, competition producers, debug both cycles) runs Opus 5. Opus 5 shipped at the same price as Opus 4.8 but >2× its coding score, so Fable's cost premium is no longer justified. The M82 judge-blindness invariant is relaxed to "fresh independent context" — producers and judge both run opus. 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 dial which stages run on Opus vs. Sonnet.
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.
@@ -51,7 +51,7 @@ const GLOBAL_DEFAULT_PROFILE = 'premium';
51
51
  const VALID_PROFILES = Object.keys(PROFILE_STAGE_TIERS); // ['standard','pro','premium']
52
52
 
53
53
  /** Valid tier names. */
54
- const VALID_TIERS = Object.keys(MODEL_IDS); // ['opus','fable','sonnet','haiku']
54
+ const VALID_TIERS = Object.keys(MODEL_IDS); // ['opus','sonnet','haiku'] (Fable removed 2026-07-24)
55
55
 
56
56
  /**
57
57
  * Stages that CANNOT be overridden (M82 blindness invariant).
@@ -216,12 +216,10 @@ function validateSetStage(stageKey, tier) {
216
216
  };
217
217
  }
218
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
- }
219
+ // Blindness clamp REMOVED 2026-07-24: the M82 invariant relaxed from "judge
220
+ // model ≠ producers model" to "judge runs in a fresh independent context", so
221
+ // competition-judge = opus (= producers' model) is now ALLOWED. Any valid tier
222
+ // is accepted for the judge.
225
223
 
226
224
  return { ok: true };
227
225
  }
@@ -281,14 +279,9 @@ function buildResolveEnvelope(profile, stageOverrides, specificStage, configErro
281
279
  if (r.configError && !envelopeConfigError) envelopeConfigError = r.configError;
282
280
  }
283
281
 
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
- }
282
+ // Blindness clamp REMOVED 2026-07-24: competition-judge = opus (= producers'
283
+ // model) is now permitted (invariant relaxed to "fresh independent context").
284
+ // No rewrite of the judge model.
292
285
 
293
286
  const result = {
294
287
  ok: true,
@@ -18,11 +18,16 @@
18
18
  * Frozen map: tier alias → concrete model id.
19
19
  * Consumers MUST import from here — never re-hardcode these strings.
20
20
  *
21
- * @type {Readonly<{opus: string, fable: string, sonnet: string, haiku: string}>}
21
+ * THREE tiers (Fable removed 2026-07-24): `opus` is now `claude-opus-5` — the
22
+ * default top tier. Opus 5 shipped at the SAME price as Opus 4.8 ($5/$25 per M
23
+ * tokens) but >2× its coding score and within 0.5% of Fable 5 at max effort, so
24
+ * the Fable cost premium ($10/$50 — double Opus 5) is no longer justified. Every
25
+ * stage formerly on `fable` OR `opus` (4.8) now runs `opus` = claude-opus-5.
26
+ *
27
+ * @type {Readonly<{opus: string, sonnet: string, haiku: string}>}
22
28
  */
23
29
  const MODEL_IDS = Object.freeze({
24
- opus: 'claude-opus-4-8',
25
- fable: 'claude-fable-5',
30
+ opus: 'claude-opus-5',
26
31
  sonnet: 'claude-sonnet-4-6',
27
32
  haiku: 'claude-haiku-4-5-20251001',
28
33
  });
@@ -33,18 +38,23 @@ const MODEL_IDS = Object.freeze({
33
38
 
34
39
  /**
35
40
  * Frozen map: stage key → tier alias.
36
- * 6 stages fable; competition-producers held at opus (M82 blindness invariant).
41
+ * Fable removed 2026-07-24: all 7 stages resolve to `opus` (= claude-opus-5).
42
+ * The M82 competition judge-blindness invariant is RELAXED from "different model"
43
+ * to "fresh independent context" — producers AND judge both run Opus 5 (fresh
44
+ * contexts remove memory-bias; the modest residual taste/blind-spot bias is
45
+ * accepted for a stronger judge — user-locked 2026-07-24). See
46
+ * competition-mode-contract.md §Different-context judge.
37
47
  *
38
48
  * @type {Readonly<Record<string, string>>}
39
49
  */
40
50
  const STAGE_TIERS = Object.freeze({
41
- 'solution-space-probe': 'fable',
42
- 'partition-probe': 'fable',
43
- 'competition-judge': 'fable',
44
- 'competition-producers': 'opus', // HELD — M82 judge-blindness invariant; do NOT move to fable
45
- 'pre-mortem': 'fable',
46
- 'red-team': 'fable',
47
- 'debug-cycle-2': 'fable',
51
+ 'solution-space-probe': 'opus',
52
+ 'partition-probe': 'opus',
53
+ 'competition-judge': 'opus', // was fable; blindness now via fresh context, not different model
54
+ 'competition-producers': 'opus',
55
+ 'pre-mortem': 'opus',
56
+ 'red-team': 'opus',
57
+ 'debug-cycle-2': 'opus',
48
58
  });
49
59
 
50
60
  // ---------------------------------------------------------------------------
@@ -55,18 +65,22 @@ const STAGE_TIERS = Object.freeze({
55
65
  * Returns true IFF the model requires the explicit thinking-disabled parameter
56
66
  * to be OMITTED from the API call.
57
67
  *
58
- * Rationale (canonical, single home): `claude-fable-5` returns HTTP 400 when
59
- * the explicit thinking-disabled parameter is sent. The parameter must therefore
60
- * be OMITTED for Fable. No other file may re-implement or re-state this predicate.
68
+ * This predicate existed for `claude-fable-5`, which returned HTTP 400 when the
69
+ * explicit thinking-disabled parameter was sent. Fable was removed 2026-07-24;
70
+ * NO current tier model (opus=claude-opus-5, sonnet, haiku) is known to require
71
+ * omission — Opus 5 and Sonnet 5 default `effort:high` on the API and accept the
72
+ * thinking params normally. Kept as a single-home predicate (callers still import
73
+ * it) so a future model that needs omission is added HERE, never re-hardcoded.
61
74
  *
62
75
  * @param {string} model — concrete model id or tier alias or any string
63
76
  * @returns {boolean}
64
77
  */
78
+ const MODELS_REQUIRING_THINKING_OMITTED = Object.freeze([]); // none post-Fable
65
79
  function requiresThinkingOmitted(model) {
66
80
  if (typeof model !== 'string') return false;
67
- // Source the id from MODEL_IDS (single-source — no second literal), and accept
68
- // the runtime's bracket-suffixed display form (e.g. "claude-fable-5[1m]").
69
- return model === MODEL_IDS.fable || model.startsWith(MODEL_IDS.fable + '[');
81
+ return MODELS_REQUIRING_THINKING_OMITTED.some(
82
+ (id) => model === id || model.startsWith(id + '[')
83
+ );
70
84
  }
71
85
 
72
86
  // ---------------------------------------------------------------------------
@@ -98,16 +112,15 @@ function resolve(stageKey) {
98
112
  /**
99
113
  * Frozen profile → stage-key → tier map.
100
114
  *
101
- * Three named profiles:
102
- * standard — ZERO fable (pre-M85 tiers: probes→opus, judge→sonnet,
103
- * pre-mortem→opus, red-team→opus, debug both cycles→opus).
104
- * pro — red-team + pre-mortem + debug-cycle-2 → fable; everything
105
- * else reverts to standard.
106
- * premium — all 6 M85 fable stages (the full M85 posture).
115
+ * Fable removed 2026-07-24 — profiles now dial OPUS-vs-SONNET spend (not Fable):
116
+ * standard — cost-leanest: the high-stakes reasoning stages run sonnet,
117
+ * only the probes stay opus.
118
+ * pro — mid: red-team + pre-mortem + debug-cycle-2 → opus; the rest sonnet.
119
+ * premium — full opus posture: all 6 designated stages → opus (= claude-opus-5).
107
120
  *
108
- * competition-producers is HELD at opus in ALL profiles (M82 blindness
109
- * invariant never fable). It is NOT included here because it is never
110
- * overridable; the resolver enforces this separately.
121
+ * competition-producers is held at opus in ALL profiles (always opus-5). The
122
+ * former judge≠producers blindness clamp is REMOVED the invariant is now
123
+ * "fresh independent context," so the judge may equal producers' model.
111
124
  *
112
125
  * @type {Readonly<Record<string, Readonly<Record<string, string>>>>}
113
126
  */
@@ -116,25 +129,25 @@ const PROFILE_STAGE_TIERS = Object.freeze({
116
129
  'solution-space-probe': 'opus',
117
130
  'partition-probe': 'opus',
118
131
  'competition-judge': 'sonnet',
119
- 'pre-mortem': 'opus',
120
- 'red-team': 'opus',
121
- 'debug-cycle-2': 'opus',
132
+ 'pre-mortem': 'sonnet',
133
+ 'red-team': 'sonnet',
134
+ 'debug-cycle-2': 'sonnet',
122
135
  }),
123
136
  pro: Object.freeze({
124
137
  'solution-space-probe': 'opus',
125
138
  'partition-probe': 'opus',
126
139
  'competition-judge': 'sonnet',
127
- 'pre-mortem': 'fable',
128
- 'red-team': 'fable',
129
- 'debug-cycle-2': 'fable',
140
+ 'pre-mortem': 'opus',
141
+ 'red-team': 'opus',
142
+ 'debug-cycle-2': 'opus',
130
143
  }),
131
144
  premium: Object.freeze({
132
- 'solution-space-probe': 'fable',
133
- 'partition-probe': 'fable',
134
- 'competition-judge': 'fable',
135
- 'pre-mortem': 'fable',
136
- 'red-team': 'fable',
137
- 'debug-cycle-2': 'fable',
145
+ 'solution-space-probe': 'opus',
146
+ 'partition-probe': 'opus',
147
+ 'competition-judge': 'opus',
148
+ 'pre-mortem': 'opus',
149
+ 'red-team': 'opus',
150
+ 'debug-cycle-2': 'opus',
138
151
  }),
139
152
  });
140
153
 
@@ -148,18 +161,19 @@ const INJECTABLE_STAGES = Object.freeze([
148
161
  'debug-cycle-2',
149
162
  ]);
150
163
 
151
- /** The HELD producers model id used by blindness clamps. */
152
- const PRODUCERS_MODEL_ID = MODEL_IDS.opus; // claude-opus-4-8
164
+ /** The HELD producers model id (always opus = claude-opus-5). */
165
+ const PRODUCERS_MODEL_ID = MODEL_IDS.opus; // claude-opus-5
153
166
 
154
167
  /**
155
168
  * Resolves the concrete model id for a given stage key under a profile,
156
169
  * honoring precedence: stageOverrides[stage] ?? profile-tier ?? global-default.
157
170
  *
158
- * Blindness clamps (M82 / pre-mortem c2 #4 enforced at RESOLVE, not only at
159
- * write time because the config file is hand-editable):
160
- * - competition-producers key in stageOverrides: silently dropped (never in overrides map).
161
- * - competition-judge resolved to the producers' model id: BLOCKEDdrops the override
162
- * and uses the profile tier for competition-judge instead.
171
+ * Blindness (M82, RELAXED 2026-07-24 to "fresh independent context"):
172
+ * - competition-producers key in stageOverrides: still silently dropped (producers
173
+ * are always opus not profile-overridable).
174
+ * - competition-judge may now equal the producers' model (both opus) — the old
175
+ * judge≠producers clamp is REMOVED; isolation is enforced by fresh context, not
176
+ * by a different model.
163
177
  *
164
178
  * @param {string} stageKey
165
179
  * @param {{ profile?: string, stageOverrides?: Record<string,string> }} opts
@@ -222,11 +236,10 @@ function resolveProfile(stageKey, opts) {
222
236
  // (Red Team M86 r2 LOW: unknown stage + invalid override resolved fable on standard).
223
237
  errors.push(`stageOverrides["${stageKey}"] has invalid tier "${rawOverrideTier}"; falling back to profile tier`);
224
238
  resolvedTier = stageKnown ? profileTierMap[stageKey] : 'sonnet';
225
- } else if (stageKey === 'competition-judge' && MODEL_IDS[rawOverrideTier] === PRODUCERS_MODEL_ID) {
226
- // Blindness clamp: competition-judge must not equal producers' model
227
- errors.push(`stageOverrides["competition-judge"] resolves to "${MODEL_IDS[rawOverrideTier]}" (=producers' model); blindness clamp rejected — falling back to profile tier`);
228
- resolvedTier = stageKnown ? profileTierMap[stageKey] : 'sonnet';
229
239
  } else {
240
+ // Blindness clamp REMOVED (2026-07-24): competition-judge may equal the
241
+ // producers' model — isolation is now enforced by fresh context, not a
242
+ // different model. Any valid tier override is honored.
230
243
  resolvedTier = rawOverrideTier;
231
244
  }
232
245
  } else {
@@ -610,10 +610,9 @@ function runDispatch(opts) {
610
610
  // is sufficient (single-source thesis, AC b).
611
611
  const modelAlias = {
612
612
  opus: MODEL_IDS.opus,
613
- fable: MODEL_IDS.fable,
614
613
  sonnet: MODEL_IDS.sonnet,
615
614
  haiku: MODEL_IDS.haiku,
616
- };
615
+ }; // fable removed 2026-07-24
617
616
  const callerModel = opts && opts.workerModel;
618
617
  const workerModel = callerModel === false
619
618
  ? null // explicit opt-out: inherit parent's ANTHROPIC_MODEL
@@ -0,0 +1,246 @@
1
+ "use strict";
2
+
3
+ // bin/gsd-t-schema-id-check.cjs
4
+ //
5
+ // The deterministic verify-gate lint for the integer-primary-key rule.
6
+ // FAIL-CLOSED for relational schemas; NO-OP PASS for projects with no schema.
7
+ //
8
+ // THE RULE (relational stacks only — postgresql / prisma / supabase / drizzle):
9
+ // every NEW table has a self-incrementing integer primary key named `id`.
10
+ //
11
+ // SCOPE — NEW tables only. A pre-existing table with a UUID primary key is NOT a
12
+ // failure: its foreign keys already point at that UUID, so changing the PK is a
13
+ // data migration + Destructive Action Guard item, never an in-passing edit. The
14
+ // gate therefore checks only tables whose defining file is UNCOMMITTED or was
15
+ // added in the working tree — i.e. tables this run is introducing. A repo with an
16
+ // established UUID-keyed schema passes untouched.
17
+ //
18
+ // NOT APPLICABLE — firestore/firebase (document store: no sequence to increment;
19
+ // a counter document serializes writes) and neo4j (no rows; internal id() is
20
+ // reused after deletion). Those stacks state the exemption in their own rules
21
+ // file; this gate never inspects them.
22
+ //
23
+ // Zero external npm runtime deps — fs/path/child_process only.
24
+
25
+ const fs = require("fs");
26
+ const path = require("path");
27
+ const { execFileSync } = require("child_process");
28
+
29
+ // ─── SQL: CREATE TABLE detection ─────────────────────────────────────────────
30
+
31
+ // A table body is scanned for a column that is BOTH an integer type AND carries
32
+ // an identity/serial default AND is the primary key. Postgres spells this three
33
+ // ways; all three are accepted.
34
+ const SQL_IDENTITY_PK =
35
+ /\bid\b[^,]*?\b(?:bigint|integer|int|smallint)\b[^,]*?\bgenerated\s+(?:always|by\s+default)\s+as\s+identity\b[^,]*?\bprimary\s+key\b/is;
36
+ const SQL_SERIAL_PK = /\bid\b\s+(?:big|small)?serial\b[^,]*?\bprimary\s+key\b/is;
37
+ // Table-level constraint form: id BIGINT GENERATED ... , PRIMARY KEY (id)
38
+ const SQL_IDENTITY_COL =
39
+ /\bid\b[^,]*?\b(?:bigint|integer|int|smallint)\b[^,]*?\b(?:generated\s+(?:always|by\s+default)\s+as\s+identity|serial)\b/is;
40
+ const SQL_TABLE_PK_ID = /\bprimary\s+key\s*\(\s*"?id"?\s*\)/is;
41
+
42
+ /**
43
+ * Extract (tableName, body) for each CREATE TABLE in a SQL string.
44
+ * Brace-matched on parentheses so nested type parens (numeric(10,2)) don't
45
+ * truncate the body.
46
+ */
47
+ function extractSqlTables(sql) {
48
+ const out = [];
49
+ const re = /create\s+table\s+(?:if\s+not\s+exists\s+)?([`"\[]?[\w.]+[`"\]]?)\s*\(/gi;
50
+ let m;
51
+ while ((m = re.exec(sql)) !== null) {
52
+ const name = m[1].replace(/[`"\[\]]/g, "");
53
+ let depth = 1;
54
+ let i = re.lastIndex;
55
+ while (i < sql.length && depth > 0) {
56
+ const ch = sql[i];
57
+ if (ch === "(") depth++;
58
+ else if (ch === ")") depth--;
59
+ i++;
60
+ }
61
+ out.push({ name, body: sql.slice(re.lastIndex, i - 1) });
62
+ }
63
+ return out;
64
+ }
65
+
66
+ function sqlTableHasIntegerIdPk(body) {
67
+ if (SQL_IDENTITY_PK.test(body)) return true;
68
+ if (SQL_SERIAL_PK.test(body)) return true;
69
+ if (SQL_IDENTITY_COL.test(body) && SQL_TABLE_PK_ID.test(body)) return true;
70
+ return false;
71
+ }
72
+
73
+ // ─── Prisma: model detection ─────────────────────────────────────────────────
74
+
75
+ const PRISMA_INT_ID = /\bid\s+Int\s+@id\s+@default\s*\(\s*autoincrement\s*\(\s*\)\s*\)/;
76
+ const PRISMA_BIGINT_ID = /\bid\s+BigInt\s+@id\s+@default\s*\(\s*autoincrement\s*\(\s*\)\s*\)/;
77
+
78
+ /** Extract (modelName, body) for each `model X { ... }` block. */
79
+ function extractPrismaModels(src) {
80
+ const out = [];
81
+ const re = /^\s*model\s+(\w+)\s*\{/gm;
82
+ let m;
83
+ while ((m = re.exec(src)) !== null) {
84
+ let depth = 1;
85
+ let i = re.lastIndex;
86
+ while (i < src.length && depth > 0) {
87
+ const ch = src[i];
88
+ if (ch === "{") depth++;
89
+ else if (ch === "}") depth--;
90
+ i++;
91
+ }
92
+ out.push({ name: m[1], body: src.slice(re.lastIndex, i - 1) });
93
+ }
94
+ return out;
95
+ }
96
+
97
+ function prismaModelHasIntegerIdPk(body) {
98
+ return PRISMA_INT_ID.test(body) || PRISMA_BIGINT_ID.test(body);
99
+ }
100
+
101
+ // A Prisma block that is only a relation join table declared with @@id([a, b])
102
+ // still needs its own integer id under the rule — no exemption. But a `view`
103
+ // or `enum` block is not a model and is never reached (regex matches `model`).
104
+
105
+ // ─── Which files are NEW in this working tree ────────────────────────────────
106
+
107
+ /**
108
+ * Files added/modified relative to HEAD, per git. The rule governs NEW tables,
109
+ * so only these files are inspected.
110
+ *
111
+ * Returns null when git is unavailable or the directory is not a repo — the
112
+ * caller turns that into a NO-OP PASS with an explicit note rather than
113
+ * silently scanning everything (which would fail every legacy schema).
114
+ */
115
+ function changedFiles(projectDir) {
116
+ try {
117
+ const out = execFileSync(
118
+ "git",
119
+ ["status", "--porcelain=v1", "--untracked-files=all"],
120
+ { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
121
+ );
122
+ const files = [];
123
+ for (const line of out.split("\n")) {
124
+ if (!line.trim()) continue;
125
+ // porcelain v1: XY <path> (rename: XY <old> -> <new>)
126
+ const p = line.slice(3).trim();
127
+ const finalPath = p.includes(" -> ") ? p.split(" -> ").pop() : p;
128
+ files.push(finalPath.replace(/^"|"$/g, ""));
129
+ }
130
+ return files;
131
+ } catch (_) {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ const SQL_EXT = /\.sql$/i;
137
+ const PRISMA_EXT = /\.prisma$/i;
138
+
139
+ function isSchemaFile(rel) {
140
+ if (/node_modules|\.git\//.test(rel)) return false;
141
+ return SQL_EXT.test(rel) || PRISMA_EXT.test(rel);
142
+ }
143
+
144
+ function readSafe(p) {
145
+ try {
146
+ return fs.readFileSync(p, "utf8");
147
+ } catch (_) {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ // ─── Main check ──────────────────────────────────────────────────────────────
153
+
154
+ function check(projectDir) {
155
+ const failures = [];
156
+ const inspected = [];
157
+
158
+ const changed = changedFiles(projectDir);
159
+ if (changed === null) {
160
+ return {
161
+ ok: true,
162
+ check: "schema-id",
163
+ inspected: [],
164
+ failures: [],
165
+ note:
166
+ "no-op PASS: git unavailable or not a repository — cannot distinguish NEW tables from " +
167
+ "pre-existing ones, and the rule governs NEW tables only",
168
+ };
169
+ }
170
+
171
+ const schemaFiles = changed.filter(isSchemaFile);
172
+ if (schemaFiles.length === 0) {
173
+ return {
174
+ ok: true,
175
+ check: "schema-id",
176
+ inspected: [],
177
+ failures: [],
178
+ note: "no-op PASS: no new or modified .sql/.prisma schema files in the working tree",
179
+ };
180
+ }
181
+
182
+ for (const rel of schemaFiles) {
183
+ const abs = path.join(projectDir, rel);
184
+ const src = readSafe(abs);
185
+ if (src === null) continue; // deleted file listed by git status
186
+
187
+ if (PRISMA_EXT.test(rel)) {
188
+ for (const model of extractPrismaModels(src)) {
189
+ inspected.push(`${rel}::model ${model.name}`);
190
+ if (!prismaModelHasIntegerIdPk(model.body)) {
191
+ failures.push(
192
+ `${rel}: model \`${model.name}\` has no self-incrementing integer primary key — ` +
193
+ `add \`id Int @id @default(autoincrement())\`. If this model is API-exposed, also add ` +
194
+ `\`publicId String @unique @default(uuid())\` and expose that instead of the integer id.`
195
+ );
196
+ }
197
+ }
198
+ } else {
199
+ for (const table of extractSqlTables(src)) {
200
+ inspected.push(`${rel}::table ${table.name}`);
201
+ if (!sqlTableHasIntegerIdPk(table.body)) {
202
+ failures.push(
203
+ `${rel}: table \`${table.name}\` has no self-incrementing integer primary key — ` +
204
+ `add \`id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY\`. If this table is ` +
205
+ `API-exposed, also add \`public_id UUID NOT NULL DEFAULT gen_random_uuid()\` with a ` +
206
+ `UNIQUE constraint and expose that instead of the integer id.`
207
+ );
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ return {
214
+ ok: failures.length === 0,
215
+ check: "schema-id",
216
+ inspected,
217
+ failures,
218
+ note:
219
+ inspected.length === 0
220
+ ? "no-op PASS: changed schema files contain no CREATE TABLE / model definitions"
221
+ : undefined,
222
+ };
223
+ }
224
+
225
+ function parseArgs(argv) {
226
+ const out = { projectDir: "." };
227
+ for (let i = 0; i < argv.length; i++) {
228
+ if (argv[i] === "--project") out.projectDir = argv[++i] || ".";
229
+ }
230
+ return out;
231
+ }
232
+
233
+ module.exports = {
234
+ check,
235
+ extractSqlTables,
236
+ sqlTableHasIntegerIdPk,
237
+ extractPrismaModels,
238
+ prismaModelHasIntegerIdPk,
239
+ };
240
+
241
+ if (require.main === module) {
242
+ const { projectDir } = parseArgs(process.argv.slice(2));
243
+ const result = check(projectDir);
244
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
245
+ process.exit(result.ok ? 0 : 1);
246
+ }
@@ -309,6 +309,8 @@ function _detectDefaultTrack2(projectDir, notes) {
309
309
 
310
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
311
 
312
+ plan.push({ id: 'schema-id', cmd: 'node', args: [path.join(__dirname, 'gsd-t-schema-id-check.cjs'), '--project', projectDir], timeoutMs: 30000 }); // integer-identity PK on every NEW relational table, FAIL-CLOSED (no-op PASS when no new schema files)
313
+
312
314
  // secrets — gitleaks (PATH detection deferred to runtime)
313
315
  if (_hasOnPath('gitleaks')) {
314
316
  plan.push({
@@ -62,7 +62,7 @@ Call the `Workflow` tool with:
62
62
  symptom: "describe the failing test or runtime error in one sentence",
63
63
  projectDir: ".",
64
64
  // M86: inject the resolved overrides map so the workflow's ?? form for debug-cycle-2
65
- // picks up the profile-tier assignment instead of the premium fable literal.
65
+ // picks up the profile-tier assignment instead of the premium opus literal.
66
66
  // Pass {} when the resolver failed AND you chose the loud-warning path (not halt).
67
67
  overrides: { /* ...from resolver result.overrides, or {} on failure */ }
68
68
  }