@tekyzinc/gsd-t 5.11.31 → 5.12.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 +96 -0
- package/README.md +1 -1
- package/bin/gsd-t-graph-use-gate.cjs +239 -0
- package/bin/gsd-t-pick-worktree.cjs +98 -7
- package/bin/gsd-t-verify-gate.cjs +2 -0
- package/bin/gsd-t.js +5 -1
- package/commands/gsd-t-debug.md +1 -1
- package/commands/gsd-t-design-build.md +1 -1
- package/commands/gsd-t-quick.md +1 -1
- package/commands/gsd-t-test-sync.md +1 -1
- package/package.json +2 -2
- package/templates/CLAUDE-global.md +3 -0
- package/templates/workflows/gsd-t-integrate.workflow.js +46 -15
- package/templates/workflows/gsd-t-scan.workflow.js +32 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,102 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [5.12.10] - 2026-08-19
|
|
6
|
+
|
|
7
|
+
### Added — naming an existing worktree walks you into it
|
|
8
|
+
|
|
9
|
+
Naming a worktree that already existed used to be refused outright. That
|
|
10
|
+
refused the ordinary case — your own worktree, from yesterday, with nobody in
|
|
11
|
+
it — and left no way back except quitting the session and starting one by
|
|
12
|
+
hand, which is the exact chore the picker exists to spare you.
|
|
13
|
+
|
|
14
|
+
`--name` now ENTERS a worktree when git confirms it as this repo's, on that
|
|
15
|
+
branch, with no interactive session in it. Two cases still STOP, because each
|
|
16
|
+
is a way of landing on somebody's uncommitted work: a directory git does not
|
|
17
|
+
know as that branch's worktree (a stray folder, or another branch's), and one
|
|
18
|
+
an interactive session already occupies (the M105 collision). The occupancy
|
|
19
|
+
check already existed on the reuse path and was simply never asked on the
|
|
20
|
+
naming path — no new mechanism was added.
|
|
21
|
+
|
|
22
|
+
A path bug surfaced while testing and is fixed: git reports symlink-resolved
|
|
23
|
+
paths while ours were used as typed, so on macOS `/var` vs `/private/var` made
|
|
24
|
+
one folder compare as two. Any repo under a symlinked path would have been
|
|
25
|
+
wrongly refused. An unresolvable path now HALTS rather than answering "not a
|
|
26
|
+
match".
|
|
27
|
+
|
|
28
|
+
- `bin/gsd-t-pick-worktree.cjs`: `create()` becomes `enterOrCreate()`; new
|
|
29
|
+
`isWorktreeOf()` asks the repo's own worktree register (not the directory,
|
|
30
|
+
which a foreign checkout would answer for); new `realPath()` compares
|
|
31
|
+
symlink-resolved paths and halts when one cannot be resolved; new `--list`
|
|
32
|
+
prints `free|busy<TAB><path>` per worktree.
|
|
33
|
+
- `bin/gsd-t.js`: flags pass through unchanged; comment notes `--list`.
|
|
34
|
+
- `templates/CLAUDE-global.md`: states the enter-vs-stop rule.
|
|
35
|
+
- `.gsd-t/pseudocode/PseudoCode-EnterExistingWorktree.md`: source-of-truth
|
|
36
|
+
behaviour map, style gate clean.
|
|
37
|
+
- `test/m111-pick-worktree.test.js`: the old "refuses a directory that already
|
|
38
|
+
exists" test narrows to the two cases that still refuse; adds coverage for
|
|
39
|
+
entering a free worktree, a stray directory, a worktree on another branch,
|
|
40
|
+
and both `--list` shapes.
|
|
41
|
+
|
|
42
|
+
The launcher (`cc()` in `~/.zshrc`) shows the worktree list above the name
|
|
43
|
+
prompt, so an existing one is picked by sight rather than recalled.
|
|
44
|
+
|
|
45
|
+
## [5.11.32] - 2026-08-14
|
|
46
|
+
|
|
47
|
+
### Fixed — a missing graph is now BUILT, and a "wired" claim has to prove itself
|
|
48
|
+
|
|
49
|
+
A TimeTracking scan finished, reported success, and had done its whole deep
|
|
50
|
+
analysis with grep. The graph was healthy, current, and the run even logged
|
|
51
|
+
`graphWiringMode: WIRED` — but of 666 ledger events, every single one carried
|
|
52
|
+
`consumer: "cli"`. Not one came from an analyst agent. The reports say it
|
|
53
|
+
plainly in their own words: *"a repo-wide grep shows no caller anywhere."*
|
|
54
|
+
Nothing failed, because nothing was watching for this.
|
|
55
|
+
|
|
56
|
+
Three defects, one theme — **a check that cannot fail is not a check**:
|
|
57
|
+
|
|
58
|
+
- **An absent graph left consumers blind instead of building it.** Every
|
|
59
|
+
FAIL-LOUD clause said: don't fall back to grep, then *proceed without the
|
|
60
|
+
structural slice*. That is a third bad outcome sitting between the two the
|
|
61
|
+
doctrine names — the agent answers "what calls this?" with no structural
|
|
62
|
+
knowledge at all. The clauses in `/quick`, `/debug`, `/design-build` and
|
|
63
|
+
`/test-sync` now BUILD the index and re-run the query; only a failed build
|
|
64
|
+
halts. This was already the contract's own rule (§FAIL-LOUD line 64,
|
|
65
|
+
"REPAIR IT, do not merely halt") — the command docs had been contradicting
|
|
66
|
+
it. `[RULE] graph-absent-builds-not-degrades`
|
|
67
|
+
- **`/integrate` had a licensed exemption.** It printed `⚠ graph ABSENT —
|
|
68
|
+
structural wiring-check skipped (announced carve-out)`. A fresh worktree
|
|
69
|
+
carries no graph (it is gitignored), so the check did nothing on exactly the
|
|
70
|
+
runs it was written for. Announcing a skip does not stop it being a skip. It
|
|
71
|
+
now builds. The verify/integrate exception covers the HARD-FAIL only; it
|
|
72
|
+
never covered the build.
|
|
73
|
+
- **The anti-grep lint aimed at the wrong target.** Being static, its only
|
|
74
|
+
failure mode is a code shape (`try graph → catch → grep`). A consumer that
|
|
75
|
+
never calls the graph has no catch block, so the lint reports clean while the
|
|
76
|
+
entire run uses text search.
|
|
77
|
+
|
|
78
|
+
### Added — `gsd-t graph-use-gate` (runtime, `[RULE] wired-claim-requires-query-evidence`)
|
|
79
|
+
|
|
80
|
+
`bin/gsd-t-graph-use-gate.cjs` reads the append-only ledger and fails any
|
|
81
|
+
consumer that logged `WIRED` with zero `kind:"query"` events. It detects the
|
|
82
|
+
**absence of use**, not the presence of a fallback — the thing a static lint
|
|
83
|
+
structurally cannot see. `kind:"read"` (the Read-intercept hook) is never
|
|
84
|
+
accepted as evidence, or it would mask the very failure being detected.
|
|
85
|
+
|
|
86
|
+
Run against the two projects that prompted this, it flags both — confirming the
|
|
87
|
+
defect is systemic, not a one-off. Wired into `gsd-t-verify-gate.cjs` as the
|
|
88
|
+
FAIL-CLOSED `graph-use` check, propagated via `PROJECT_BIN_TOOLS` (it reads each
|
|
89
|
+
project's own ledger), and covered by `test/m113-graph-use-gate.test.js` — whose
|
|
90
|
+
leading test is the negative one, because a gate nobody has watched fail is a
|
|
91
|
+
gate nobody knows works. Exit `0` clean · `4` violations · `64` bad input; a
|
|
92
|
+
missing ledger is a *documented* no-op PASS under `--verify-mode`, tagged
|
|
93
|
+
`noOpPass:true` so it stays distinguishable from wired-and-clean.
|
|
94
|
+
|
|
95
|
+
**Still open:** none of this covers ordinary conversational code-reading, which
|
|
96
|
+
is where the original complaint arose — an agent asked "what calls this?" in a
|
|
97
|
+
worktree with no graph and grepped, with nothing objecting. That needs a
|
|
98
|
+
PreToolUse hook on structural Grep/Read, and is deliberately left for its own
|
|
99
|
+
milestone rather than bolted on here.
|
|
100
|
+
|
|
5
101
|
## [5.11.31] - 2026-08-11
|
|
6
102
|
|
|
7
103
|
### Added — the scanner now ranks its own findings before it numbers them
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GSD-T: Contract-Driven Development for Claude Code
|
|
2
2
|
|
|
3
|
-
**v5.
|
|
3
|
+
**v5.12.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,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gsd-t-graph-use-gate.cjs
|
|
4
|
+
*
|
|
5
|
+
* Graph USE gate — proves a consumer that declared itself WIRED actually
|
|
6
|
+
* QUERIED the graph.
|
|
7
|
+
*
|
|
8
|
+
* [RULE] wired-claim-requires-query-evidence
|
|
9
|
+
* [RULE] use-gate-detects-absence-of-use-not-presence-of-fallback
|
|
10
|
+
*
|
|
11
|
+
* WHY THIS EXISTS (the gap it closes):
|
|
12
|
+
* `gsd-t-graph-anti-grep-lint.cjs` is STATIC. It reads source files and looks
|
|
13
|
+
* for a `try graph-query -> catch -> structural grep` fallback. That shape is
|
|
14
|
+
* the only way it can fail.
|
|
15
|
+
*
|
|
16
|
+
* A real scan failed a different way. Every analyst agent simply NEVER CALLED
|
|
17
|
+
* the graph — so there was no catch block, no fallback branch, nothing for a
|
|
18
|
+
* static scan to find. The lint passed, the run logged
|
|
19
|
+
* `{"kind":"wiring","graphWiringMode":"WIRED"}`, and the whole scan ran on
|
|
20
|
+
* text search anyway. A check whose only failure mode is a code shape cannot
|
|
21
|
+
* see a consumer that never tried.
|
|
22
|
+
*
|
|
23
|
+
* This gate reads the RUNTIME ledger instead. A consumer that stamped WIRED
|
|
24
|
+
* and issued zero graph queries in that run is a violation, because "wired"
|
|
25
|
+
* is a claim about behaviour and the ledger is the only record of behaviour.
|
|
26
|
+
*
|
|
27
|
+
* INPUT : .gsd-t/graphDB/logs/graph-events-*.jsonl (append-only, JSONL)
|
|
28
|
+
* OUTPUT : {ok, violations:[{consumer, wiringMode, queryCount, firstWiringTs}], ...}
|
|
29
|
+
* EXIT : 0 clean | 4 violations | 64 bad input (no ledger / unreadable)
|
|
30
|
+
*
|
|
31
|
+
* Unreadable input THROWS (LedgerUnavailable) rather than returning a value —
|
|
32
|
+
* a gate that hands back a result when it could not read its own evidence is
|
|
33
|
+
* the exact failure mode this file exists to remove.
|
|
34
|
+
*
|
|
35
|
+
* Node built-ins only (zero-dep invariant).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
'use strict';
|
|
39
|
+
|
|
40
|
+
const fs = require('fs');
|
|
41
|
+
const path = require('path');
|
|
42
|
+
|
|
43
|
+
// A `kind:"read"` event is the Read-intercept hook, NOT a structural query — it
|
|
44
|
+
// fires on ordinary file reads and would mask the exact failure this gate exists
|
|
45
|
+
// to catch. Only `kind:"query"` counts as evidence that the graph was consulted.
|
|
46
|
+
const EVIDENCE_KIND = 'query';
|
|
47
|
+
const WIRING_KIND = 'wiring';
|
|
48
|
+
|
|
49
|
+
// `cli` is the operator at a terminal, not a workflow consumer; it has no WIRED
|
|
50
|
+
// claim to honour. Consumers are exempt ONLY by explicit name here.
|
|
51
|
+
const NON_CONSUMER_IDS = new Set(['cli']);
|
|
52
|
+
|
|
53
|
+
/** Thrown when the ledger cannot be located or read. Never swallowed internally. */
|
|
54
|
+
class LedgerUnavailable extends Error {
|
|
55
|
+
constructor(message) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = 'LedgerUnavailable';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ledgerDir(projectDir) {
|
|
62
|
+
return path.join(projectDir, '.gsd-t', 'graphDB', 'logs');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Resolve every graph-events-*.jsonl file, oldest first. Throws if none. */
|
|
66
|
+
function ledgerFiles(projectDir) {
|
|
67
|
+
const dir = ledgerDir(projectDir);
|
|
68
|
+
let names;
|
|
69
|
+
try {
|
|
70
|
+
names = fs.readdirSync(dir);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
throw new LedgerUnavailable(`graph event ledger not readable: ${dir} (${err.code || err.message})`);
|
|
73
|
+
}
|
|
74
|
+
const files = names
|
|
75
|
+
.filter((n) => /^graph-events-\d+\.jsonl$/.test(n))
|
|
76
|
+
.sort()
|
|
77
|
+
.map((n) => path.join(dir, n));
|
|
78
|
+
if (files.length === 0) {
|
|
79
|
+
throw new LedgerUnavailable(`no graph-events-*.jsonl in ${dir}`);
|
|
80
|
+
}
|
|
81
|
+
return files;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parse the ledger into per-consumer counters.
|
|
86
|
+
* A malformed line is SKIPPED and COUNTED — it is never treated as a query,
|
|
87
|
+
* because counting an unparseable line as evidence would let a corrupt ledger
|
|
88
|
+
* satisfy the gate.
|
|
89
|
+
*/
|
|
90
|
+
function readLedger(files, sinceTs) {
|
|
91
|
+
const consumers = Object.create(null); // prototype-less: consumer ids come from a file
|
|
92
|
+
let malformed = 0;
|
|
93
|
+
let total = 0;
|
|
94
|
+
|
|
95
|
+
const ensure = (id) => {
|
|
96
|
+
if (!consumers[id]) {
|
|
97
|
+
consumers[id] = { consumer: id, queryCount: 0, wiringModes: [], firstWiringTs: null };
|
|
98
|
+
}
|
|
99
|
+
return consumers[id];
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
for (const file of files) {
|
|
103
|
+
let text;
|
|
104
|
+
try {
|
|
105
|
+
text = fs.readFileSync(file, 'utf8');
|
|
106
|
+
} catch (err) {
|
|
107
|
+
throw new LedgerUnavailable(`cannot read ${file}: ${err.code || err.message}`);
|
|
108
|
+
}
|
|
109
|
+
for (const line of text.split('\n')) {
|
|
110
|
+
if (!line.trim()) continue;
|
|
111
|
+
total++;
|
|
112
|
+
let ev;
|
|
113
|
+
try {
|
|
114
|
+
ev = JSON.parse(line);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
malformed++;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (sinceTs && typeof ev.ts === 'string' && ev.ts < sinceTs) continue;
|
|
120
|
+
|
|
121
|
+
const id = typeof ev.consumer === 'string' && ev.consumer ? ev.consumer : 'unknown';
|
|
122
|
+
const kind = String(ev.kind || '');
|
|
123
|
+
|
|
124
|
+
if (kind === EVIDENCE_KIND) {
|
|
125
|
+
ensure(id).queryCount++;
|
|
126
|
+
} else if (kind === WIRING_KIND) {
|
|
127
|
+
const c = ensure(id);
|
|
128
|
+
c.wiringModes.push(String(ev.graphWiringMode || ''));
|
|
129
|
+
if (!c.firstWiringTs && typeof ev.ts === 'string') c.firstWiringTs = ev.ts;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { consumers, malformed, total };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A consumer violates the gate when it CLAIMED wired and issued ZERO queries.
|
|
138
|
+
* Claiming `fallback-announced` or `disabled` is not a violation here — those
|
|
139
|
+
* are honest declarations, and the anti-grep lint governs whether the fallback
|
|
140
|
+
* itself is permitted.
|
|
141
|
+
*/
|
|
142
|
+
function evaluate(consumers) {
|
|
143
|
+
const violations = [];
|
|
144
|
+
const checked = [];
|
|
145
|
+
|
|
146
|
+
for (const id of Object.keys(consumers)) {
|
|
147
|
+
if (NON_CONSUMER_IDS.has(id)) continue;
|
|
148
|
+
const c = consumers[id];
|
|
149
|
+
if (c.wiringModes.length === 0) continue; // never declared; nothing claimed, nothing to prove
|
|
150
|
+
|
|
151
|
+
const claimedWired = c.wiringModes.some((m) => m.toLowerCase() === 'wired');
|
|
152
|
+
checked.push({ consumer: id, wiringModes: c.wiringModes, queryCount: c.queryCount });
|
|
153
|
+
|
|
154
|
+
if (claimedWired && c.queryCount === 0) {
|
|
155
|
+
violations.push({
|
|
156
|
+
consumer: id,
|
|
157
|
+
wiringMode: 'WIRED',
|
|
158
|
+
queryCount: 0,
|
|
159
|
+
firstWiringTs: c.firstWiringTs,
|
|
160
|
+
evidence:
|
|
161
|
+
`consumer "${id}" logged graphWiringMode=WIRED but issued 0 graph queries ` +
|
|
162
|
+
`(kind:"query") in this window — a WIRED claim with no query evidence means ` +
|
|
163
|
+
`the structural question was answered some other way.`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { violations, checked };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Throws LedgerUnavailable when the evidence cannot be read. */
|
|
171
|
+
function runGate(opts = {}) {
|
|
172
|
+
const projectDir = opts.projectDir || process.cwd();
|
|
173
|
+
|
|
174
|
+
// Test override: callers may inject parsed consumers directly.
|
|
175
|
+
if (opts.consumers) {
|
|
176
|
+
const { violations, checked } = evaluate(opts.consumers);
|
|
177
|
+
return { ok: violations.length === 0, violations, checked, malformed: 0, ledgerFiles: [] };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const files = ledgerFiles(projectDir);
|
|
181
|
+
const { consumers, malformed } = readLedger(files, opts.since || null);
|
|
182
|
+
const { violations, checked } = evaluate(consumers);
|
|
183
|
+
return { ok: violations.length === 0, violations, checked, malformed, ledgerFiles: files };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── CLI entry point ─────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
if (require.main === module) {
|
|
189
|
+
const argv = process.argv.slice(2);
|
|
190
|
+
const opts = { projectDir: process.cwd() };
|
|
191
|
+
for (let i = 0; i < argv.length; i++) {
|
|
192
|
+
if (argv[i] === '--project' || argv[i] === '--repo') opts.projectDir = argv[++i];
|
|
193
|
+
else if (argv[i] === '--since') opts.since = argv[++i];
|
|
194
|
+
else if (argv[i] === '--verify-mode') opts.verifyMode = true;
|
|
195
|
+
else if (!argv[i].startsWith('-')) opts.projectDir = argv[i];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let result;
|
|
199
|
+
try {
|
|
200
|
+
result = runGate(opts);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (err instanceof LedgerUnavailable) {
|
|
203
|
+
// No ledger means NO CONSUMER HAS RUN YET — nothing has been claimed, so
|
|
204
|
+
// there is nothing to disprove. That is a documented no-op PASS in
|
|
205
|
+
// --verify-mode, distinguishable in the JSON (`noOpPass:true`) from a
|
|
206
|
+
// wired-but-broken run. Invoked directly it is BAD INPUT (exit 64): an
|
|
207
|
+
// operator asking about evidence that does not exist deserves to be told,
|
|
208
|
+
// not handed a clean bill.
|
|
209
|
+
if (opts.verifyMode) {
|
|
210
|
+
process.stdout.write(JSON.stringify(
|
|
211
|
+
{ ok: true, noOpPass: true, reason: 'no-graph-event-ledger', violations: [], checked: [] }, null, 2) + '\n');
|
|
212
|
+
process.stderr.write('[graph-use-gate] PASS (no-op): no graph event ledger — no consumer has claimed WIRED yet.\n');
|
|
213
|
+
process.exit(0);
|
|
214
|
+
}
|
|
215
|
+
process.stderr.write(`[graph-use-gate] ERROR: ${err.message}\n`);
|
|
216
|
+
process.stdout.write(JSON.stringify({ ok: false, violations: [], error: err.message }, null, 2) + '\n');
|
|
217
|
+
process.exit(64);
|
|
218
|
+
}
|
|
219
|
+
throw err;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
223
|
+
|
|
224
|
+
if (!result.ok) {
|
|
225
|
+
process.stderr.write(
|
|
226
|
+
`[graph-use-gate] FAIL: ${result.violations.length} consumer(s) claimed WIRED with zero graph queries.\n`
|
|
227
|
+
);
|
|
228
|
+
for (const v of result.violations) {
|
|
229
|
+
process.stderr.write(` ${v.consumer}: ${v.evidence}\n`);
|
|
230
|
+
}
|
|
231
|
+
process.exit(4);
|
|
232
|
+
}
|
|
233
|
+
process.stderr.write(
|
|
234
|
+
`[graph-use-gate] PASS: ${result.checked.length} declaring consumer(s), all WIRED claims backed by queries.\n`
|
|
235
|
+
);
|
|
236
|
+
process.exit(0);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = { runGate, readLedger, evaluate, ledgerFiles, LedgerUnavailable, EVIDENCE_KIND, NON_CONSUMER_IDS };
|
|
@@ -12,12 +12,23 @@
|
|
|
12
12
|
* d=$(gsd-t-pick-worktree) && [ -n "$d" ] && cd "$d"
|
|
13
13
|
* claude
|
|
14
14
|
*
|
|
15
|
-
*
|
|
15
|
+
* Four modes:
|
|
16
16
|
*
|
|
17
17
|
* (no flags) pick a worktree, creating one if none is free
|
|
18
18
|
* --suggest say what WOULD happen, create nothing. Prints
|
|
19
19
|
* "reuse:<path>", "create", or nothing at all.
|
|
20
|
-
* --name <name>
|
|
20
|
+
* --name <name> go to the worktree on a branch called <name>, creating
|
|
21
|
+
* it if it isn't there yet
|
|
22
|
+
* --list list this project's worktrees, one per line, as
|
|
23
|
+
* "<free|busy>\t<path>". Creates nothing.
|
|
24
|
+
*
|
|
25
|
+
* --name naming a worktree that already exists ENTERS it. Refusing that was the
|
|
26
|
+
* old behaviour and it refused the ordinary case — your own worktree, from
|
|
27
|
+
* yesterday, with nobody in it — leaving no way back in except quitting and
|
|
28
|
+
* starting a session by hand, which is the thing this script exists to spare
|
|
29
|
+
* you. What the refusal genuinely protected is narrower: another live session
|
|
30
|
+
* sitting in that folder, or a directory git does not know as this branch's
|
|
31
|
+
* worktree. Both still stop.
|
|
21
32
|
*
|
|
22
33
|
* --suggest exists so the shell can ask for a name BEFORE anything is created.
|
|
23
34
|
* A branch named at session start, before the work is known, can only be a
|
|
@@ -64,6 +75,7 @@ function fail(message) { // could not decide — say why, change nothing
|
|
|
64
75
|
function main() {
|
|
65
76
|
const argv = process.argv.slice(2);
|
|
66
77
|
const suggest = argv.includes("--suggest");
|
|
78
|
+
const wantsList = argv.includes("--list");
|
|
67
79
|
const nameAt = argv.indexOf("--name");
|
|
68
80
|
const wanted = nameAt >= 0 ? argv[nameAt + 1] : null;
|
|
69
81
|
|
|
@@ -78,6 +90,17 @@ function main() {
|
|
|
78
90
|
|
|
79
91
|
const home = path.join(process.env.HOME, "Worktrees", path.basename(cwd));
|
|
80
92
|
|
|
93
|
+
// Report what is there so the prompt can show it before a name is typed.
|
|
94
|
+
// Nothing here is a path for the shell to cd into, so each line is labelled;
|
|
95
|
+
// the caller reads this one deliberately rather than as a bare directory.
|
|
96
|
+
if (wantsList) {
|
|
97
|
+
const busy = interactiveClaudeDirs();
|
|
98
|
+
for (const w of worktreesNewestFirst(home)) {
|
|
99
|
+
process.stdout.write(`${busy.has(w.path) ? "busy" : "free"}\t${w.path}\n`);
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
81
104
|
if (wanted) {
|
|
82
105
|
// Asking for the repo's own default branch means "work here, in the main
|
|
83
106
|
// checkout" — not "make a worktree called main", which git refuses anyway
|
|
@@ -94,7 +117,7 @@ function main() {
|
|
|
94
117
|
);
|
|
95
118
|
stay();
|
|
96
119
|
}
|
|
97
|
-
process.stdout.write(
|
|
120
|
+
process.stdout.write(enterOrCreate(cwd, home, branchNameFrom(wanted)).path + "\n");
|
|
98
121
|
return;
|
|
99
122
|
}
|
|
100
123
|
|
|
@@ -202,16 +225,30 @@ function worktreesNewestFirst(home) {
|
|
|
202
225
|
// timestamp, and a timestamp describes nothing — which is how this repo
|
|
203
226
|
// collected twenty `session-2026-08-08T23-10-16` branches. Callers with no name
|
|
204
227
|
// get "create" from --suggest and must come back with --name.
|
|
205
|
-
function
|
|
228
|
+
function enterOrCreate(repo, home, branch) {
|
|
206
229
|
if (!branch) {
|
|
207
230
|
fail("A worktree needs a branch name — run with --name <name>.");
|
|
208
231
|
}
|
|
209
232
|
const dest = path.join(home, branch);
|
|
210
233
|
|
|
211
|
-
//
|
|
212
|
-
//
|
|
234
|
+
// Already there: go in, provided it is genuinely this branch's worktree and
|
|
235
|
+
// nobody is working in it. Both conditions are checked before entering, since
|
|
236
|
+
// each is a way of landing on top of somebody's uncommitted work.
|
|
213
237
|
if (fs.existsSync(dest)) {
|
|
214
|
-
|
|
238
|
+
if (!isWorktreeOf(repo, dest, branch)) {
|
|
239
|
+
fail(
|
|
240
|
+
`${dest} exists but git does not know it as this repo's worktree for ` +
|
|
241
|
+
`"${branch}". Working there would sit on top of whatever is in it — ` +
|
|
242
|
+
`move it aside, or pick a different name.`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
if (interactiveClaudeDirs().has(dest)) {
|
|
246
|
+
fail(
|
|
247
|
+
`Another session is working in ${dest} right now. Two sessions in one ` +
|
|
248
|
+
`folder interleave each other's uncommitted work — pick a different name.`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
return { path: dest };
|
|
215
252
|
}
|
|
216
253
|
|
|
217
254
|
fs.mkdirSync(home, { recursive: true });
|
|
@@ -235,6 +272,60 @@ function create(repo, home, branch) {
|
|
|
235
272
|
return { path: dest };
|
|
236
273
|
}
|
|
237
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Does THIS repo know `dest` as its worktree for `branch`?
|
|
277
|
+
*
|
|
278
|
+
* Asked of the main repo rather than of the directory: a folder can be a
|
|
279
|
+
* perfectly valid git checkout of something else entirely, and it would answer
|
|
280
|
+
* "yes, I am a worktree on that branch" while belonging to another project.
|
|
281
|
+
* The repo's own register is the only place that settles ownership.
|
|
282
|
+
*
|
|
283
|
+
* A git that cannot answer is a stop, not a "probably fine" — the unanswered
|
|
284
|
+
* question is precisely whether somebody's work is already there.
|
|
285
|
+
*/
|
|
286
|
+
function isWorktreeOf(repo, dest, branch) {
|
|
287
|
+
const r = spawnSync("git", ["worktree", "list", "--porcelain"], {
|
|
288
|
+
cwd: repo, encoding: "utf8", timeout: 10000,
|
|
289
|
+
});
|
|
290
|
+
if (r.status !== 0) {
|
|
291
|
+
fail(
|
|
292
|
+
`Cannot read this repo's worktree list ` +
|
|
293
|
+
`(${String(r.stderr).trim() || "git failed"}), so it can't be confirmed ` +
|
|
294
|
+
`that ${dest} is yours to work in.`
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Entries are blank-line separated; the lines that matter are "worktree
|
|
299
|
+
// <path>" and "branch refs/heads/<name>".
|
|
300
|
+
const target = realPath(dest);
|
|
301
|
+
for (const block of String(r.stdout).split(/\n\s*\n/)) {
|
|
302
|
+
const at = block.match(/^worktree (.+)$/m);
|
|
303
|
+
if (!at) continue;
|
|
304
|
+
if (realPath(at[1].trim()) !== target) continue;
|
|
305
|
+
const on = block.match(/^branch refs\/heads\/(.+)$/m);
|
|
306
|
+
return Boolean(on) && on[1].trim() === branch;
|
|
307
|
+
}
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The path with every symlink followed, so two spellings of one directory
|
|
313
|
+
* compare equal. git reports resolved paths; ours are as typed, and on macOS
|
|
314
|
+
* /var is a symlink to /private/var — so the same folder arrives under two
|
|
315
|
+
* names and a plain string compare calls them different places.
|
|
316
|
+
*
|
|
317
|
+
* A path that cannot be resolved stops the run. The comparison it feeds decides
|
|
318
|
+
* whether a directory is safe to work in, and an unresolvable path leaves that
|
|
319
|
+
* unanswered rather than answered "no".
|
|
320
|
+
*/
|
|
321
|
+
function realPath(p) {
|
|
322
|
+
try {
|
|
323
|
+
return fs.realpathSync(p);
|
|
324
|
+
} catch (e) {
|
|
325
|
+
fail(`Cannot resolve ${p} (${e.message}), so it can't be told apart from another directory.`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
238
329
|
/**
|
|
239
330
|
* A worktree holds only what git tracks, so the secrets and the installed
|
|
240
331
|
* dependencies stay behind and the new folder is born unable to run. Carry the
|
|
@@ -313,6 +313,8 @@ function _detectDefaultTrack2(projectDir, notes) {
|
|
|
313
313
|
|
|
314
314
|
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
315
|
|
|
316
|
+
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.
|
|
317
|
+
|
|
316
318
|
// secrets — gitleaks (PATH detection deferred to runtime)
|
|
317
319
|
if (_hasOnPath('gitleaks')) {
|
|
318
320
|
plan.push({
|
package/bin/gsd-t.js
CHANGED
|
@@ -3487,6 +3487,10 @@ const PROJECT_BIN_TOOLS = [
|
|
|
3487
3487
|
// every Write/Edit is DENIED (the guard halts rather than allowing unchecked),
|
|
3488
3488
|
// so this entry is load-bearing — [[project_global_bin_propagation_gap]].
|
|
3489
3489
|
"gsd-t-fallback-detect.cjs",
|
|
3490
|
+
// M113 — Graph USE gate. The verify gate dispatches to it by absolute path,
|
|
3491
|
+
// and it reads the project's OWN .gsd-t/graphDB/logs ledger, so it must live
|
|
3492
|
+
// in the project — [[project_global_bin_propagation_gap]].
|
|
3493
|
+
"gsd-t-graph-use-gate.cjs",
|
|
3490
3494
|
// M107 — Concise rewriter, invoked by the Stop hook.
|
|
3491
3495
|
// M108 — Install self-check. Every project carries its own copy so it can
|
|
3492
3496
|
// verify and repair itself even when the global install is what broke.
|
|
@@ -5654,7 +5658,7 @@ if (require.main === module) {
|
|
|
5654
5658
|
case "pick-worktree": {
|
|
5655
5659
|
const picker = path.join(PKG_ROOT, "bin", "gsd-t-pick-worktree.cjs");
|
|
5656
5660
|
try {
|
|
5657
|
-
// Pass the flags through — --suggest and --
|
|
5661
|
+
// Pass the flags through — --suggest, --name and --list live on the picker.
|
|
5658
5662
|
execFileSync(process.execPath, [picker, ...args.slice(1)], { stdio: "inherit" });
|
|
5659
5663
|
} catch (e) {
|
|
5660
5664
|
// The picker prints its own reason on stderr and exits non-zero when it
|
package/commands/gsd-t-debug.md
CHANGED
|
@@ -98,7 +98,7 @@ Debug applies the **WRITER pattern** from `graph-consumer-wiring-contract.md`:
|
|
|
98
98
|
|
|
99
99
|
**WRITER half (re-index after fix):** After the fix lands, the workflow triggers a re-index of the edited files so downstream graph queries see fresh edges (`graph-freshness-contract.md` D4 surface — `freshness_check_on_query` over the touched set). `[RULE] debug-reader-and-writer-both`.
|
|
100
100
|
|
|
101
|
-
**
|
|
101
|
+
**BUILD-THEN-QUERY on graph-absent:** On `{ok:false, reason:"graph-unavailable"}` the debug workflow **builds the index right then** (`gsd-t graph index`) and re-runs the localization query. It does NOT fall back to grep for the structural question, and it does NOT skip the query. Only a **failed build** halts the graph-query step, with the build's own stderr surfaced. The existing debug-loop logic (2-cycle cap, loop-ledger halt) is NOT disrupted — the graph query is additive, injected before the fix agent receives context. `[RULE] graph-absent-builds-not-degrades`.
|
|
102
102
|
|
|
103
103
|
## Contract-Boundary Debugging
|
|
104
104
|
|
|
@@ -10,7 +10,7 @@ Design-build applies the **WRITER pattern** from `graph-consumer-wiring-contract
|
|
|
10
10
|
|
|
11
11
|
**WRITER half:** After each tier (elements → widgets → pages) generates files, the pipeline triggers a re-index of the generated files (`freshness_check_on_query` from `graph-freshness-contract.md` D4 surface) so the next tier's `who-imports` / `cluster` query sees the new edges from the generated components. `[RULE] design-build-writer-pattern`.
|
|
12
12
|
|
|
13
|
-
**
|
|
13
|
+
**BUILD-THEN-QUERY on graph-absent:** On `{ok:false, reason:"graph-unavailable"}` the pipeline **builds the index right then** (`gsd-t graph index`) and re-runs the structural query. It does NOT fall back to grep, and it does NOT proceed without the structural answer. Only a **failed build** halts, with the build's own stderr surfaced. `[RULE] consumer-structural-grep-removed`, `[RULE] graph-absent-builds-not-degrades`.
|
|
14
14
|
|
|
15
15
|
## Step 1: Launch the Orchestrator
|
|
16
16
|
|
package/commands/gsd-t-quick.md
CHANGED
|
@@ -182,7 +182,7 @@ Quick applies the **WRITER pattern** from `graph-consumer-wiring-contract.md`:
|
|
|
182
182
|
|
|
183
183
|
**WRITER half:** After edits land, the workflow triggers a re-index of the touched files (`freshness_check_on_query` from `graph-freshness-contract.md` D4 surface) so downstream graph queries see fresh edges. `[RULE] quick-writer-pattern`.
|
|
184
184
|
|
|
185
|
-
**
|
|
185
|
+
**BUILD-THEN-QUERY on graph-absent:** On `{ok:false, reason:"graph-unavailable"}` the workflow **builds the index right then** (`gsd-t graph index`) and re-runs the query. It does NOT fall back to grep, and it does NOT proceed without the structural slice — proceeding blind is a third failure mode, not a safe default. Only a **failed build** halts, with the build's own stderr surfaced. `[RULE] consumer-structural-grep-removed`, `[RULE] graph-absent-builds-not-degrades`.
|
|
186
186
|
|
|
187
187
|
## Step 1.5: Graph-Enhanced Scope Check
|
|
188
188
|
|
|
@@ -40,7 +40,7 @@ Test-sync applies the **WRITER pattern** from `graph-consumer-wiring-contract.md
|
|
|
40
40
|
|
|
41
41
|
**WRITER half:** After writing or updating tests, trigger a re-index of the edited test files (`freshness_check_on_query` from `graph-freshness-contract.md` D4 surface) so the next `test-impl` query sees the updated call-site edges from the new test code. `[RULE] test-sync-uses-test-impl-verb`.
|
|
42
42
|
|
|
43
|
-
**
|
|
43
|
+
**BUILD-THEN-QUERY on graph-absent:** On `{ok:false, reason:"graph-unavailable"}` the workflow **builds the index right then** (`gsd-t graph index`) and re-runs the `test-impl` / `untested-impl` queries. Filesystem discovery is NOT an acceptable substitute — announcing a fallback does not stop it being one, and "which test exercises which impl" answered by filename convention is exactly the wrong-answer class the graph exists to remove. Only a **failed build** halts, with the build's own stderr surfaced. `[RULE] consumer-structural-grep-removed`, `[RULE] graph-absent-builds-not-degrades`.
|
|
44
44
|
|
|
45
45
|
## Step 1.5: Graph-Enhanced Test Discovery
|
|
46
46
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekyzinc/gsd-t",
|
|
3
|
-
"version": "5.
|
|
4
|
-
"description": "GSD-T: Contract-Driven Development for Claude Code
|
|
3
|
+
"version": "5.12.10",
|
|
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",
|
|
7
7
|
"repository": {
|
|
@@ -154,6 +154,9 @@ WHEN creating a worktree directly (git worktree add, isolation: "worktree", etc.
|
|
|
154
154
|
|
|
155
155
|
**A worktree is NOT usable until it is provisioned (M112).** `git worktree add` brings only what git TRACKS, so every ignored file stays behind — `.env` and its secrets, local settings, and the installed dependencies. The new folder looks complete and is not: tests fail on a missing module and the app cannot reach its database, both reading as broken code rather than a setup gap. `bin/gsd-t-worktree-provision.cjs` runs automatically inside `gsd-t-pick-worktree` and treats the three kinds of ignored file differently — **CARRY** local config and secrets (`.env*`, `.npmrc`, credentials; permissions preserved, so a 600 secret does not become world-readable), **SKIP** per-session `.gsd-t` state, build output and OS junk (copying another session's briefs/heartbeats is how two sessions come to believe they own the same work), and **INSTALL** dependencies from the worktree's own lockfile rather than copying or symlinking (a symlink makes a lockfile change in one tree silently alter the other). Anything it cannot read or copy is REPORTED, and a failed install HALTS — a worktree that came up short says so instead of looking ready. Creating a worktree by hand skips all of this, so prefer `gsd-t-pick-worktree --name <branch>`.
|
|
156
156
|
|
|
157
|
+
**Naming an existing worktree WALKS YOU INTO IT (M113).** `gsd-t pick-worktree --name <branch>` used to refuse whenever the folder was already there, which refused the ordinary case — your own worktree, from yesterday, nobody in it — and left no way back except quitting the session and starting one by hand. It now ENTERS a worktree git confirms as this repo's, on that branch, with no interactive session in it. Two cases still STOP, because each is a way of landing on somebody's uncommitted work: a directory git does not know as that branch's worktree (a stray folder, or another branch's), and a worktree an interactive session already occupies (the M105 collision). `gsd-t pick-worktree --list` prints one line per worktree as `free|busy<TAB><path>`, so the launcher can show what exists before asking for a name.
|
|
158
|
+
|
|
159
|
+
|
|
157
160
|
**The expected-branch rule governs the MAIN checkout only (M112).** A worktree exists precisely to be on its own branch, so `branch-guard` passes there and names the skip; a **detached HEAD in a worktree FAILS**, because commits made with no branch attached are easily lost. The rule is read from the project CLAUDE.md in either shape — a sentence (`Expected branch: main`) or a table row (`| Expected branch | main |`) — and when no rule is declared the check says `NOT CHECKED` rather than returning a bare pass.
|
|
158
161
|
|
|
159
162
|
# Destructive Action Guard (MANDATORY)
|
|
@@ -79,6 +79,15 @@ async function classifyGraphFailure(projectDir, reason, detail, phaseName) {
|
|
|
79
79
|
if (env && (env.state === "ABSENT" || env.state === "BROKEN")) return env.state;
|
|
80
80
|
return "BROKEN";
|
|
81
81
|
}
|
|
82
|
+
// [RULE] absent-graph-auto-builds-once — build the index once via the existing
|
|
83
|
+
// `gsd-t graph index` path (local bin: gsd-t-graph-index.cjs build --repo <dir>).
|
|
84
|
+
async function buildGraphIndex(projectDir, phaseName) {
|
|
85
|
+
const r = await runCli(
|
|
86
|
+
projectDir, "graph index", ["build", "--repo", projectDir],
|
|
87
|
+
"gsd-t-graph-index.cjs", "graph-index", false, phaseName
|
|
88
|
+
).catch(() => null);
|
|
89
|
+
return !!(r && r.ok);
|
|
90
|
+
}
|
|
82
91
|
async function runVerifyGate(projectDir, label = "verify-gate", phaseName) { return runCli(projectDir, "verify-gate", ["--json"], "gsd-t-verify-gate.cjs", label, true, phaseName); }
|
|
83
92
|
async function generateBrief(projectDir, { kind = "execute", milestone, domain, id, label = "brief", phaseName } = {}) {
|
|
84
93
|
const argv = ["--kind", kind, "--spawn-id", id, "--out", `${projectDir}/.gsd-t/briefs/${id}.json`];
|
|
@@ -133,32 +142,54 @@ await persistWiringMode("Preflight");
|
|
|
133
142
|
|
|
134
143
|
// M94-D10-T6: Graph Structural Slice — who-imports + blast-radius (ADDITIVE, announced-degradation)
|
|
135
144
|
// [RULE] integrate-uses-graph-for-wiring-verification
|
|
136
|
-
// [RULE] verify-integrate-graph-additive-announced-not-hard-fail —
|
|
137
|
-
//
|
|
145
|
+
// [RULE] verify-integrate-graph-additive-announced-not-hard-fail — integrate does NOT
|
|
146
|
+
// hard-fail on a graph it cannot use. It DOES build an ABSENT index first
|
|
147
|
+
// ([RULE] graph-absent-builds-not-degrades); only a BROKEN graph, or a build that
|
|
148
|
+
// itself fails, degrades announced.
|
|
138
149
|
let _graphWhoImportsSlice = null;
|
|
139
150
|
let _graphBlastRadiusSlice = null;
|
|
140
151
|
let _graphIntegrateWarning = null;
|
|
141
152
|
|
|
142
153
|
{
|
|
143
|
-
|
|
154
|
+
// [RULE] graph-absent-builds-not-degrades — an ABSENT graph is BUILT here, not
|
|
155
|
+
// skipped. The former "announced carve-out" skipped the structural wiring-check
|
|
156
|
+
// whenever the index had never been built — which is exactly when integrate most
|
|
157
|
+
// needs it, because a fresh worktree carries no graph (it is gitignored). The
|
|
158
|
+
// check therefore did nothing on the very runs it was written for. Announcing a
|
|
159
|
+
// skip does not stop it being a skip.
|
|
160
|
+
let wiEnv = (await runCli(
|
|
144
161
|
projectDir, "graph who-imports", [], "gsd-t-graph-query-cli.cjs",
|
|
145
162
|
"graph:who-imports", true, "Integrate"
|
|
146
|
-
);
|
|
147
|
-
|
|
148
|
-
if (wiEnv.ok ===
|
|
149
|
-
|
|
150
|
-
log(`M94 graph who-imports: ${(wiEnv.results || []).length} result(s) (tier: ${wiEnv.tier || "?"})`);
|
|
151
|
-
} else if (wiEnv.ok === false) {
|
|
152
|
-
// [RULE] one-availability-classifier — distinguish ABSENT (announced skip) from BROKEN (LOUD).
|
|
163
|
+
)).envelope || {};
|
|
164
|
+
|
|
165
|
+
if (wiEnv.ok === false) {
|
|
166
|
+
// [RULE] one-availability-classifier — ONE classify call, ONE state, no re-derivation.
|
|
153
167
|
const _state = await classifyGraphFailure(projectDir, wiEnv.reason, wiEnv.detail, "Integrate");
|
|
154
|
-
if (_state === "
|
|
155
|
-
|
|
168
|
+
if (_state === "ABSENT") {
|
|
169
|
+
log("M94 graph who-imports: index ABSENT (never indexed) — building it now (gsd-t graph index)...");
|
|
170
|
+
if (await buildGraphIndex(projectDir, "Integrate")) {
|
|
171
|
+
wiEnv = (await runCli(
|
|
172
|
+
projectDir, "graph who-imports", [], "gsd-t-graph-query-cli.cjs",
|
|
173
|
+
"graph:who-imports", true, "Integrate"
|
|
174
|
+
)).envelope || {};
|
|
175
|
+
if (wiEnv.ok !== true) {
|
|
176
|
+
_graphIntegrateWarning = `⚠ graph built but who-imports still failed (reason=${wiEnv.reason || "?"}) — structural wiring-check skipped. FIX it (gsd-t graph status).`;
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
// A failed BUILD is loud, and distinct from a merely-missing index.
|
|
180
|
+
_graphIntegrateWarning = "⚠ graph ABSENT and `gsd-t graph index` FAILED — structural wiring-check cannot run. FIX it (gsd-t graph status).";
|
|
181
|
+
}
|
|
156
182
|
} else {
|
|
157
|
-
_graphIntegrateWarning =
|
|
183
|
+
_graphIntegrateWarning = `⚠ graph BROKEN (reason=${wiEnv.reason || "?"}) — structural wiring-check skipped. This is NOT merely un-indexed; FIX it (gsd-t graph status).`;
|
|
158
184
|
}
|
|
159
|
-
|
|
160
|
-
} else {
|
|
185
|
+
} else if (wiEnv.ok !== true) {
|
|
161
186
|
_graphIntegrateWarning = `⚠ graph who-imports query unexpected envelope (reason: ${wiEnv.reason || "?"}); structural wiring-check skipped`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (wiEnv.ok === true) {
|
|
190
|
+
_graphWhoImportsSlice = wiEnv;
|
|
191
|
+
log(`M94 graph who-imports: ${(wiEnv.results || []).length} result(s) (tier: ${wiEnv.tier || "?"})`);
|
|
192
|
+
} else {
|
|
162
193
|
log(`M94 graph who-imports: ${_graphIntegrateWarning}`);
|
|
163
194
|
}
|
|
164
195
|
|
|
@@ -1309,12 +1309,16 @@ const ARCHITECT_SCHEMA = {
|
|
|
1309
1309
|
additionalProperties: true,
|
|
1310
1310
|
properties: {
|
|
1311
1311
|
key: { type: "string", description: "short id, e.g. R1" },
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1312
|
+
// Short by contract, not by request. The first architect run produced
|
|
1313
|
+
// headings like "Doors that check who you are but never which flight
|
|
1314
|
+
// school you belong to" over 250-word explanations; a reader scanning
|
|
1315
|
+
// 28 roots for the one to fix next cannot skim that.
|
|
1316
|
+
name: { type: "string", maxLength: 40, description: "2-5 word noun phrase, e.g. 'Missing tenant scoping'. NOT a sentence." },
|
|
1317
|
+
why: { type: "string", maxLength: 320, description: "ONE sentence naming the shared mistake, plus the file:line citations that prove it. Keep every citation; compress the prose around them." },
|
|
1318
|
+
fix: { type: "string", maxLength: 200, description: "ONE sentence: the single change that closes them all." },
|
|
1315
1319
|
tier: { type: "string", enum: ["EXTREME", "CRITICAL", "HIGH", "MEDIUM", "LOW", "extreme", "critical", "high", "medium", "low"] },
|
|
1316
1320
|
rank: { type: "integer", description: "rank among roots of the same tier, 1 = worst" },
|
|
1317
|
-
rankReason:{ type: "string" },
|
|
1321
|
+
rankReason:{ type: "string", maxLength: 220, description: "ONE sentence: why this root outranks the ones below it in its tier." },
|
|
1318
1322
|
},
|
|
1319
1323
|
},
|
|
1320
1324
|
},
|
|
@@ -1413,7 +1417,8 @@ async function architectPass(findings) {
|
|
|
1413
1417
|
`FINDINGS:`,
|
|
1414
1418
|
listing,
|
|
1415
1419
|
``,
|
|
1416
|
-
`GROUP BY ROOT CAUSE. A root is one underlying cause where ONE fix closes several findings — e.g. many routes missing the same tenant check.
|
|
1420
|
+
`GROUP BY ROOT CAUSE. A root is one underlying cause where ONE fix closes several findings — e.g. many routes missing the same tenant check. A root with ONE member is not a group; leave it standalone. A root's tier is the tier of its WORST member.`,
|
|
1421
|
+
`WRITE IT SHORT. The name is a 2-5 word noun phrase ("Missing tenant scoping", "Silent write failures"), never a sentence. \`why\` and \`fix\` and \`rankReason\` are ONE sentence each. Someone scanning 28 roots to pick what to fix next cannot read a paragraph per root — but KEEP every file:line citation inside \`why\`, because those are what make the grouping checkable. Compress the prose, never the evidence.`,
|
|
1417
1422
|
`Rank roots within their tier by worst consequence, and members within a root by consequence. RISK order, never the order the findings arrived.`,
|
|
1418
1423
|
``,
|
|
1419
1424
|
`Return JSON per the schema. Every one of the ${chunk.length} findings must appear exactly once in \`placements\` — a finding you drop is a defect nobody will see again.`,
|
|
@@ -1506,6 +1511,8 @@ if (architect) {
|
|
|
1506
1511
|
f._rootKey = p.rootKey;
|
|
1507
1512
|
f._rootName = (root && root.name) || p.rootKey;
|
|
1508
1513
|
f._rootFix = root && root.fix;
|
|
1514
|
+
f._rootWhy = root && root.why;
|
|
1515
|
+
f._rootRankReason = root && root.rankReason;
|
|
1509
1516
|
f._rootRank = (root && Number.isInteger(root.rank)) ? root.rank : 99;
|
|
1510
1517
|
f._rankInRoot = Number.isInteger(p.rankInRoot) ? p.rankInRoot : 99;
|
|
1511
1518
|
}
|
|
@@ -1610,17 +1617,34 @@ function fmtChunks(today) {
|
|
|
1610
1617
|
|
|
1611
1618
|
const CHUNK_MAX = 30000;
|
|
1612
1619
|
const chunks = [head.join("\n")];
|
|
1613
|
-
let buf = "", n = tdStart, lastSev = null, lastType = null;
|
|
1620
|
+
let buf = "", n = tdStart, lastSev = null, lastType = null, lastRoot = null;
|
|
1614
1621
|
const flush = () => { if (buf) { chunks.push(buf); buf = ""; } };
|
|
1615
1622
|
// Consume the shared `orderedFindings` (severity → type → original-index) so the
|
|
1616
1623
|
// TD numbers assigned here are IDENTICAL to those the consolidation stage references.
|
|
1617
1624
|
for (const { f, t } of orderedFindings) {
|
|
1618
1625
|
let piece = "";
|
|
1619
|
-
if (f.severity !== lastSev) { piece += `\n## ${sevHead[f.severity] || f.severity} Priority\n\n`; lastSev = f.severity; lastType = null; }
|
|
1626
|
+
if (f.severity !== lastSev) { piece += `\n## ${sevHead[f.severity] || f.severity} Priority\n\n`; lastSev = f.severity; lastType = null; lastRoot = null; }
|
|
1620
1627
|
// Type sub-heading uses a bold marker line (NOT `###`) so it never collides with
|
|
1621
1628
|
// the `### TD-N` item headings that downstream tools grep for. ASCII hyphens only
|
|
1622
1629
|
// (M76: no em/en-dashes in fmtChunks literals).
|
|
1623
|
-
|
|
1630
|
+
// Root grouping is the fix unit. When the architect grouped the findings, the
|
|
1631
|
+
// register shows the root and its one-line cause/fix/rank above its members,
|
|
1632
|
+
// because scheduling the members separately produces separate half-fixes of
|
|
1633
|
+
// one defect. Bullets sit in <sub> so the eye lands on the finding titles.
|
|
1634
|
+
// Without an architect result there are no roots, and the old type marker
|
|
1635
|
+
// still separates the sections.
|
|
1636
|
+
if (f._rootKey && f._rootKey !== lastRoot) {
|
|
1637
|
+
piece += `\n### ${f._rootKey} - ${ascii(f._rootName) || f._rootKey}\n\n<sub>\n\n`;
|
|
1638
|
+
if (f._rootWhy) piece += `- **Cause:** ${ascii(f._rootWhy)}\n`;
|
|
1639
|
+
if (f._rootFix) piece += `- **Fix:** ${ascii(f._rootFix)}\n`;
|
|
1640
|
+
if (f._rootRankReason) piece += `- **Rank:** ${ascii(f._rootRankReason)}\n`;
|
|
1641
|
+
piece += `\n</sub>\n\n`;
|
|
1642
|
+
lastRoot = f._rootKey; lastType = null;
|
|
1643
|
+
} else if (!f._rootKey && lastRoot) {
|
|
1644
|
+
piece += `\n### Standalone\n\n`;
|
|
1645
|
+
lastRoot = null; lastType = null;
|
|
1646
|
+
}
|
|
1647
|
+
if (!f._rootKey && t !== lastType) { piece += `**-- ${t} --**\n\n`; lastType = t; }
|
|
1624
1648
|
piece += itemMd(f, n++);
|
|
1625
1649
|
if (buf.length + piece.length > CHUNK_MAX) flush();
|
|
1626
1650
|
buf += piece;
|