@tekyzinc/gsd-t 5.18.10 → 5.19.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,53 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.19.10] - 2026-09-09
6
+
7
+ ### Added — M117 Graph Search Guard: the graph rule finally fires on the path work actually takes
8
+
9
+ The rule "read code structure through the graph, never grep around it" had three enforcement
10
+ points and all three missed. The Grep-tool hook and Read-tool hook never fire, because
11
+ bypass-permissions mode routes every search through Bash. The runtime use-gate only runs inside
12
+ `gsd-t verify`, so a plain conversation was never measured. The ledger recorded the result:
13
+ 2 grep events in three months, both June test probes, against 34,418 graph queries all issued by
14
+ the graph's own tooling. Not one came from a session choosing to consult it.
15
+
16
+ - `scripts/gsd-t-graph-search-guard.js`: a PreToolUse hook on Bash and Grep. Three outcomes and no
17
+ fourth — a structural code search BLOCKS with the graph command to run instead; a search over
18
+ content the graph does not index (`.md`, `.json`, `.sql`, config, prose) RUNS, because the graph
19
+ holds no answer to route to; a search that cannot be classified BLOCKS. No fail-open, no bypass
20
+ env var. A missing or unbuilt graph BLOCKS with `gsd-t graph index`, never a quiet fall back to
21
+ grep — that fallback is what let a project grep its way through 827 files with the graph unbuilt.
22
+ - `bin/gsd-t-code-search-classifier.cjs`: the three-way classifier. Deliberately NOT merged with
23
+ `gsd-t-grep-classifier.cjs` — that one feeds a hook that replaces grep output, so unsure means
24
+ "let grep run"; this one feeds a guard that blocks, so unsure means block. One module cannot hold
25
+ both defaults, and merging them would silently pick one caller's behaviour for both.
26
+ - `scripts/gsd-t-graph-use-report.js`: a Stop hook covering what no pattern-matcher can see — a turn
27
+ that answered a structural question by reading files end to end. It reports rather than blocks,
28
+ because a Stop hook fires after the work is done.
29
+ - Both hooks registered by `gsd-t install`; the classifier ships in both bin registries.
30
+ - `configureWriteEditHook` generalized to `configurePreToolUseHook` with the matcher as a parameter,
31
+ rather than copied for a Bash|Grep variant.
32
+ - 17 tests in `test/m117-graph-search-guard.test.js`, including the six real searches from the
33
+ session that prompted this, each of which ran unchallenged at the time.
34
+
35
+ ## [5.18.11] - 2026-09-07
36
+
37
+ ### Fixed — `gsd-t pick-worktree --name main` refused when the main checkout sat on a feature branch
38
+
39
+ The launcher prompt offers `"main" = work in main`, but the picker only treated a name as
40
+ "stay here" when it equalled the branch currently checked out. A main checkout left on
41
+ `feat/m26-file-mgmt` (NiceNote) turned `main` into `git worktree add -b main`, which git refused:
42
+ `a branch named 'main' already exists`. The person typing `main` means the FOLDER, not the branch.
43
+
44
+ - `bin/gsd-t-pick-worktree.cjs`: a typed name means the main checkout when it equals EITHER the branch
45
+ checked out there OR the repo's default branch as the remote declares it (`origin/HEAD` → `main`);
46
+ nothing is inferred from the name, so a `trunk` repo with no remote still treats `main` as a new
47
+ branch. The stderr notice names the branch actually checked out.
48
+ - Same class, other half: naming an EXISTING branch that is checked out nowhere now checks it out
49
+ into a worktree (`git worktree add <dest> <branch>`) instead of asking git to create it again.
50
+ - 2 regression tests in `test/m111-pick-worktree.test.js`.
51
+
5
52
  ## [5.18.10] - 2026-09-03
6
53
 
