@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 +62 -0
- package/README.md +1 -1
- package/bin/gsd-t-boundary-normalize-check.cjs +333 -0
- package/bin/gsd-t-verify-gate.cjs +2 -0
- package/bin/gsd-t.js +2 -0
- package/commands/gsd-t-architect.md +26 -9
- package/commands/gsd-t-health.md +7 -0
- package/commands/gsd-t-quick.md +7 -0
- package/commands/gsd-t-status.md +7 -0
- package/package.json +1 -1
- package/templates/stacks/_comparison.md +38 -0
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.
|
|
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
|
|
235
|
+
## Step 4: Launch the architect as ONE blocking subagent
|
|
236
236
|
|
|
237
|
-
Give the assessment a fresh context window. Spawn ONE
|
|
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
|
|
273
|
-
return (the subagent died
|
|
274
|
-
|
|
275
|
-
summary from your own reading of the code — a
|
|
276
|
-
fresh-context assessment the user asked for, and
|
|
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
|
|
package/commands/gsd-t-health.md
CHANGED
|
@@ -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.
|
package/commands/gsd-t-quick.md
CHANGED
|
@@ -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):**
|
package/commands/gsd-t-status.md
CHANGED
|
@@ -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.
|
|
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
|