@tekyzinc/gsd-t 5.12.11 → 5.13.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,68 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.13.10] - 2026-08-22
6
+
7
+ ### Added — trim and case are enforced at the boundary, FAIL-CLOSED
8
+
9
+ Case and trim bugs kept arriving across projects, surfacing only as unexpected
10
+ bugs days later: a status compared against a literal that never matched, an
11
+ email stored with a trailing space, a value saved untrimmed that then broke
12
+ every later comparison including the correct ones. The written standard could
13
+ not stop any of it, because nothing checked it.
14
+
15
+ **Measured before designing.** A check on COMPARISONS is unusable: one real
16
+ project holds 1,275 literal string comparisons, 193 shaped like business
17
+ values, and nearly all of those are legitimate — internal message tags, a build
18
+ mode, a value the code itself wrote a line earlier. That is roughly 190 false
19
+ alarms per project, and a check that noisy gets switched off while still
20
+ looking enforced.
21
+
22
+ So the check sits at the ENTRY POINT instead — where a value arrives from a
23
+ form, a URL, or a request. That is where "did this value cross a boundary?" is
24
+ actually knowable, there are a handful of them against hundreds of uses, and it
25
+ covers STORED values too, which no comparison rule ever reaches.
26
+
27
+ - `bin/gsd-t-boundary-normalize-check.cjs`: `--full` is a one-time
28
+ whole-project inventory that reports without blocking, so a project can adopt
29
+ the rule the day it lands; the default mode inspects only files a run touched
30
+ and FAILS on an unclean entry point. No baseline file — a stored list of
31
+ accepted violations is one that can be quietly extended. An exemption is
32
+ written at the entry point itself (`// gsd-t-allow-raw: <reason>`).
33
+ - `bin/gsd-t-verify-gate.cjs`: wired as `boundary-normalize`.
34
+ - `bin/gsd-t.js`: registered in both propagation lists.
35
+ - `templates/stacks/_comparison.md`: the rule, with a live defect as its
36
+ example.
37
+ - `test/m114-boundary-normalize.test.js`: 18 tests, including the negative
38
+ cases that prove the check can actually fail.
39
+
40
+ **Trimming is universal — passwords included.** A leading or trailing space is
41
+ never something a person meant to type; storing a password untrimmed locks them
42
+ out when they later type it normally. Only free text a person wrote on purpose
43
+ (a note, a description, a message body) keeps its spaces. Case-sensitive means
44
+ *do not change the casing*; it never means *do not trim*.
45
+
46
+ Live findings, all genuine: binvoice 9, TimeTracking 29, UMI-Automation 28 —
47
+ including a signup route that trims an email and never lowercases it, so two
48
+ spellings of one address become two accounts.
49
+
50
+ ### Fixed — the architect spawned a background agent and lost its own report
51
+
52
+ Five consecutive architect runs looked like an idle session. Watched live: the
53
+ subagent worked for three minutes, logged a clean completion, and six seconds
54
+ later the parent called `ListAgents` — hunting for a result that never arrived.
55
+
56
+ One stale line caused it. Step 4 said "spawn ONE Task subagent", terminology
57
+ from a tool that no longer exists. The current `Agent` tool accepts a `name`,
58
+ and a named agent runs in the BACKGROUND: its report arrives as a notification
59
+ rather than as the call's return value, leaving the parent with nothing.
60
+
61
+ - `commands/gsd-t-architect.md`: Step 4 specifies the blocking form and bans
62
+ passing a `name`; Step 4a treats an availability ping as an empty return and
63
+ bans reaching for `ListAgents`/`SendMessage` to find a result.
64
+ - `commands/gsd-t-health.md`, `-quick.md`, `-status.md`: the same guard where
65
+ they spawn.
66
+
5
67
  ## [5.12.11] - 2026-08-22
6
68
 