7
54
  ### Added — M115 Test-Plan-First Requirements Interrogation (`/gsd-t-test-plan`)
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.18.10** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.19.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,318 @@
1
+ /**
2
+ * gsd-t-code-search-classifier.cjs
3
+ *
4
+ * M117 - Decide what a shell search command is asking for, so the search guard
5
+ * can stop the ones the code graph should answer.
6
+ *
7
+ * THIS IS NOT gsd-t-grep-classifier.cjs, AND THE DIFFERENCE IS THE POINT.
8
+ * That one feeds a hook that REPLACES grep output, so an unsure answer there
9
+ * means "let grep run" - a wrong guess only costs a missed opportunity. This
10
+ * one feeds a guard that BLOCKS, and under the graph rule an unsure answer is
11
+ * the dangerous direction: a search that proceeds because the classifier could
12
+ * not decide is a search that continued past a failure, which is the fallback
13
+ * the No-Fallback rule bans. So the defaults are inverted here on purpose.
14
+ * Merging the two would force one default onto both callers and silently break
15
+ * whichever one it was not chosen for.
16
+ *
17
+ * Three answers, no fourth:
18
+ *
19
+ * 'structural' asking who calls / who imports / where something is defined,
20
+ * over code. The graph answers this. BLOCK.
21
+ * 'content' searching text the graph does not index - markdown, JSON,
22
+ * SQL, config, prose, comments, log output. ALLOW. This is not
23
+ * a loophole: the graph has no answer, so there is nothing to
24
+ * route to.
25
+ * 'unclear' cannot tell. BLOCK, and say so. Never guessed either way.
26
+ *
27
+ * [RULE] search-classifier-unclear-blocks-never-allows
28
+ * [RULE] search-classifier-content-scope-is-what-graph-cannot-index
29
+ * [RULE] search-classifier-missing-input-is-unclear-not-content
30
+ *
31
+ * Zero dependencies. Pure - no filesystem, no spawning, no environment reads.
32
+ */
33
+
34
+ 'use strict';
35
+
36
+ // --- What the graph indexes ------------------------------------------------
37
+ // A search restricted to files OUTSIDE this set is a content search by
38
+ // definition: the graph holds nothing about them, so it cannot be the better
39
+ // answer. Kept as one shared constant because a second copy is where a
40
+ // mismatch hides.
41
+ const CODE_EXT = new Set([
42
+ '.js', '.cjs', '.mjs', '.jsx', '.ts', '.tsx', '.mts', '.cts',
43
+ '.py', '.pyi', '.go', '.rs', '.java', '.rb', '.php',
44
+ '.c', '.h', '.cc', '.cpp', '.hpp', '.cs', '.swift', '.kt', '.scala',
45
+ ]);
46
+
47
+ // Content the graph does not index, named explicitly so "it is a .md file" is a
48
+ // decision rather than an absence of evidence.
49
+ const CONTENT_EXT = new Set([
50
+ '.md', '.markdown', '.txt', '.rst',
51
+ '.json', '.jsonl', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf', '.env',
52
+ '.sql', '.csv', '.tsv',
53
+ '.sh', '.bash', '.zsh', '.fish',
54
+ '.html', '.css', '.scss', '.less',
55
+ '.lock', '.log', '.xml', '.svg',
56
+ ]);
57
+
58
+ // The search programs this guard governs. `find` is here for `-name`, which is
59
+ // a "where does this live" question the graph answers.
60
+ const SEARCH_PROGRAMS = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ripgrep', 'ag', 'ack', 'find', 'ugrep']);
61
+
62
+ // Flags whose NEXT argument names a file scope.
63
+ const SCOPE_FLAGS = new Set(['--include', '--glob', '-g', '--name', '-name', '-iname']);
64
+
65
+ // Flags of the form --include=<glob>, carrying the scope inline.
66
+ const INLINE_SCOPE_FLAGS = ['--include=', '--glob='];
67
+
68
+ // A bare identifier - a function, class, or variable name and nothing else.
69
+ const BARE_IDENT = /^[A-Za-z_$][A-Za-z0-9_$]{1,79}$/;
70
+
71
+ // A member access or call shape: Obj.method, this.method, foo(
72
+ const MEMBER_OR_CALL = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)?\(?$/;
73
+
74
+ // Declaration-hunting shapes. These ask where something is DEFINED.
75
+ const DECLARATION_SHAPES = [
76
+ /^(?:async\s+)?function\s+[A-Za-z_$]/,
77
+ /^class\s+[A-Za-z_$]/,
78
+ /^def\s+[A-Za-z_$]/,
79
+ /^(?:export\s+)?(?:const|let|var)\s+[A-Za-z_$]/,
80
+ /^func\s+[A-Za-z_$]/,
81
+ /^(?:pub\s+)?fn\s+[A-Za-z_$]/,
82
+ ];
83
+
84
+ // Import/require shapes - "who depends on this".
85
+ const IMPORT_SHAPES = [
86
+ /\bimport\s/,
87
+ /\bfrom\s+\S+\s+import\b/,
88
+ /\brequire\s*\(/,
89
+ /\bexport\s+(?:default\s+)?(?:function|class|const)\b/,
90
+ ];
91
+
92
+ // Multi-word English: an error message or a sentence, not a symbol.
93
+ const PROSE_SHAPE = /^[A-Za-z][A-Za-z'-]*(?:\s+[A-Za-z][A-Za-z'-]*)+$/;
94
+
95
+ // A run of characters shaped like an identifier.
96
+ const IDENTIFIER_RUN = /[A-Za-z_$][A-Za-z0-9_$]{2,}/;
97
+
98
+ /**
99
+ * Pull the file extensions a search is scoped to, from its arguments.
100
+ * Returns a Set; empty means "no scope stated".
101
+ */
102
+ function scopedExtensions(argv) {
103
+ const found = new Set();
104
+
105
+ const addFrom = (s) => {
106
+ if (typeof s !== 'string') return;
107
+ // *.{js,ts} - a brace group naming several at once.
108
+ const brace = s.match(/\*\.\{([^}]+)\}/);
109
+ if (brace) {
110
+ for (const part of brace[1].split(',')) {
111
+ const e = '.' + part.trim().replace(/^\./, '');
112
+ if (e.length > 1) found.add(e.toLowerCase());
113
+ }
114
+ return;
115
+ }
116
+ // *.md, path/to/file.json
117
+ const m = s.match(/\.([A-Za-z0-9]+)$/);
118
+ if (m) found.add(('.' + m[1]).toLowerCase());
119
+ };
120
+
121
+ const inlineScopeValue = (arg) => {
122
+ for (const prefix of INLINE_SCOPE_FLAGS) {
123
+ if (arg.startsWith(prefix)) return arg.slice(prefix.length);
124
+ }
125
+ return null;
126
+ };
127
+
128
+ for (let i = 0; i < argv.length; i++) {
129
+ const a = argv[i];
130
+ if (typeof a !== 'string') continue;
131
+
132
+ if (SCOPE_FLAGS.has(a)) {
133
+ addFrom(argv[i + 1]);
134
+ i++;
135
+ continue;
136
+ }
137
+
138
+ const inline = inlineScopeValue(a);
139
+ if (inline !== null) {
140
+ addFrom(inline);
141
+ continue;
142
+ }
143
+
144
+ // ripgrep -t<type> shorthand: -tmd, -tjs. Counted only when the type names
145
+ // an extension we know; an unknown type states no scope.
146
+ const typeShorthand = a.match(/^-t([a-z]+)$/);
147
+ if (typeShorthand) {
148
+ const asExt = '.' + typeShorthand[1];
149
+ if (CODE_EXT.has(asExt)) found.add(asExt);
150
+ else if (CONTENT_EXT.has(asExt)) found.add(asExt);
151
+ continue;
152
+ }
153
+
154
+ if (!a.startsWith('-')) addFrom(a);
155
+ }
156
+ return found;
157
+ }
158
+
159
+ /**
160
+ * Is every extension this search touches outside the graph's index?
161
+ * Only true when a scope was actually stated - an unscoped search reaches code
162
+ * whether or not the pattern looks like prose.
163
+ */
164
+ function scopedEntirelyToContent(exts) {
165
+ if (exts.size === 0) return false;
166
+ for (const e of exts) {
167
+ if (!CONTENT_EXT.has(e)) return false;
168
+ }
169
+ return true;
170
+ }
171
+
172
+ function touchesCode(exts) {
173
+ for (const e of exts) {
174
+ if (CODE_EXT.has(e)) return true;
175
+ }
176
+ return false;
177
+ }
178
+
179
+ function asksByFileName(argv) {
180
+ for (const a of argv) {
181
+ if (a === '-name') return true;
182
+ if (a === '-iname') return true;
183
+ }
184
+ return false;
185
+ }
186
+
187
+ /**
188
+ * Classify a search.
189
+ *
190
+ * @param {object} input
191
+ * @param {string} input.pattern what is being searched for
192
+ * @param {string[]} [input.argv] the rest of the command's arguments
193
+ * @param {string} [input.program] grep | rg | find | ...
194
+ * @returns {{ verdict:'structural'|'content'|'unclear',
195
+ * reason:string, symbol:string|null, verb:string|null }}
196
+ */
197
+ function classifySearch(input) {
198
+ // A caller that hands over no pattern has not asked a question this can
199
+ // answer. Reporting 'content' would ALLOW the search on the strength of an
200
+ // input that never arrived - deciding by absence of evidence. It is unclear,
201
+ // and unclear blocks.
202
+ if (!input) return unclear('no input was given');
203
+ if (typeof input.pattern !== 'string') return unclear('no search pattern was given');
204
+ const pattern = input.pattern.trim();
205
+ if (!pattern) return unclear('the search pattern was empty');
206
+
207
+ const argv = Array.isArray(input.argv) ? input.argv : [];
208
+ const program = typeof input.program === 'string' ? input.program : 'grep';
209
+
210
+ const exts = scopedExtensions(argv);
211
+
212
+ // Scoped entirely to things the graph does not index. The graph has no
213
+ // answer, so there is nothing to route to.
214
+ if (scopedEntirelyToContent(exts)) {
215
+ return {
216
+ verdict: 'content',
217
+ reason: 'scoped to ' + [...exts].join(', ') + ' - the graph does not index these',
218
+ symbol: null,
219
+ verb: null,
220
+ };
221
+ }
222
+
223
+ // `find -name Foo.js` asks where a file lives. The graph knows.
224
+ if (program === 'find' && asksByFileName(argv) && touchesCode(exts)) {
225
+ return {
226
+ verdict: 'structural',
227
+ reason: 'locating a code file by name',
228
+ symbol: pattern.replace(/^\*/, '').replace(/\*$/, ''),
229
+ verb: 'defines',
230
+ };
231
+ }
232
+
233
+ // Unmistakable prose: several English words. The graph indexes symbols and
234
+ // edges, not sentences.
235
+ if (PROSE_SHAPE.test(pattern) && pattern.split(/\s+/).length >= 3) {
236
+ return { verdict: 'content', reason: 'a phrase, not a symbol', symbol: null, verb: null };
237
+ }
238
+
239
+ // A quoted string literal hunted through the codebase is content.
240
+ if (/^["'].*["']$/.test(pattern) && pattern.length > 12) {
241
+ return { verdict: 'content', reason: 'a string literal', symbol: null, verb: null };
242
+ }
243
+
244
+ // --- Structural shapes ---------------------------------------------------
245
+ for (const re of IMPORT_SHAPES) {
246
+ if (re.test(pattern)) {
247
+ return {
248
+ verdict: 'structural',
249
+ reason: 'asking who imports or requires something',
250
+ symbol: extractSymbol(pattern),
251
+ verb: 'who-imports',
252
+ };
253
+ }
254
+ }
255
+
256
+ for (const re of DECLARATION_SHAPES) {
257
+ if (re.test(pattern)) {
258
+ return {
259
+ verdict: 'structural',
260
+ reason: 'asking where something is defined',
261
+ symbol: extractSymbol(pattern),
262
+ verb: 'defines',
263
+ };
264
+ }
265
+ }
266
+
267
+ if (BARE_IDENT.test(pattern)) {
268
+ return {
269
+ verdict: 'structural',
270
+ reason: 'a bare symbol name - who calls it and who imports it is a graph question',
271
+ symbol: pattern,
272
+ verb: 'who-calls',
273
+ };
274
+ }
275
+
276
+ if (MEMBER_OR_CALL.test(pattern)) {
277
+ return {
278
+ verdict: 'structural',
279
+ reason: 'a call or member-access shape',
280
+ symbol: pattern.replace(/\($/, ''),
281
+ verb: 'who-calls',
282
+ };
283
+ }
284
+
285
+ // --- Everything left -----------------------------------------------------
286
+ // A regex, a mixed pattern, something with punctuation. It MIGHT be a text
287
+ // search and it might be a symbol hunt wearing a regex. Saying "probably
288
+ // text" here is the guess this classifier exists not to make.
289
+ //
290
+ // One narrowing first: a pattern holding no identifier-shaped run has nothing
291
+ // the graph could be asked about, whatever else it is.
292
+ if (!IDENTIFIER_RUN.test(pattern)) {
293
+ return {
294
+ verdict: 'content',
295
+ reason: 'no symbol-shaped text in the pattern',
296
+ symbol: null,
297
+ verb: null,
298
+ };
299
+ }
300
+
301
+ return unclear('could be a symbol hunt or a text search - it has to be said which');
302
+ }
303
+
304
+ function unclear(reason) {
305
+ return { verdict: 'unclear', reason, symbol: null, verb: null };
306
+ }
307
+
308
+ // Pull the most likely symbol out of a declaration/import pattern, for the
309
+ // suggested graph command. Null when nothing identifier-shaped is present.
310
+ function extractSymbol(pattern) {
311
+ const trailing = pattern.match(/([A-Za-z_$][A-Za-z0-9_$]{1,})\s*$/);
312
+ if (trailing) return trailing[1];
313
+ const anywhere = pattern.match(/([A-Za-z_$][A-Za-z0-9_$]{1,})/);
314
+ if (anywhere) return anywhere[1];
315
+ return null;
316
+ }
317
+
318
+ module.exports = { classifySearch, CODE_EXT, CONTENT_EXT, SEARCH_PROGRAMS };
@@ -102,17 +102,20 @@ function main() {
102
102
  }
103
103
 
104
104
  if (wanted) {
105
- // Asking for the repo's own default branch means "work here, in the main
106
- // checkout" not "make a worktree called main", which git refuses anyway
107
- // because that branch is already checked out. Rare, but it is a real
108
- // choice, and the only way to express it is to name it.
109
- if (isDefaultBranch(cwd, branchNameFrom(wanted))) {
105
+ // Naming the main checkout means "work here" not "make a worktree called
106
+ // main", which git refuses anyway. The main checkout answers to two names:
107
+ // the branch it is sitting on right now, and the repo's default branch
108
+ // (`main` in a repo whose origin/HEAD is main), which is what the launcher
109
+ // prompt offers even when the checkout has wandered onto a feature branch.
110
+ // Rare, but it is a real choice, and the only way to express it is to name it.
111
+ if (meansMainCheckout(cwd, branchNameFrom(wanted))) {
110
112
  // Said out loud on stderr: every other path here is silent, but this one
111
113
  // is a deliberate choice to work somewhere the house rules steer away
112
114
  // from, and silence would read as "the name was ignored". stdout stays
113
- // empty because the shell reads it as the directory to move to.
115
+ // empty because the shell reads it as the directory to move to. The
116
+ // branch named is the one actually checked out, not the one typed.
114
117
  process.stderr.write(
115
- `[GSD-T WORKTREE] staying in the main checkout on ${branchNameFrom(wanted)} — ` +
118
+ `[GSD-T WORKTREE] staying in the main checkout on ${currentBranch(cwd) || "(detached)"} — ` +
116
119
  `no worktree created.\n`
117
120
  );
118
121
  stay();
@@ -138,25 +141,45 @@ function main() {
138
141
  }
139
142
 
140
143
  /**
141
- * Is this the repo's own default branch — the one the main checkout sits on?
144
+ * Does this name refer to the main checkout — the folder the session started in?
142
145
  *
143
- * Asked of git rather than matched against a list: a repo may use `master`,
144
- * `trunk` or anything else, and a hardcoded list would send those repos into a
145
- * worktree named after their own main branch. The branch currently checked out
146
- * in the main tree IS the answer, since that is the thing being opted into.
146
+ * Two names do: the branch checked out there right now, and the repo's default
147
+ * branch. The second matters because the main checkout is often left on a
148
+ * feature branch, and the person typing "main" at the launcher prompt means the
149
+ * FOLDER, not the branch a worktree on `main` is exactly what git refuses.
147
150
  *
148
- * A repo git cannot answer for is not the default-branch case it falls
149
- * through to the ordinary worktree path, which fails loudly on its own if git
150
- * is genuinely broken.
151
+ * The default is asked of git (origin/HEAD), never assumed: a repo on `trunk`
152
+ * with no origin treats `main` as an ordinary new branch name. A repo git
153
+ * cannot answer for falls through to the ordinary worktree path, which fails
154
+ * loudly on its own if git is genuinely broken.
151
155
  */
152
- function isDefaultBranch(repo, name) {
156
+ function meansMainCheckout(repo, name) {
157
+ const typed = String(name).toLowerCase();
158
+ // Both sides lowercased: a branch name typed at a prompt is a value the user
159
+ // types, and "Main" must mean main.
160
+ const current = currentBranch(repo);
161
+ if (current && current.toLowerCase() === typed) return true;
162
+ const def = defaultBranch(repo);
163
+ return Boolean(def) && def.toLowerCase() === typed;
164
+ }
165
+
166
+ // The branch the main checkout sits on; "" when detached or git cannot say.
167
+ function currentBranch(repo) {
153
168
  const r = spawnSync("git", ["branch", "--show-current"], {
154
169
  cwd: repo, encoding: "utf8", timeout: 10000,
155
170
  });
156
- if (r.status !== 0) return false;
157
- // Both sides lowercased: a branch name typed at a prompt is a value the user
158
- // types, and "Main" must mean main.
159
- return String(r.stdout || "").trim().toLowerCase() === String(name).toLowerCase();
171
+ return r.status === 0 ? String(r.stdout || "").trim() : "";
172
+ }
173
+
174
+ // The repo's default branch as the remote declares it (origin/HEAD "main"),
175
+ // or null when there is no remote to ask. Nothing is inferred from the name.
176
+ function defaultBranch(repo) {
177
+ const r = spawnSync("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], {
178
+ cwd: repo, encoding: "utf8", timeout: 10000,
179
+ });
180
+ if (r.status !== 0) return null;
181
+ const ref = String(r.stdout || "").trim();
182
+ return ref.startsWith("origin/") ? ref.slice("origin/".length) : null;
160
183
  }
161
184
 
162
185
  // Turn what the user typed into a name git will accept, without silently
@@ -253,9 +276,13 @@ function enterOrCreate(repo, home, branch) {
253
276
 
254
277
  fs.mkdirSync(home, { recursive: true });
255
278
 
256
- const r = spawnSync("git", ["worktree", "add", dest, "-b", branch], {
257
- cwd: repo, encoding: "utf8",
258
- });
279
+ // A branch that already exists (a feature branch from last week, nobody in
280
+ // it) is checked out as it is; `-b` would ask git to create it again, and git
281
+ // refuses. Only a name git has never seen becomes a new branch.
282
+ const args = localBranchExists(repo, branch)
283
+ ? ["worktree", "add", dest, branch]
284
+ : ["worktree", "add", dest, "-b", branch];
285
+ const r = spawnSync("git", args, { cwd: repo, encoding: "utf8" });
259
286
 
260
287
  // git writes progress to stderr even when it succeeds, so the exit code and
261
288
  // the directory existing are what actually prove it worked.
@@ -272,6 +299,14 @@ function enterOrCreate(repo, home, branch) {
272
299
  return { path: dest };
273
300
  }
274
301
 
302
+ // Is there a local branch by this exact name? Asked of git's refs, not the
303
+ // worktree list, so a branch checked out nowhere still counts.
304
+ function localBranchExists(repo, branch) {
305
+ return spawnSync("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], {
306
+ cwd: repo, stdio: "pipe", timeout: 10000,
307
+ }).status === 0;
308
+ }
309
+
275
310
  /**
276
311
  * Does THIS repo know `dest` as its worktree for `branch`?
277
312
  *