@tekyzinc/gsd-t 5.5.11 → 5.6.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,31 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.6.10] - 2026-08-02
6
+
7
+ ### Changed — the Environment Registry now fires (M103)
8
+
9
+ The registry shipped in M102 was never filled in — empty in 31 of 33 projects — so every session re-asked the human how to reach production. Four independent breaks, each verified:
10
+
11
+ 1. **Record-at-create was prose, not code.** `recordEnvironment` had zero production callers; the instruction to record a row lived only in a markdown command file. Provisioning also often has no discrete moment to fire on — one project's production database came up via a hosting-platform integration during a deploy.
12
+ 2. **Nothing backfilled** projects that predate M102.
13
+ 3. **The empty state was certified as fine.** The gate looked for the env-access rule in the *project's* `CLAUDE.md`, but that rule ships in the *global* one — so every project took the "has not adopted the registry" no-op PASS branch.
14
+ 4. **The nine-cycle secret grammar rejected TRUE values** (`n/a`, `cli-session`, lowercase `yes`, plain-English notes). The observed consequence was writing something *false* to get past it — a staging row recorded as production.
15
+
16
+ Changes:
17
+
18
+ - `bin/gsd-t-env-registry-check.cjs`: accept true values in columns that structurally cannot carry a secret (`n/a` in port / db-name / env-var-NAME; unenumerated sign-in methods in label shape; `yes`/`no` in any capitalisation; word-by-word prose in `access gotchas`). Password-carrying columns — host, db name, both commands — are untouched. A project with a remote environment and no prod/staging row now FAILS; a local-only project PASSES and is named `localOnly:true`. The M102 rule-vs-table condition is removed: once the rule is read from the global file it fired in every project (21 of 28, all correct local-only states) and added no signal the remote-environment check does not already carry.
19
+ - `bin/gsd-t-env-registry.cjs`: new `source` column. A vendor resource id is shape-identical to a token, so the row names where the value came from (`neonctl projects list`, `.vercel/project.json`) and the source vouches for it. Its presence is the flag — there is no per-vendor "source required" list to maintain, and therefore none to forget. A source never vouches past the backstop.
20
+ - `templates/infrastructure.md`, `templates/CLAUDE-global.md`: the 15-column schema, the relaxed column rules, and a write-the-row-before-you-use-the-answer rule.
21
+ - `.gsd-t/contracts/env-registry-contract.md`: v1.1.0 DRAFT → **v1.2.0 STABLE**, plus four `[RULE]`s — truth-accepted, source-vouches, empty-is-not-pass, local-only-named.
22
+ - `test/m102-env-registry.test.js`: 274 → 305 tests. Includes the leak found *during* this work — once `source` took the 15th slot the overflow-corruption guard no longer covered it, and a bare word passed an early "looks like a path" rule.
23
+
24
+ Two things were built and then reverted, both killed by evidence: a "recorded command must run as written" gate check (not a secret question, it failed provably-safe rows, and its rule only holds for a multi-project account — three existing tests caught it), and a rule that guessed resource ids by their spelling (replaced by the source column).
25
+
26
+ Measured across the project registry: **22 PASS / 6 FAIL, all six true findings** — five genuinely unmapped remote projects and one malformed row.
27
+
28
+ Suite 3083/0/13-skip.
29
+
5
30
  ## [5.5.11] - 2026-07-29
6
31
 
7
32
  ### Fixed — the new style gate would have failed 27 untouched documents in three projects
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.5.11** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.6.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.
@@ -36,6 +36,7 @@
36
36
  // by construction) would still trip these.
37
37
 
38
38
  const fs = require("fs");
39
+ const os = require("os");
39
40
  const path = require("path");
40
41
 