7
69
  ### Fixed — the architect could finish its work and leave nothing, silently
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.12.11** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.13.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.
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * gsd-t-boundary-normalize-check — a value entering the program must be cleaned
6
+ * where it enters.
7
+ *
8
+ * The bug this exists to stop is quiet. A status arrives from a form with a
9
+ * trailing space, or a filter arrives from a URL with different casing than the
10
+ * literal it is compared against. The comparison answers "no match", nothing
11
+ * reports anything, and the feature simply does nothing. It is found days later
12
+ * as an unexpected bug, traced back by hand.
13
+ *
14
+ * Why the check lives at the ENTRY POINT rather than at the comparison:
15
+ * measured, not assumed. One real project holds 1,275 literal string
16
+ * comparisons, 193 of them shaped like business values, and nearly all of those
17
+ * are legitimate — internal message tags, a build mode, a value the code itself
18
+ * wrote a line earlier. A check flagging comparisons produces roughly 190 false
19
+ * alarms per project, and a check that noisy gets switched off. Switched off it
20
+ * enforces nothing while still looking enforced.
21
+ *
22
+ * A comparison cannot be judged alone: whether it is a bug depends on where the
23
+ * value came from. The entry point is where that is knowable, and a project has
24
+ * a handful of entry points against hundreds of uses. Cleaning there also
25
+ * covers values that are STORED, which no comparison rule ever reaches — and
26
+ * half the reported bugs were stored values.
27
+ *
28
+ * Two modes, chosen by whether the project has been checked before:
29
+ *
30
+ * full every entry point in the project. Reports, never blocks. This is
31
+ * the one-time inventory for a project adopting the rule.
32
+ * changed only entry points in files this run touched. An unclean one FAILS.
33
+ *
34
+ * Nothing is ever recorded as "permitted". A stored list of accepted violations
35
+ * is a list somebody can quietly extend, and then the check measures the list
36
+ * rather than the code. An exemption is written at the entry point itself, in a
37
+ * comment, where the next reader is already looking.
38
+ *
39
+ * Exit 0 clean or reporting, 1 on a blocking failure, 64 when the check cannot
40
+ * be run at all. It never passes a question it could not answer.
41
+ */
42
+
43
+ const fs = require("fs");
44
+ const path = require("path");
45
+ const { execFileSync } = require("child_process");
46
+
47
+ // ─── What counts as a value entering the program ─────────────────────────────
48
+ //
49
+ // Each entry is a way a value arrives from outside: something a person typed, a
50
+ // web address, or a row read back from storage.
51
+ // Matched on the read itself, so the check sees the value at the moment it
52
+ // crosses in.
53
+ const ENTRY_POINTS = [
54
+ { id: "http-body", re: /\breq(?:uest)?\.body\s*(?:\.|\[)/g, what: "a request body" },
55
+ { id: "http-query", re: /\breq(?:uest)?\.query\s*(?:\.|\[)/g, what: "a URL query value" },
56
+ { id: "http-params", re: /\breq(?:uest)?\.params\s*(?:\.|\[)/g, what: "a URL path value" },
57
+ { id: "url-search", re: /\b(?:searchParams|URLSearchParams)\s*\.\s*get\s*\(/g, what: "a URL query value" },
58
+ { id: "form-data", re: /\bformData\s*\.\s*get\s*\(/g, what: "a submitted form value" },
59
+ { id: "dom-input", re: /\.\s*value\s*(?:;|,|\)|\s*$)/g, what: "a typed-in field", weak: true },
60
+ ];
61
+
62
+ // TRIMMING IS UNIVERSAL. A leading or trailing space is never something a
63
+ // person meant to type — it is paste damage. That includes a PASSWORD: a space
64
+ // on the end is not part of the secret, it is a stray keystroke, and storing it
65
+ // untrimmed locks the person out when they later type the password normally.
66
+ //
67
+ // The only values where the space IS the content are free text a person wrote
68
+ // on purpose — a note, a description, a message body. Everything else, without
69
+ // exception, is trimmed where it enters.
70
+ const FREE_TEXT_NAMES =
71
+ /\b(note|notes|description|comment|comments|message|messageBody|content|text|summary|bio)\b/i;
72
+
73
+ // A value naming something in the business. These cross boundaries where the
74
+ // casing is free to change, so they are lowercased as well as trimmed.
75
+ const DOMAIN_NAMES = /\b(status|state|filter|tab|mode|role|category|kind|type|view|email|username|slug)\b/i;
76
+
77
+ // Casing is part of the value here. Lowercasing these is a defect, and for the
78
+ // first group a security defect — it shrinks the space a secret lives in.
79
+ const CASE_SENSITIVE_NAMES =
80
+ /\b(password|passwd|secret|token|apiKey|api_key|signature|hash|digest|salt|nonce|sessionId|path|filepath|url|uri|href|base64|sha|checksum)\b/i;
81
+
82
+ // Cleaning that satisfies the rule, at or near the entry point.
83
+ const TRIMS = /\.\s*trim\s*\(\s*\)|\btrimmed\b|\bnormali[sz]e[A-Za-z]*\s*\(/;
84
+ const LOWERS = /\.\s*toLowerCase\s*\(\s*\)|\.\s*toUpperCase\s*\(\s*\)|\blocaleCompare\s*\(/;
85
+
86
+ // An exemption is written where the value enters, never in a separate file.
87
+ const EXEMPT = /gsd-t-(?:allow-raw|raw-value)\s*:\s*\S/;
88
+
89
+ const CODE_EXT = /\.(?:ts|tsx|js|jsx|mjs|cjs)$/i;
90
+ // Bundled and minified files are generated, not authored: a bundle carries its
91
+ // dependencies' internals, and reporting Express's own parameter handling is work
92
+ // nobody can act on. One real project produced 46 of its 75 findings from a single
93
+ // bundle file.
94
+ const GENERATED_FILE = /(?:^|\/)(?:_?bundle|.*\.bundle|.*\.min|.*-bundle)\.[cm]?jsx?$/i;
95
+ // Generated output is not authored code. Matched with a suffix so `dist-local`
96
+ // and `dist-test` are skipped too — a check that lints its own build output
97
+ // reports work nobody can act on.
98
+ const SKIP_PATH =
99
+ /(?:^|\/)(?:node_modules|\.git|dist[\w.-]*|build[\w.-]*|out|coverage|\.next|\.nuxt|vendor|\.venv|__pycache__)(?:\/|$)/;
100
+ const TEST_PATH = /(?:\.(?:test|spec)\.[tj]sx?$|(?:^|\/)(?:__tests__|tests?|e2e)\/)/i;
101
+
102
+ /** Raised when the check cannot answer its own question. Never caught into a pass. */
103
+ class CannotCheck extends Error {}
104
+
105
+ /**
106
+ * Files this run touched, as git reports them.
107
+ *
108
+ * Git failing to answer is a HALT, not a pass. "Which files did this run
109
+ * touch?" is the question the whole changed-mode check rests on; answering it
110
+ * with "none" would report a clean run over code nobody examined.
111
+ */
112
+ function changedFiles(projectDir) {
113
+ let out;
114
+ try {
115
+ out = execFileSync(
116
+ "git",
117
+ ["status", "--porcelain=v1", "--untracked-files=all"],
118
+ { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
119
+ );
120
+ } catch (err) {
121
+ throw new CannotCheck(
122
+ `git could not report which files changed in ${projectDir} ` +
123
+ `(${(err && err.message) || err}). Run with --full to inspect the whole project, or fix ` +
124
+ `git — a run that cannot see the changed files cannot vouch for them.`
125
+ );
126
+ }
127
+ const files = [];
128
+ for (const line of out.split("\n")) {
129
+ if (!line.trim()) continue;
130
+ const p = line.slice(3).trim();
131
+ const finalPath = p.includes(" -> ") ? p.split(" -> ").pop() : p;
132
+ files.push(finalPath.replace(/^"|"$/g, ""));
133
+ }
134
+ return files;
135
+ }
136
+
137
+ /**
138
+ * Every code file in the project, for the one-time inventory.
139
+ *
140
+ * A directory that cannot be read is a HALT: its files would silently go
141
+ * uninspected, and the inventory would claim a coverage it does not have.
142
+ */
143
+ function allCodeFiles(projectDir) {
144
+ const found = [];
145
+ const walk = (dir, rel) => {
146
+ let entries;
147
+ try {
148
+ entries = fs.readdirSync(dir, { withFileTypes: true });
149
+ } catch (err) {
150
+ throw new CannotCheck(
151
+ `${rel || "."} could not be listed (${(err && err.message) || err}), so the entry points ` +
152
+ `inside it were never examined.`
153
+ );
154
+ }
155
+ for (const e of entries) {
156
+ const r = rel ? `${rel}/${e.name}` : e.name;
157
+ if (SKIP_PATH.test(r)) continue;
158
+ if (e.isDirectory()) walk(path.join(dir, e.name), r);
159
+ else if (CODE_EXT.test(e.name) && !GENERATED_FILE.test(r)) found.push(r);
160
+ }
161
+ };
162
+ walk(projectDir, "");
163
+ return found;
164
+ }
165
+
166
+ /**
167
+ * The names that say what KIND of value this is: what it is being assigned to,
168
+ * and what property it was read from. Deliberately not the whole line — a line
169
+ * mentioning `req.body` carries the word "body", and judging on the whole line
170
+ * would exempt every request-body read in a project as though it were free text.
171
+ */
172
+ function valueName(line) {
173
+ const parts = [];
174
+ const assigned = line.match(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)/);
175
+ if (assigned) parts.push(assigned[1]);
176
+ // the property being read: the last `.name` or ['name'] on the line
177
+ for (const m of line.matchAll(/\.([A-Za-z_$][\w$]*)/g)) parts.push(m[1]);
178
+ for (const m of line.matchAll(/\[\s*['"]([^'"]+)['"]\s*\]/g)) parts.push(m[1]);
179
+ // drop the container words themselves; they name the doorway, not the value
180
+ return parts.filter((n) => !/^(body|query|params|value|get|trim|toLowerCase|toUpperCase)$/i.test(n)).join(" ");
181
+ }
182
+
183
+ /**
184
+ * Is this value cleaned at the point it enters?
185
+ *
186
+ * The window is the entry line plus the two lines after it: cleaning either
187
+ * rides on the same expression (`req.body.email.trim()`) or lands immediately
188
+ * (`const email = raw.trim()`). Looking further would start crediting unrelated
189
+ * cleaning elsewhere in the function, which is how a check begins passing code
190
+ * that is actually broken.
191
+ */
192
+ function inspectEntry(lines, idx, entry) {
193
+ const window = lines.slice(idx, idx + 3).join("\n");
194
+
195
+ // The exemption is looked for on the line ABOVE as well, because that is
196
+ // where people write it — a comment explaining the next line sits before it.
197
+ const exemptWindow = lines.slice(Math.max(0, idx - 1), idx + 3).join("\n");
198
+ if (EXEMPT.test(exemptWindow)) return null; // deliberately raw, and said so here
199
+
200
+ const line = lines[idx];
201
+ const trimmed = TRIMS.test(window);
202
+
203
+ // Whether casing matters is decided by the NAME the value is given or read
204
+ // from, since that is what says which kind of value it is.
205
+ const caseSensitive = CASE_SENSITIVE_NAMES.test(valueName(line));
206
+ const domain = !caseSensitive && DOMAIN_NAMES.test(valueName(line));
207
+ const lowered = LOWERS.test(window);
208
+
209
+ const missing = [];
210
+ // Judged on the NAME the value is given or read from, never the whole line:
211
+ // `req.body` contains the word "body", which would exempt every request-body
212
+ // read in the project as if it were free text.
213
+ const freeText = FREE_TEXT_NAMES.test(valueName(line));
214
+ if (!trimmed && !freeText) missing.push("trimming");
215
+ if (domain && !lowered) missing.push("case-normalising");
216
+ if (missing.length === 0) return null;
217
+
218
+ return { entry, missing, line: line.trim().slice(0, 120) };
219
+ }
220
+
221
+ /** A file that cannot be read is a HALT — an unexamined entry point is not a clean one. */
222
+ function scanFile(projectDir, rel) {
223
+ let text;
224
+ try {
225
+ text = fs.readFileSync(path.join(projectDir, rel), "utf8");
226
+ } catch (err) {
227
+ throw new CannotCheck(
228
+ `${rel} could not be read (${(err && err.message) || err}), so its entry points were never ` +
229
+ `checked.`
230
+ );
231
+ }
232
+ const lines = text.split("\n");
233
+ const problems = [];
234
+
235
+ for (let i = 0; i < lines.length; i++) {
236
+ const line = lines[i];
237
+ if (/^\s*(?:\/\/|\*|#)/.test(line)) continue; // a comment is not code
238
+
239
+ for (const ep of ENTRY_POINTS) {
240
+ // The weak patterns match ordinary property reads too, so they only count
241
+ // where the line also names a kind of value the rule governs. Without
242
+ // that, `.value` alone would flag half of every React file.
243
+ if (ep.weak && !DOMAIN_NAMES.test(line)) continue;
244
+ ep.re.lastIndex = 0;
245
+ if (!ep.re.test(line)) continue;
246
+ const problem = inspectEntry(lines, i, ep);
247
+ if (problem) problems.push({ file: rel, line: i + 1, ...problem });
248
+ break; // one report per line; a second pattern there is the same value
249
+ }
250
+ }
251
+ return problems;
252
+ }
253
+
254
+ function check(projectDir, opts = {}) {
255
+ const mode = opts.mode === "full" ? "full" : "changed";
256
+ const includeTests = opts.includeTests === true;
257
+
258
+ let files =
259
+ mode === "full"
260
+ ? allCodeFiles(projectDir)
261
+ : changedFiles(projectDir).filter(
262
+ (f) => CODE_EXT.test(f) && !SKIP_PATH.test(f) && !GENERATED_FILE.test(f)
263
+ );
264
+
265
+ if (!includeTests) files = files.filter((f) => !TEST_PATH.test(f));
266
+
267
+ const problems = [];
268
+ const inspected = [];
269
+ for (const rel of files) {
270
+ problems.push(...scanFile(projectDir, rel));
271
+ inspected.push(rel);
272
+ }
273
+
274
+ const failures = problems.map(
275
+ (p) =>
276
+ `${p.file}:${p.line}: ${p.entry.what} is used without ${p.missing.join(" or ")} it — ` +
277
+ `clean it here, where it enters. (${p.line})`
278
+ );
279
+
280
+ // full mode is the one-time inventory: it reports so the list can be worked
281
+ // through, and never blocks. Blocking here would stop every existing project
282
+ // on day one, and a check nobody can adopt enforces nothing.
283
+ const blocking = mode === "changed";
284
+
285
+ return {
286
+ ok: blocking ? failures.length === 0 : true,
287
+ check: "boundary-normalize",
288
+ mode,
289
+ reportOnly: !blocking,
290
+ filesInspected: inspected.length,
291
+ failures,
292
+ note:
293
+ failures.length === 0
294
+ ? mode === "full"
295
+ ? "no entry point is missing its cleaning"
296
+ : "PASS: no touched file reads a value from outside the program"
297
+ : blocking
298
+ ? undefined
299
+ : `${failures.length} entry point(s) to fix — reported, not blocking (whole-project inventory)`,
300
+ };
301
+ }
302
+
303
+ function parseArgs(argv) {
304
+ const out = { projectDir: ".", mode: "changed", includeTests: false };
305
+ for (let i = 0; i < argv.length; i++) {
306
+ if (argv[i] === "--project") out.projectDir = argv[++i] || ".";
307
+ else if (argv[i] === "--full") out.mode = "full";
308
+ else if (argv[i] === "--include-tests") out.includeTests = true;
309
+ }
310
+ return out;
311
+ }
312
+
313
+ module.exports = { check, scanFile, inspectEntry, ENTRY_POINTS, CannotCheck };
314
+
315
+ if (require.main === module) {
316
+ const { projectDir, mode, includeTests } = parseArgs(process.argv.slice(2));
317
+ if (!fs.existsSync(projectDir)) {
318
+ process.stderr.write(`No such directory: ${projectDir}\n`);
319
+ process.exit(64);
320
+ }
321
+ let result;
322
+ try {
323
+ result = check(projectDir, { mode, includeTests });
324
+ } catch (err) {
325
+ if (err instanceof CannotCheck) {
326
+ process.stderr.write(`boundary-normalize CANNOT CHECK: ${err.message}\n`);
327
+ process.exit(64);
328
+ }
329
+ throw err;
330
+ }
331
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
332
+ process.exit(result.ok ? 0 : 1);
333
+ }
@@ -311,6 +311,8 @@ function _detectDefaultTrack2(projectDir, notes) {
311
311
 
312
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
313
 
314
+ plan.push({ id: 'boundary-normalize', cmd: 'node', args: [path.join(__dirname, 'gsd-t-boundary-normalize-check.cjs'), '--project', projectDir], timeoutMs: 60000 }); // M114: a value entering from a form/URL/request is trimmed (and case-normalised when it names a business value) WHERE IT ENTERS, FAIL-CLOSED on touched files only. Checks the entry point rather than the comparison: measured on a real project, a comparison lint yields ~190 false alarms and gets switched off, while the entry point is where "did this value cross a boundary?" is actually knowable — and it also covers STORED values, which no comparison rule reaches.
315
+
314
316
  plan.push({ id: 'fallbacks', cmd: 'node', args: [path.join(__dirname, 'gsd-t-fallback-detect.cjs'), '--scan', '--project', projectDir, '--json'], timeoutMs: 120000 }); // M106: no unapproved continue-after-failure branch, FAIL-CLOSED (pre-existing ones excluded via .gsd-t/fallbacks-baseline.json)
315
317
 
316
318
  plan.push({ id: 'graph-use', cmd: 'node', args: [path.join(__dirname, 'gsd-t-graph-use-gate.cjs'), '--project', projectDir, '--verify-mode'], timeoutMs: 30000 }); // M113: a consumer that logged graphWiringMode=WIRED must have issued >=1 graph query, FAIL-CLOSED (documented no-op PASS when no ledger exists yet). Catches what the STATIC anti-grep lint structurally cannot: a consumer that never queried at all.
package/bin/gsd-t.js CHANGED
@@ -1786,6 +1786,7 @@ const GLOBAL_BIN_TOOLS = [
1786
1786
  // this by absolute path, so it MUST ship wherever the verify gate ships or the
1787
1787
  // schema-id check throws ENOENT. Same class as the M99 store-resolver omission below.
1788
1788
  "gsd-t-schema-id-check.cjs",
1789
+ "gsd-t-boundary-normalize-check.cjs",
1789
1790
  // M112 — measures a slice plan in LINES and splits what is too big. Called by
1790
1791
  // the scan workflow before any reviewing starts.
1791
1792
  "gsd-t-slice-budget.cjs",
@@ -3468,6 +3469,7 @@ const PROJECT_BIN_TOOLS = [
3468
3469
  // it via an absolute path in the Track 2 plan, so a project that has the verify gate
3469
3470
  // but NOT this file gets an ENOENT on every verify. Ships alongside the gate itself.
3470
3471
  "gsd-t-schema-id-check.cjs",
3472
+ "gsd-t-boundary-normalize-check.cjs",
3471
3473
  // M112 — measures a slice plan in LINES and splits what is too big. Called by
3472
3474
  // the scan workflow before any reviewing starts.
3473
3475
  "gsd-t-slice-budget.cjs",
@@ -232,10 +232,22 @@ confident you can direct a build that will not need rework.
232
232
 
233
233
  ---
234
234
 
235
- ## Step 4: Launch the architect via a Task subagent
235
+ ## Step 4: Launch the architect as ONE blocking subagent
236
236
 
237
- Give the assessment a fresh context window. Spawn ONE Task subagent (`model: opus`) — this is
238
- high-stakes design judgment, top tier.
237
+ Give the assessment a fresh context window. Spawn ONE subagent via the `Agent` tool
238
+ (`model: "opus"` — high-stakes design judgment, top tier) and **wait for its return in this same
239
+ turn.**
240
+
241
+ **Do NOT pass a `name`.** A named agent runs in the BACKGROUND: its report arrives later as a
242
+ notification rather than as this call's return value, and the parent is left holding nothing.
243
+ That is a confirmed failure, observed twice — the architect ran for three minutes, logged a clean
244
+ completion, and the parent answered by calling `ListAgents` (which returns a roster of running
245
+ agents, never the assessment). The work was finished and unreachable.
246
+
247
+ - **Correct:** `Agent({ subagent_type: "general-purpose", model: "opus", prompt: <the brief> })` —
248
+ blocks, and its return value IS the assessment.
249
+ - **Wrong:** any call passing `name`, or any use of `SendMessage`/`ListAgents` to go fetch the
250
+ result afterwards. If you are hunting for the result, it was launched wrong.
239
251
 
240
252
  **Pass it the CONFIRMED GROUNDING from Steps 0-3, not just the raw target.** This is what the
241
253
  interview was for; a subagent that has to re-derive it will make the same mistakes again. Include:
@@ -269,12 +281,17 @@ idle session, because no step ever asked whether the subagent had answered.
269
281
 
270
282
  The moment the subagent returns, before any other work:
271
283
 
272
- 1. **Did it return anything at all?** An empty return, a return that is only a status line, or no
273
- return (the subagent died on an API error) **HALT**. Say plainly: the architect subagent
274
- produced no assessment, name the target, and stop. Do NOT retry silently, do NOT write a
275
- summary from your own reading of the code — a summary you wrote yourself is not the
276
- fresh-context assessment the user asked for, and presenting it as one is worse than the
277
- silence.
284
+ 1. **Did it return anything at all?** An empty return, a return that is only a status line or an
285
+ agent-availability ping, or no return (the subagent died, or was launched as a background
286
+ agent) **HALT**. Say plainly: the architect subagent produced no assessment, name the target,
287
+ and stop. Do NOT retry silently, do NOT write a summary from your own reading of the code — a
288
+ summary you wrote yourself is not the fresh-context assessment the user asked for, and
289
+ presenting it as one is worse than the silence.
290
+
291
+ **If you find yourself reaching for `ListAgents` or `SendMessage` to locate the result, STOP.**
292
+ That means Step 4 was launched wrong (a `name` was passed, making it a background agent).
293
+ Say so and re-launch it as a blocking call — do not go hunting, and do not carry on as though
294
+ an assessment had arrived.
278
295
  2. **Does it contain the required parts?** The Six-Stage answers and a `Simply Stated` lead. A
279
296
  return that skips stages is a partial result → say which stages are missing, then HALT.
280
297
 
@@ -6,6 +6,13 @@ You are diagnosing the health of a GSD-T project. Check every required file and
6
6
 
7
7
  When invoked directly by the user, spawn yourself as a Task subagent for a fresh context window:
8
8
 
9
+ > **Blocking, never named.** Spawn via the `Agent` tool and WAIT for its return in this
10
+ > turn. Do NOT pass a `name` — a named agent runs in the background, its result arrives as a
11
+ > notification instead of this call's return value, and the parent is left with nothing. If you
12
+ > end up calling `ListAgents`/`SendMessage` to find the result, it was launched wrong: say so
13
+ > and re-launch blocking. (Confirmed failure: gsd-t-architect, twice.)
14
+
15
+
9
16
  ```
10
17
  Task subagent (general-purpose, model: haiku):
11
18
  "Run the GSD-T health check. Read commands/gsd-t-health.md for your full instructions.
@@ -38,6 +38,13 @@ node scripts/gsd-t-watch-state.js advance --agent-id "$GSD_T_AGENT_ID" --parent-
38
38
 
39
39
  To give this task a fresh context window and prevent compaction during consecutive quick runs, always execute via a Task subagent.
40
40
 
41
+ > **Blocking, never named.** Spawn via the `Agent` tool and WAIT for its return in this
42
+ > turn. Do NOT pass a `name` — a named agent runs in the background, its result arrives as a
43
+ > notification instead of this call's return value, and the parent is left with nothing. If you
44
+ > end up calling `ListAgents`/`SendMessage` to find the result, it was launched wrong: say so
45
+ > and re-launch blocking. (Confirmed failure: gsd-t-architect, twice.)
46
+
47
+
41
48
  **If you are the orchestrating agent** (you received the slash command directly):
42
49
 
43
50
  **Context observation (before spawning subagent):**
@@ -6,6 +6,13 @@ You are checking the current state of the project across all domains.
6
6
 
7
7
  To keep the main conversation context lean, run status via a Task subagent.
8
8
 
9
+ > **Blocking, never named.** Spawn via the `Agent` tool and WAIT for its return in this
10
+ > turn. Do NOT pass a `name` — a named agent runs in the background, its result arrives as a
11
+ > notification instead of this call's return value, and the parent is left with nothing. If you
12
+ > end up calling `ListAgents`/`SendMessage` to find the result, it was launched wrong: say so
13
+ > and re-launch blocking. (Confirmed failure: gsd-t-architect, twice.)
14
+
15
+
9
16
  **If you are the orchestrating agent** (you received the slash command directly):
10
17
  Spawn a fresh subagent using the Task tool:
11
18
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.12.11",
3
+ "version": "5.13.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",
@@ -4,6 +4,44 @@ These rules are MANDATORY. Violations fail the task. No exceptions.
4
4
 
5
5
  ---
6
6
 
7
+ ## 0. Clean The Value Where It Enters (trim first, then case)
8
+
9
+ **Every value arriving from outside the program is trimmed at the point it enters — and
10
+ case-normalised there too when it names something in the business.** Not at the comparison, and
11
+ not at the save. At the doorway.
12
+
13
+ A trailing space survives everything. It reaches the comparison (which answers "no match"), and
14
+ it reaches the database (where it outlives the code fix, breaking every later comparison
15
+ including correct ones). Cleaning at the entry point covers both, and there are a handful of
16
+ entry points against hundreds of uses.
17
+
18
+ ```ts
19
+ // GOOD — cleaned once, where it arrives
20
+ const email = (req.body.email ?? '').trim().toLowerCase();
21
+ const status = (req.query.status ?? '').trim().toLowerCase();
22
+ const note = (req.body.note ?? '').trim(); // free text: trimming is fine,
23
+ // never lowercased
24
+
25
+ // BAD — a real defect, found by the boundary check in a live project:
26
+ const email = req.body.email?.trim(); // trimmed, never lowercased.
27
+ // David@x.com and david@x.com become two different accounts.
28
+ ```
29
+
30
+ **Trim always — no exceptions by kind, passwords included.** A leading or trailing space is never
31
+ something a person meant to type; it is paste damage. Storing a password untrimmed locks them out
32
+ when they later type it normally. The ONLY values that keep their spaces are free text a person
33
+ wrote on purpose: a note, a description, a message body.
34
+
35
+ **Lowercase only business values** — never a password, token, signature, hash, encoded value, file
36
+ path, URL, object key, or environment-variable name (see §2). Case-sensitive means *do not change
37
+ the casing*; it never means *do not trim*.
38
+
39
+ **Enforced mechanically** by `gsd-t boundary-normalize` in the verify gate: FAIL-CLOSED on files
40
+ a run touched. A value that genuinely must stay raw says so at the entry point itself —
41
+ `// gsd-t-allow-raw: <reason>` — never in a separate list of exceptions somewhere else.
42
+
43
+ ---
44
+
7
45
  ## 1. Domain String Comparisons Are Case-Insensitive by Default
8
46
 
9
47
  **Comparing a domain string VALUE against a literal is case-insensitive unless the user has