@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 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.11.31** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
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
- * Three modes:
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> create a worktree on a branch called <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(create(cwd, home, branchNameFrom(wanted)).path + "\n");
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 create(repo, home, branch) {
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
- // Reusing a directory that already holds a different branch's work would put
212
- // this session on top of it. Say so instead.
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
- fail(`${dest} already exists. Pick a different name, or start there directly.`);
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 --name live on the picker.
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
@@ -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
- **FAIL-LOUD on graph-unavailable:** On `{ok:false, reason:"graph-unavailable"}`, the debug workflow surfaces `"graph unavailable fix it (gsd-t graph status)"` and halts the graph-query step. It does NOT fall back to grep for the structural question. 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.
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
- **FAIL-LOUD on graph-unavailable:** On `{ok:false, reason:"graph-unavailable"}`, the structural query surfaces `"graph unavailable fix it (gsd-t graph status)"` the pipeline does NOT fall back to grep for the structural question. `[RULE] consumer-structural-grep-removed`.
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
 
@@ -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
- **FAIL-LOUD on graph-unavailable:** On `{ok:false, reason:"graph-unavailable"}`, the structural-impact query surfaces `"graph unavailable fix it (gsd-t graph status)"` and the agent proceeds without the structural slice — it does NOT fall back to grep for the structural question. `[RULE] consumer-structural-grep-removed`.
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
- **FAIL-LOUD on graph-unavailable:** On `{ok:false, reason:"graph-unavailable"}`, the test-impl query surfaces `"graph unavailable fix it (gsd-t graph status)"` and the agent proceeds with filesystem discovery as announced fallbackit does NOT silently treat a missing graph as "no coverage". `[RULE] consumer-structural-grep-removed`.
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.11.31",
4
- "description": "GSD-T: Contract-Driven Development for Claude Code \u2014 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",
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 — bootstrap carve-out:
137
- // integrate degrades ANNOUNCED on graph-unavailable, does NOT hard-fail.
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
- const wiResult = await runCli(
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
- const wiEnv = wiResult.envelope || {};
148
- if (wiEnv.ok === true) {
149
- _graphWhoImportsSlice = wiEnv;
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 === "BROKEN") {
155
- _graphIntegrateWarning = `⚠ graph BROKEN (reason=${wiEnv.reason || "?"}) — structural wiring-check skipped. This is NOT merely un-indexed; FIX it (gsd-t graph status).`;
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 = "⚠ graph ABSENT (never indexed) — structural wiring-check skipped (announced carve-out; build with gsd-t graph index)";
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
- log(`M94 graph who-imports: ${_graphIntegrateWarning}`);
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
- name: { type: "string", description: "plain-English name of the shared cause" },
1313
- why: { type: "string", description: "why these are one cause, with code evidence" },
1314
- fix: { type: "string", description: "the single fix that closes them" },
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. For each root give a plain-English name, why they are one cause (with code evidence), the single fix, and its members by index. A root with ONE member is not a group; leave it standalone. A root's tier is the tier of its WORST member.`,
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
- if (t !== lastType) { piece += `**-- ${t} --**\n\n`; lastType = t; }
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;