41
42
  const {
@@ -81,6 +82,22 @@ const GATE_VAULTS = new Set([
81
82
  "hashicorp-vault", "vault", "azure-key-vault", "infisical",
82
83
  ]);
83
84
  const GATE_GOTCHA_ENUM = new Set(["vpn", "ip-allowlist", "ssh-tunnel", "bastion", "none"]);
85
+ // M103 — "this column does not apply to this kind of environment" is a TRUE
86
+ // answer, not a hidden value. Accepted ONLY in columns that cannot carry a
87
+ // secret anyway (port, secret env-var NAME).
88
+ const GATE_NOT_APPLICABLE = new Set(["n/a", "na", "none", "—", "-"]);
89
+ // M103 — an auth METHOD the enum has not heard of (`cli-session`,
90
+ // `device-code`, `browser-session`). Accepted only in LABEL shape: 1-3 short
91
+ // lowercase alphabetic words joined by hyphens, ≤24 chars, NO digits. A
92
+ // credential fails this: tokens/keys/passwords carry digits, mixed case, or
93
+ // punctuation, and random lowercase-only strings exceed the per-word length.
94
+ const GATE_AUTH_LABEL = /^[a-z]{2,12}(?:-[a-z]{2,12}){0,2}$/;
95
+ function gateIsAuthLabelShape(s) {
96
+ if (typeof s !== "string") return false;
97
+ const v = s.trim();
98
+ if (v.length > 24) return false;
99
+ return GATE_AUTH_LABEL.test(v);
100
+ }
84
101
  const GATE_CLI_WORDS = new Set([
85
102
  "psql", "mysql", "mysqldump", "mongo", "mongosh", "redis-cli", "sqlite3",
86
103
  "pg_dump", "pg_restore", "pg_dumpall", "cqlsh", "clickhouse-client",
@@ -91,6 +108,10 @@ const GATE_CLI_WORDS = new Set([
91
108
  "env", "pull", "push", "list", "get", "set", "secrets", "versions",
92
109
  "access", "version", "exec", "run", "connect", "login", "logout",
93
110
  "connection-string", "db-url", "database-url", "redis-url",
111
+ // M103 — resource-listing subcommands, the shape of a `source` value
112
+ // ("neonctl projects list"). Ordinary CLI nouns, same class as list/get/show.
113
+ "projects", "project", "branches", "branch", "orgs", "org", "databases",
114
+ "roles", "endpoints", "instances", "buckets", "services", "apps",
94
115
  "admin", "default", "latest", "read", "write", "describe", "show",
95
116
  "from", "cat", "source", "printenv", "dotenv",
96
117
  ]);
@@ -234,6 +255,46 @@ function gateCommandOk(cell) {
234
255
  }
235
256
  return true;
236
257
  }
258
+ // M103 — the access-gotchas column is the ONE cell that must carry human
259
+ // judgment in plain English ("live seller data — never mutate without an OK").
260
+ // That warning is the most valuable thing in the row: it is what stops a
261
+ // destructive mistake, and no vendor CLI can ever return it. The M102 enum
262
+ // rejected all prose, so the column was written as `none` and the judgment was
263
+ // lost.
264
+ //
265
+ // Prose is allowed here, but NOT as a hole in the guard. A word is accepted
266
+ // only if it is PROSE-SHAPED — and a secret is not prose-shaped. The test is
267
+ // per-WORD and structural (never a denylist of secret values, which the M102
268
+ // cycles proved unwinnable): a word must be ordinary letters, or ordinary
269
+ // punctuation, or one of the shapes already proven safe elsewhere in the row.
270
+ //
271
+ // What a prose word may be:
272
+ // - a letters-only word, with optional internal apostrophe/hyphen, ≤24 chars
273
+ // (`Marla's`, `never`, `read-only`, `VPN`) — no digits, so a random
274
+ // credential cannot pass as a word
275
+ // - a pure number / ordinary punctuation (`6h`, `17`, `—`, `.`, `(main)`)
276
+ // - a shape already whitelisted for other columns (host, $VAR, .env dotfile,
277
+ // curated CLI word, the gotcha enum)
278
+ // Everything else — any mixed letters+digits token that is not a plain unit
279
+ // like `6h`, anything with credential punctuation — FAILS. That is the shape
280
+ // a real secret has, and it still fails here exactly as before.
281
+ const GATE_PROSE_WORD = /^[A-Za-z]+(?:['’-][A-Za-z]+)*$/;
282
+ // A plain magnitude: 17, 6h, 30d, 5432. Digits with an optional short unit.
283
+ const GATE_PROSE_NUMBER = /^\d+[a-z]{0,2}$/i;
284
+ // Ordinary sentence punctuation carrying no value.
285
+ const GATE_PROSE_PUNCT = /^[.,;:!?()[\]{}"'’“”—–\-/&+%]+$/;
286
+
287
+ function gateIsProseWord(raw) {
288
+ // Strip surrounding punctuation so `(main),` tests as `main`.
289
+ const w = raw.replace(/^[.,;:!?()[\]{}"'’“”—–]+/, "").replace(/[.,;:!?()[\]{}"'’“”—–]+$/, "");
290
+ if (w === "") return true;
291
+ if (w.length > 24) return false;
292
+ if (GATE_PROSE_WORD.test(w)) return true;
293
+ if (GATE_PROSE_NUMBER.test(w)) return true;
294
+ if (GATE_PROSE_PUNCT.test(w)) return true;
295
+ return false;
296
+ }
297
+
237
298
  function gateGotchasOk(cell) {
238
299
  const tokens = cell.split(/[,\s]+/).filter(Boolean);
239
300
  for (let i = 0; i < tokens.length; i++) {
@@ -246,11 +307,77 @@ function gateGotchasOk(cell) {
246
307
  continue;
247
308
  }
248
309
  if (gateIsHostShape(tokens[i])) continue;
310
+ // M103 — plain-English judgment is allowed, word by word.
311
+ if (gateIsSafeNonSecretToken(tokens[i].replace(/^["']/, "").replace(/["']$/, ""))) continue;
312
+ if (gateIsProseWord(tokens[i])) continue;
249
313
  return false;
250
314
  }
251
315
  return true;
252
316
  }
253
317
 
318
+ // ─── M103 — the `source` column: where an unprovable value came from ─────────
319
+ //
320
+ // A vendor's resource id (`winter-frog-54927244`, `prj_3OQ3gUB1zkm5uraf…`) is
321
+ // indistinguishable from a token BY SHAPE — that is a fact about the values,
322
+ // not a gap in the grammar, so no amount of pattern-tightening resolves it.
323
+ // The M102 answer was to reject them, which forced a worse outcome: rows were
324
+ // written with FALSE values to get past the checker (a `staging` environment
325
+ // recorded as `prod`, a real command replaced by an unrelated one that
326
+ // happened to pass). A checker that makes the truth unwritable buys nothing.
327
+ //
328
+ // So a value the shape-grammar cannot vouch for is accepted when the row NAMES
329
+ // WHERE IT CAME FROM. The source's PRESENCE is the flag — there is no
330
+ // per-vendor "source required" list to add to, and therefore none to forget.
331
+ //
332
+ // What this does and does not defend against: it stops an ACCIDENT (a secret
333
+ // pasted into the wrong cell, a connection string carrying its password) —
334
+ // which is the whole M102 threat model. It does not stop someone deliberately
335
+ // writing a secret and inventing a source for it; nothing mechanical does, and
336
+ // that was never in scope.
337
+ //
338
+ // The source must itself be checkable — a command from the curated CLI words
339
+ // or a path to a file in the project. Free prose here would re-open the hole.
340
+ // A project-relative file path. It must LOOK like a path — carry a separator or
341
+ // a file extension. A bare word (`hunter2`) is NOT a path: allowing one would
342
+ // turn this column into the free-text cell the 15-column schema used to catch
343
+ // as overflow corruption (a real leak, found by the CYCLE5 overflow tests when
344
+ // the `source` column took over the 15th slot).
345
+ const GATE_SOURCE_PATH = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/;
346
+ const GATE_SOURCE_PATH_EVIDENCE = /[/]|^\.[A-Za-z]|\.[A-Za-z0-9]{1,8}$/;
347
+
348
+ function gateIsSourceShape(s) {
349
+ if (typeof s !== "string") return false;
350
+ const v = s.trim();
351
+ if (v === "" || v === "—") return true;
352
+ if (v.length > 120) return false;
353
+ // A command: every token must clear the same grammar the command columns use.
354
+ if (gateCommandOk(v)) return true;
355
+ // A file path in the project (.vercel/project.json, fly.toml, .env.example).
356
+ // Rejected if absolute, if it climbs out of the project, or if it is a bare
357
+ // word carrying no evidence of being a path at all.
358
+ if (
359
+ GATE_SOURCE_PATH.test(v) &&
360
+ !v.startsWith("/") &&
361
+ !v.includes("..") &&
362
+ GATE_SOURCE_PATH_EVIDENCE.test(v)
363
+ ) {
364
+ return true;
365
+ }
366
+ return false;
367
+ }
368
+
369
+ // Does this row name a source? If so, cells the shape-grammar cannot prove are
370
+ // accepted — EXCEPT cells that hit the backstop, which stays absolute (see
371
+ // cellLeaks). A source vouches for an unrecognised value; it never vouches for
372
+ // something that positively looks like a credential.
373
+ function rowNamesASource(cells) {
374
+ const idx = ENV_COLUMNS.indexOf("source");
375
+ if (idx === -1) return false;
376
+ const v = (cells[idx] || "").trim();
377
+ if (v === "" || v === "—") return false;
378
+ return gateIsSourceShape(v);
379
+ }
380
+
254
381
  // The BACKSTOP leak test — the known-prefix/JWT/base64/hex detector + the
255
382
  // embedded-cred regex. Applied to EVERY cell as an extra layer.
256
383
  function cellHitsBackstop(cell) {
@@ -278,24 +405,42 @@ function cellMatchesColumnShape(col, cell) {
278
405
  case "host":
279
406
  return gateIsHostShape(s.replace(/:\d+$/, ""));
280
407
  case "port":
281
- return /^\d+$/.test(s);
408
+ // M103: "not applicable" is a TRUE answer for a kind with no port (a
409
+ // web-console / CLI-session environment). Digits or n/a — nothing else.
410
+ return /^\d+$/.test(s) || GATE_NOT_APPLICABLE.has(s.toLowerCase());
282
411
  case "db/name":
283
- return gateIsDbNameShape(s);
412
+ // M103: an environment that is not a database (a hosting account, a web
413
+ // console) has no db name. `n/a` is true, not hidden.
414
+ return gateIsDbNameShape(s) || GATE_NOT_APPLICABLE.has(s.toLowerCase());
284
415
  case "auth method":
285
- return GATE_AUTH_METHODS.has(s.toLowerCase());
416
+ // M103: the enum can never be complete (every vendor names sign-in its
417
+ // own way — `cli-session`, `device-code`). An unenumerated method is
418
+ // accepted ONLY in LABEL shape: short lowercase hyphenated words. A
419
+ // credential is not that shape (see gateIsAuthLabelShape).
420
+ return GATE_AUTH_METHODS.has(s.toLowerCase()) || gateIsAuthLabelShape(s);
286
421
  case "secret vault":
287
422
  return GATE_VAULTS.has(s.toLowerCase());
288
423
  case "secret env-var NAME":
289
- return GATE_UPPER_SNAKE.test(s);
424
+ // M103: an environment reached by an interactive CLI session carries no
425
+ // env var. `n/a` is the true answer; it is not a secret in any shape.
426
+ return GATE_UPPER_SNAKE.test(s) || GATE_NOT_APPLICABLE.has(s.toLowerCase());
290
427
  case "fetch command":
291
428
  case "connect command":
292
429
  return gateCommandOk(s);
293
430
  case "access gotchas":
294
431
  return gateGotchasOk(s);
295
432
  case "read-only default":
296
- return s === "YES" || s === "NO";
433
+ // M103: a boolean is a boolean whatever its capitalisation. Rejecting
434
+ // `yes` taught humans to retype until the checker relented.
435
+ return /^(yes|no)$/i.test(s);
297
436
  case "recorded":
298
437
  return GATE_ISO_TS.test(s);
438
+ case "source":
439
+ // M103 — where an otherwise-unprovable value came from. It must itself be
440
+ // checkable: a command built from the curated CLI words, or a path to a
441
+ // file in the project. Free prose here would re-open the hole the column
442
+ // exists to close.
443
+ return gateIsSourceShape(s);
299
444
  default:
300
445
  // Unknown column — fall back to refusing anything the backstop flags.
301
446
  return !cellHitsBackstop(s);
@@ -305,10 +450,20 @@ function cellMatchesColumnShape(col, cell) {
305
450
  // The gate's leak test for a cell in a KNOWN column: FAIL if it is NOT the
306
451
  // column's positive shape OR (backstop) it hits the known-prefix/embedded-cred
307
452
  // detector. The positive shape is the PRIMARY guard.
308
- function cellLeaks(col, cell) {
453
+ function cellLeaks(col, cell, hasSource) {
309
454
  if (typeof cell !== "string" || !cell) return false;
310
- if (!cellMatchesColumnShape(col, cell)) return true; // primary: wrong shape
311
- if (cellHitsBackstop(cell)) return true; // backstop: extra layer
455
+ // BACKSTOP FIRST, and it is absolute: a value that positively looks like a
456
+ // credential (known prefix / JWT / base64 / hex / embedded cred) fails no
457
+ // matter what the row claims about where it came from. A source vouches for
458
+ // the UNRECOGNISED, never for the recognisably-secret.
459
+ if (cellHitsBackstop(cell)) return true;
460
+ if (!cellMatchesColumnShape(col, cell)) {
461
+ // M103 — the shape grammar cannot recognise this value. If the row names
462
+ // where it came from, that is the vouching the shape cannot provide.
463
+ // The `source` column itself is never vouched for by its own presence.
464
+ if (hasSource && col !== "source") return false;
465
+ return true;
466
+ }
312
467
  return false;
313
468
  }
314
469
 
@@ -354,6 +509,98 @@ function readAllowLocalLiteral(projectDir) {
354
509
  }
355
510
  }
356
511
 
512
+ // ─── M103 — does this project reach anything that is NOT on this machine? ────
513
+ //
514
+ // The M102 gate treated "no table and no rule" as "hasn't adopted the registry
515
+ // yet" and PASSED. That certified the exact emptiness the registry exists to
516
+ // prevent: 31 of 33 projects sat in that state, so every session re-asked the
517
+ // human for connection details.
518
+ //
519
+ // A project only NEEDS a map if it reaches a remote environment. So the gate
520
+ // now looks for evidence of one. The signals are re-derived HERE rather than
521
+ // imported from the writer's detectEnvConfig — the gate must stay independently
522
+ // implemented (a writer bug must never disable it), same invariant M102 set for
523
+ // the shape grammar.
524
+ //
525
+ // This is a HALT, not a fallback: a project with a remote environment and no
526
+ // map FAILS and says which marker it found. A genuinely local-only project
527
+ // PASSES and is NAMED as local-only, so "passed" never silently means
528
+ // "unchecked" (no-silent-degradation).
529
+ const REMOTE_MARKER_FILES = [
530
+ ["vercel.json", "Vercel"],
531
+ [".vercel", "Vercel"],
532
+ ["cloudbuild.yaml", "Google Cloud"],
533
+ ["cloudbuild.yml", "Google Cloud"],
534
+ [".gcloudignore", "Google Cloud"],
535
+ ["fly.toml", "Fly.io"],
536
+ ["render.yaml", "Render"],
537
+ ["railway.json", "Railway"],
538
+ ["app.yaml", "Google App Engine"],
539
+ ["netlify.toml", "Netlify"],
540
+ ["wrangler.toml", "Cloudflare"],
541
+ ["captain-definition", "CapRover"],
542
+ [".neon", "Neon"],
543
+ ];
544
+ // A dependency whose presence means a hosted service is being talked to.
545
+ const REMOTE_DEP_HINTS = [
546
+ ["@neondatabase/serverless", "Neon"],
547
+ ["@vercel/postgres", "Vercel Postgres"],
548
+ ["@supabase/supabase-js", "Supabase"],
549
+ ["@planetscale/database", "PlanetScale"],
550
+ ["@aws-sdk/client-s3", "AWS"],
551
+ ["@google-cloud/storage", "Google Cloud"],
552
+ ["mongodb", "MongoDB"],
553
+ ];
554
+
555
+ // M103 — "a recorded command must run as written" was TRIED as a gate check
556
+ // here and REMOVED. Two reasons, both found by the existing tests:
557
+ // 1. It is not a secret question. Bolting a usefulness check onto the leak
558
+ // gate made it fail rows that are perfectly safe — three pre-existing
559
+ // tests assert bare `neonctl connection-string` is legitimate, correctly.
560
+ // 2. The rule was not universally true. `neonctl connection-string` resolves
561
+ // fine in a single-project account; it only opens a picker when the
562
+ // account holds several (as David's does). Generalising one environment's
563
+ // behaviour into a universal requirement is the guess this codebase's
564
+ // No-Fallback/evidence rules exist to stop.
565
+ // The requirement survives as guidance in the CLAUDE.md env-access rule, where
566
+ // the human recording the row can judge their own account shape.
567
+
568
+ function detectRemoteEnvironment(projectDir) {
569
+ const found = [];
570
+ for (const [file, label] of REMOTE_MARKER_FILES) {
571
+ try {
572
+ if (fs.existsSync(path.join(projectDir, file))) found.push(`${label} (${file})`);
573
+ } catch (_) { /* unreadable path is not evidence */ }
574
+ }
575
+ try {
576
+ const pkgRaw = fs.readFileSync(path.join(projectDir, "package.json"), "utf8");
577
+ const pkg = JSON.parse(pkgRaw);
578
+ const deps = Object.assign({}, pkg.dependencies, pkg.devDependencies);
579
+ for (const [dep, label] of REMOTE_DEP_HINTS) {
580
+ if (Object.prototype.hasOwnProperty.call(deps, dep)) found.push(`${label} (${dep})`);
581
+ }
582
+ } catch (_) { /* no/unparseable package.json is not evidence */ }
583
+ return found;
584
+ }
585
+
586
+ // Does the table hold at least one row describing a NON-local environment?
587
+ // A map listing only `scope=local` rows does not answer "how do I reach prod".
588
+ function hasNonLocalRow(infra) {
589
+ const start = infra.indexOf(ENV_MARKER_START);
590
+ const end = infra.indexOf(ENV_MARKER_END);
591
+ if (start === -1 || end === -1) return false;
592
+ const lines = infra.slice(start, end).split("\n");
593
+ for (const line of lines) {
594
+ if (!line.trim().startsWith("|")) continue;
595
+ const cells = splitRow(line);
596
+ if (cells[0] === "id") continue;
597
+ if (cells.every((c) => /^-{1,}$/.test(c) || c === "")) continue;
598
+ const scope = (cells[1] || "").trim().toLowerCase();
599
+ if (scope === "prod" || scope === "staging") return true;
600
+ }
601
+ return false;
602
+ }
603
+
357
604
  function check(projectDir) {
358
605
  const infraPath = path.join(projectDir, "docs", "infrastructure.md");
359
606
  const claudePath = path.join(projectDir, "CLAUDE.md");
@@ -364,14 +611,42 @@ function check(projectDir) {
364
611
 
365
612
  const hasMarkers = infra.includes(ENV_MARKER_START) && infra.includes(ENV_MARKER_END);
366
613
  // The env-access rule is identified by its stable marker phrase.
367
- const hasRule = /Environment Access — read-first, HALT-and-document/.test(claude);
614
+ //
615
+ // M103 — the rule ships in the GLOBAL ~/.claude/CLAUDE.md (via
616
+ // templates/CLAUDE-global.md), NOT in a project CLAUDE.md. M102 looked only
617
+ // at the project file, so hasRule was false in EVERY project, which sent
618
+ // every project down the "hasn't adopted it" no-op-PASS branch. Check both:
619
+ // the project file first (a project may restate it), then the global.
620
+ const RULE_PHRASE = /Environment Access — read-first, HALT-and-document/;
621
+ const globalClaude = readSafe(path.join(os.homedir(), ".claude", "CLAUDE.md")) || "";
622
+ const hasRule = RULE_PHRASE.test(claude) || RULE_PHRASE.test(globalClaude);
368
623
 
369
624
  const failures = [];
370
625
 
371
- // (b) rule present but table markers absent.
372
- if (hasRule && !hasMarkers) {
626
+ // (b) M102 asked "the rule promises a map — is there a table?". That made
627
+ // sense while the rule was believed to be per-project. It is not: the rule
628
+ // ships in the GLOBAL CLAUDE.md, so once M103 reads the global file the
629
+ // condition is true in EVERY project and fires on local-only projects that
630
+ // have nothing remote to map (measured: 21 of 28 projects failed on this
631
+ // alone). A gate that fails a project for a correct state gets switched off.
632
+ //
633
+ // Condition (c) below asks the question (b) was reaching for, and asks it
634
+ // against evidence from the project itself rather than a globally-true
635
+ // premise: does this project REACH something remote, and is it mapped?
636
+ // (b) is therefore removed, not merely relaxed — it added no signal (c)
637
+ // does not already carry.
638
+
639
+ // (c) M103 — a project that reaches a REMOTE environment must map it. An
640
+ // empty (or local-only) map in a project with a deploy marker is the state
641
+ // that made every session re-ask the human for connection details.
642
+ const remoteMarkers = detectRemoteEnvironment(projectDir);
643
+ const mapsARemoteEnv = hasMarkers && hasNonLocalRow(infra);
644
+ const isLocalOnly = remoteMarkers.length === 0;
645
+ if (!isLocalOnly && !mapsARemoteEnv) {
373
646
  failures.push(
374
- "env-access rule is present in CLAUDE.md but the `## Environments` table markers are absent from docs/infrastructure.md"
647
+ `project reaches a remote environment (${remoteMarkers.join(", ")}) but the ` +
648
+ "`## Environments` table has no prod/staging row — record it with " +
649
+ "`gsd-t env-registry record`, so the connection is not rediscovered every session"
375
650
  );
376
651
  }
377
652
 
@@ -391,7 +666,11 @@ function check(projectDir) {
391
666
  // check below — a malformed schema always fails).
392
667
  const rowScope = (cells[1] || "").trim();
393
668
  const exemptSecrets = allowLocalLiteral && rowScope === "local";
394
- // An OVERFLOW cell (index the fixed 14-column schema) is itself a
669
+ // M103 does this row say where its values came from? A named source
670
+ // vouches for a value the shape-grammar cannot recognise (a vendor
671
+ // resource id). It never vouches past the backstop.
672
+ const hasSource = rowNamesASource(cells);
673
+ // An OVERFLOW cell (index ≥ the fixed column schema) is itself a
395
674
  // corruption signal — a hand-edit/merge/tool that appended a 15th column
396
675
  // could hide a plaintext secret in a column the shape-map doesn't cover.
397
676
  // The old `col${i}` default branch fell back to the WEAK backstop only
@@ -409,9 +688,11 @@ function check(projectDir) {
409
688
  }
410
689
  if (exemptSecrets) continue; // opted-in local row — skip secret-leak check
411
690
  const col = ENV_COLUMNS[i];
412
- if (cellLeaks(col, cells[i])) {
691
+ if (cellLeaks(col, cells[i], hasSource)) {
413
692
  failures.push(
414
- `Environments row cell (${col}) contains a secret-shaped literal value: "${cells[i]}" — record the env-var NAME and a $VAR reference, never a literal secret`
693
+ hasSource
694
+ ? `Environments row cell (${col}) contains a secret-shaped literal value: "${cells[i]}" — naming a source does not permit a value that looks like a credential; record the env-var NAME and a $VAR reference`
695
+ : `Environments row cell (${col}) contains a secret-shaped literal value: "${cells[i]}" — record the env-var NAME and a $VAR reference, never a literal secret, OR name where the value came from in the \`source\` column if it is a vendor resource id`
415
696
  );
416
697
  }
417
698
  }
@@ -423,10 +704,16 @@ function check(projectDir) {
423
704
  check: "env-registry",
424
705
  hasMarkers,
425
706
  hasRule,
707
+ // M103 — the reason for a PASS is always NAMED. "local-only" is a real
708
+ // verdict about this project; it never silently means "not checked".
709
+ localOnly: isLocalOnly,
710
+ remoteMarkers,
426
711
  failures,
712
+ // Only describe a PASS when it IS one — a note saying "PASS" beside
713
+ // ok:false is exactly the kind of mixed signal this gate exists to remove.
427
714
  note:
428
- !hasMarkers && !hasRule
429
- ? "no-op PASS: project has not adopted the M102 Environments registry (no table, no rule)"
715
+ isLocalOnly && failures.length === 0
716
+ ? "PASS: local-only no deploy marker or hosted-service dependency found, so there is no remote environment to map"
430
717
  : undefined,
431
718
  };
432
719
  }
@@ -50,6 +50,12 @@ const ENV_COLUMNS = [
50
50
  "access gotchas",
51
51
  "read-only default",
52
52
  "recorded",
53
+ // M103 — WHERE a value came from. Filled ONLY when a cell holds something the
54
+ // checker cannot prove safe by its shape (a vendor's project id looks exactly
55
+ // like a token). Naming the source is what makes such a value acceptable; its
56
+ // PRESENCE is the flag, so there is no per-vendor list to keep up to date.
57
+ // Empty for rows whose every cell stands on its own — most rows.
58
+ "source",
53
59
  ];
54
60
 
55
61
  // ─── Secret-value BACKSTOP detector (known-prefix / JWT / embedded-cred) ─────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.5.11",
3
+ "version": "5.6.10",
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",
@@ -106,6 +106,8 @@ NEED TO UNDERSTAND SOMETHING?
106
106
 
107
107
  - **On a HIT** — use the recorded connect command; the secret is pulled from its vault at runtime via the env-var NAME.
108
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
+ - **WRITE THE ROW BEFORE YOU USE THE ANSWER (M103).** The moment the human answers is the ONLY moment both a human and a real answer are in the same place. `record` FIRST, then continue the work that needed it. Using the answer and recording it "after this task" is how the map stayed empty in 31 of 33 projects and why the same question got asked again next session. This is not a fallback: the HALT already fired and was answered — writing it down is what COMPLETES the halt.
110
+ - **A recorded command must RUN AS WRITTEN (M103).** Record the command you actually verified, including every identifier it needs (project id, branch, org). `neonctl connection-string` with no `--project-id` drops into an interactive picker and answers nothing unattended; `neonctl connection-string --project-id winter-frog-54927244 --database-name neondb` works. A command that needs a fact it does not carry is an incomplete row — it looks recorded and still forces the next session to ask.
109
111
  - **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
112
  - **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
113
  - **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.
@@ -79,8 +79,8 @@ cp .env.example .env
79
79
  > document (detect → ask → record → proceed); never guess a connection string,
80
80
  > never grep transcripts to rediscover.
81
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
- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
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 | source |
83
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
84
84
  <!-- gsd-t-env-registry:end -->
85
85
 
86
86
  <!--
@@ -92,17 +92,35 @@ cp .env.example .env
92
92
  host = POSITIVE shape: localhost | IPv4 | dotted DNS hostname |
93
93
  short lowercase service name (letters+hyphen, no digits, e.g. db, postgres)
94
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 | ...
95
+ auth method = password | iam | oauth | oauth2 | service-account | ssh-key |
96
+ api-key | none | scram-sha-256 | md5 | trust | token | key | ...
97
+ A method the list has not heard of (cli-session, device-code) is
98
+ accepted in LABEL shape: short lowercase hyphenated words, no digits.
97
99
  fetch command = how to pull the secret from its vault (e.g. `vercel env pull`)
98
100
  POSITIVE allowlist: a token is accepted ONLY if it IS a $VAR/${VAR} ref,
99
101
  a flag, a hostname/IP, a .env dotfile, or a curated CLI/db word.
100
102
  Any OTHER bare literal is REJECTED (move it to an env var, reference as $VAR).
101
103
  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
104
+ access gotchas = vpn | ip-allowlist | ssh-tunnel | bastion | none, optionally
105
+ `via <hostname>` (e.g. `ssh-tunnel via bastion.example.com`).
106
+ PLAIN-ENGLISH judgment is also allowed here and is the most
107
+ valuable cell in the row ("live customer data never mutate
108
+ without an explicit OK"): no vendor CLI can ever return it.
109
+ Checked word-by-word — ordinary words and numbers pass; a
110
+ credential-shaped token does not.
111
+ read-only default = yes | no (any capitalisation). YES for scope=prod unless a
112
+ human explicitly recorded write-ok.
113
+ port / db/name / secret env-var NAME
114
+ = `n/a` is a valid TRUE answer where the column does not apply
115
+ (a web console has no port; a hosting account is not a database).
116
+ source = WHERE an unprovable value came from — a command
117
+ (`neonctl projects list`) or a project file (`.vercel/project.json`).
118
+ Fill it ONLY when a cell holds something the checker cannot
119
+ recognise by shape: a vendor resource id (winter-frog-54927244)
120
+ is indistinguishable from a token, so naming its source is what
121
+ makes it acceptable. Its PRESENCE is the flag — there is no
122
+ per-vendor list to maintain. It never excuses a value that
123
+ positively looks like a credential.
106
124
  There is deliberately NO secret-value column — a row is structurally incapable
107
125
  of holding a secret. Populated by `gsd-t-env-registry` (record-at-create +
108
126
  capture-on-first-need).