pi-odd-worktree 1.1.0 → 1.2.0

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.
@@ -3,8 +3,7 @@
3
3
  //
4
4
  // Every external process is spawned with a fixed argv array — no shell
5
5
  // string is ever built, so branch names and paths cannot inject commands.
6
- // `listWorktrees` parses `git worktree list --porcelain -z` itself instead
7
- // of trusting Herdr JSON output.
6
+ // `listWorktrees` parses `git worktree list --porcelain -z` itself.
8
7
 
9
8
  const { spawnSync } = require('node:child_process');
10
9
  const fs = require('node:fs');
@@ -564,193 +563,7 @@ function matchesAnyCheckout(identity, resolved, entries) {
564
563
  return false;
565
564
  }
566
565
 
567
- // Closed union of herdr operations with a per-operation flag allowlist,
568
- // mirroring the installed CLI surface (herdr 0.9.4, verified read-only
569
- // against `herdr worktree list|create|open --help`, `herdr pane list
570
- // --help` and `herdr agent start|get|list --help`: every flag below
571
- // exists there with the shape used here). Every value travels as its own
572
- // argv element because herdr rejects `--flag=value`. Unknown operations
573
- // or flags throw. `--trust-repository` is deliberately absent everywhere:
574
- // trusting a repository is a human decision these tools never take.
575
- // `--focus` stays opt-in for the worktree create/open calls that predate
576
- // the session tool; the session tool itself only ever passes `--no-focus`.
577
- const HERDR_ALLOWLIST = {
578
- list: ['cwd'],
579
- create: ['cwd', 'branch', 'base', 'path', 'label', 'focus'],
580
- open: ['cwd', 'path', 'branch', 'noFocus', 'focus'],
581
- remove: ['workspace', 'force'],
582
- 'pane-list': ['workspace'],
583
- 'agent-start': ['name', 'kind', 'pane', 'timeout', 'agentArgs'],
584
- 'agent-get': ['target'],
585
- 'agent-list': [],
586
- };
587
-
588
- const HERDR_FLAG = {
589
- cwd: '--cwd',
590
- workspace: '--workspace',
591
- branch: '--branch',
592
- base: '--base',
593
- path: '--path',
594
- label: '--label',
595
- focus: '--focus',
596
- noFocus: '--no-focus',
597
- force: '--force',
598
- kind: '--kind',
599
- pane: '--pane',
600
- timeout: '--timeout',
601
- };
602
566
 
603
- // Build the fixed argv for a herdr operation. Worktree operations produce
604
- // `herdr worktree <op> ...`; the session composition additionally needs
605
- // `herdr pane list`, `herdr agent start <NAME> ... -- [AGENT_ARG]...`,
606
- // `herdr agent get <target>` and `herdr agent list`, each with its own
607
- // fixed shape below so no caller hand-builds herdr argv.
608
- function herdrArgv(operation, params = {}) {
609
- const allowed = HERDR_ALLOWLIST[operation];
610
- if (!allowed) throw new Error(`unknown herdr operation: ${operation}`);
611
- for (const key of Object.keys(params)) {
612
- if (!allowed.includes(key)) throw new Error(`flag not allowed for herdr ${operation}: ${key}`);
613
- }
614
- // `herdr agent start <NAME> --kind <KIND> --pane <ID> --timeout <MS>
615
- // -- [AGENT_ARG]...`: the agent name is positional and always first,
616
- // the Pi argv travels after a `--` separator. Fixed order, values as
617
- // separate elements, never `--flag=value`.
618
- if (operation === 'agent-start') {
619
- const { name, kind, pane, timeout, agentArgs } = params;
620
- if (typeof name !== 'string' || name === '') throw new Error('invalid value for herdr agent-start name');
621
- for (const [key, value] of [['kind', kind], ['pane', pane], ['timeout', timeout]]) {
622
- if (typeof value !== 'string' || value === '') throw new Error(`invalid value for herdr agent-start ${key}`);
623
- }
624
- if (!Array.isArray(agentArgs) || agentArgs.some((entry) => typeof entry !== 'string')) {
625
- throw new Error('invalid value for herdr agent-start agentArgs');
626
- }
627
- return ['agent', 'start', name,
628
- '--kind', kind, '--pane', pane, '--timeout', timeout,
629
- '--', ...agentArgs];
630
- }
631
- // `herdr agent get <target>`: the target is positional.
632
- if (operation === 'agent-get') {
633
- const { target } = params;
634
- if (typeof target !== 'string' || target === '') throw new Error('invalid value for herdr agent-get target');
635
- return ['agent', 'get', target];
636
- }
637
- // `herdr agent list` takes no flags.
638
- if (operation === 'agent-list') return ['agent', 'list'];
639
- // `herdr pane list [--workspace <ID>]`.
640
- if (operation === 'pane-list') {
641
- const argv = ['pane', 'list'];
642
- if (params.workspace !== undefined) {
643
- if (typeof params.workspace !== 'string' || params.workspace === '') {
644
- throw new Error('invalid value for herdr pane-list workspace');
645
- }
646
- argv.push('--workspace', params.workspace);
647
- }
648
- return argv;
649
- }
650
- const argv = ['worktree', operation];
651
- for (const key of Object.keys(params)) {
652
- if (!allowed.includes(key)) throw new Error(`flag not allowed for herdr ${operation}: ${key}`);
653
- const value = params[key];
654
- if (typeof value === 'boolean') {
655
- if (value) argv.push(HERDR_FLAG[key]);
656
- continue;
657
- }
658
- if (typeof value !== 'string' || value === '') throw new Error(`invalid value for herdr ${operation} ${key}`);
659
- argv.push(HERDR_FLAG[key], value);
660
- }
661
- return argv;
662
- }
663
-
664
- // Where the herdr binary comes from. Production honors the documented
665
- // HERDR_BIN_PATH override (tests), else `herdr` on PATH — the same
666
- // convention lib/gentle-diagnostics.js uses. Production (no harness
667
- // marker) keeps that historical behavior untouched.
668
- //
669
- // The test harness (GENTLE_WORKTREE_LIB_DIR, set only by the test loaders
670
- // that import the extension copy) is resolved FIRST, before the override is
671
- // trusted: an exported HERDR_BIN_PATH is inherited by every test process, so
672
- // honoring it first let a test run spawn the developer's LIVE herdr and
673
- // create real Herdr workspaces. Under the harness, therefore:
674
- //
675
- // - no binary at all still fails closed with the same refusal as before;
676
- // - a binary is honored only when the path itself proves it cannot be the
677
- // live one (`isProvablyNotLiveHerdr`, which decides from the filesystem and
678
- // never executes anything);
679
- // - a possible live herdr is refused unless the CURRENT test opts in with the
680
- // dedicated flag below. The flag is the test's own declaration, so it must
681
- // be set deliberately rather than inherited along with the path.
682
- const LIVE_HERDR_OPT_IN = 'GENTLE_WORKTREE_ALLOW_LIVE_HERDR';
683
-
684
- function realPathOrNull(target) {
685
- try {
686
- return fs.realpathSync(target);
687
- } catch {
688
- return null;
689
- }
690
- }
691
-
692
- function isInsideDir(candidate, dir) {
693
- if (candidate === dir) return true;
694
- return candidate.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep);
695
- }
696
-
697
- // The scratch locations this repository's tests build their stub binaries in.
698
- // os.tmpdir() is the convention every fixture follows (`fs.mkdtempSync(
699
- // path.join(os.tmpdir(), ...))`), and no live Herdr install lives there.
700
- function testScratchRoots() {
701
- const roots = [];
702
- for (const candidate of [os.tmpdir(), path.resolve(os.tmpdir())]) {
703
- const real = realPathOrNull(candidate) || candidate;
704
- if (!roots.includes(real)) roots.push(real);
705
- }
706
- return roots;
707
- }
708
-
709
- // Can this path be PROVEN not to be a live herdr? Only filesystem facts are
710
- // used — the binary is never executed and nothing is spawned, so the answer
711
- // costs one realpath plus one stat and cannot reach a Herdr session.
712
- //
713
- // Proven not live: an absolute path that does not exist (a spawn of it fails),
714
- // that is not a regular file, that is not executable (a spawn of it fails),
715
- // or whose realpath is inside the test scratch directory. Everything else —
716
- // a relative or bare value (PATH/cwd resolved) and an existing executable
717
- // outside scratch — could be the live binary and is left unproven.
718
- function isProvablyNotLiveHerdr(bin) {
719
- if (typeof bin !== 'string' || !path.isAbsolute(bin)) return false;
720
- const real = realPathOrNull(bin);
721
- if (real === null) return true;
722
- let stat;
723
- try {
724
- stat = fs.statSync(real);
725
- } catch {
726
- return true;
727
- }
728
- if (!stat.isFile()) return true;
729
- try {
730
- fs.accessSync(real, fs.constants.X_OK);
731
- } catch {
732
- return true;
733
- }
734
- return testScratchRoots().some((root) => isInsideDir(real, root));
735
- }
736
-
737
- function resolveHerdrBinary(env = {}) {
738
- const source = env || {};
739
- const bin = source.HERDR_BIN_PATH;
740
- const injected = typeof bin === 'string' && bin.length > 0;
741
- const harness = source.GENTLE_WORKTREE_LIB_DIR;
742
- const inHarness = typeof harness === 'string' && harness.length > 0;
743
- if (!inHarness) return injected ? bin : 'herdr';
744
- if (source[LIVE_HERDR_OPT_IN] === '1') return injected ? bin : 'herdr';
745
- if (!injected) {
746
- throw new Error('herdr binary is not injected: set HERDR_BIN_PATH in tests (refusing to run live herdr)');
747
- }
748
- if (isProvablyNotLiveHerdr(bin)) return bin;
749
- throw new Error(
750
- `refusing to run live herdr from a test harness: HERDR_BIN_PATH=${bin} can execute a live herdr; `
751
- + `inject a scratch stub inside the OS temp directory, or set ${LIVE_HERDR_OPT_IN}=1 in the test that needs a live session`,
752
- );
753
- }
754
567
 
755
568
  // ---------------------------------------------------------------------
756
569
  // Commits ahead of / behind a base branch (warp-popup W1).
@@ -1043,6 +856,4 @@ module.exports = {
1043
856
  listWorktreesAsync,
1044
857
  isPathInAnyCheckout,
1045
858
  isPathInAnyCheckoutAsync,
1046
- herdrArgv,
1047
- resolveHerdrBinary,
1048
859
  };
@@ -8,7 +8,7 @@
8
8
  // (b) dependency install by lockfile-detected stack,
9
9
  // (c) `codegraph init` when the CLI is available.
10
10
  //
11
- // Both `worktree_upsert` (sync runner) and the wizard's gitOnly path (async
11
+ // Both `worktree_upsert` (sync runner) and the panel create path (async
12
12
  // twin) reach these hooks, so the link policy is applied by ONE shared
13
13
  // implementation in `lib/worktree-symlinks.js` and both creation routes
14
14
  // behave identically.
@@ -394,7 +394,7 @@ async function runCreationHooksAsync(opts = {}) {
394
394
 
395
395
  // (a2) Link git-ignored principal entries: the async twin of the sync
396
396
  // step, sharing the one classifier in lib/worktree-symlinks.js so the tool
397
- // path and the gitOnly/wizard path cannot disagree. The git reads and the
397
+ // path and the panel create path cannot disagree. The git reads and the
398
398
  // link writes are awaited, so the event loop keeps running.
399
399
  //
400
400
  // Policy (round-3 S3/L4): `.agents`, `.pi`, `.claude` and `odd` become
@@ -362,6 +362,14 @@ async function readGentlePresence(options = {}) {
362
362
  incarnation: header.incarnation,
363
363
  label: header.label,
364
364
  heartbeat: header.heartbeat,
365
+ // Published task counters, validated above and passed through
366
+ // unchanged: the warp guard reads this session's own workload.
367
+ counts: {
368
+ running: header.counts.running,
369
+ queued: header.counts.queued,
370
+ waiting: header.counts.waiting,
371
+ finished: header.counts.finished,
372
+ },
365
373
  recent: now >= header.heartbeat && now - header.heartbeat <= PRESENCE_FRESH_MS,
366
374
  workspace,
367
375
  unknown: workspace === null,
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+ // S4: a warp waits for THIS session's own background work.
3
+ //
4
+ // Gentle Shell publishes one presence header per live Pi session, carrying that
5
+ // session's agent-task counters (`counts.{running,queued,waiting,finished}`) and
6
+ // a heartbeat. This module reads that store through the SAME bounded reader the
7
+ // checkout-deletion guard uses, hashes this session's id with the same sha256,
8
+ // and keeps only ITS freshest header. Nothing else is read: no transcript, no
9
+ // prompt, no activity payload.
10
+ //
11
+ // WHAT IT DOES NOT COVER. Background jobs (`bash_background`, `monitor`) live in
12
+ // gentle-shell's in-memory job registry (lib/background-jobs.ts) and leave no
13
+ // durable, session-attributable state on disk, and the Pi host exposes no API
14
+ // for them. This module therefore never claims to see a job: the refusal text
15
+ // names agent tasks only.
16
+ //
17
+ // FAILURE MODE. Best effort, like the close guard: an absent or unreadable
18
+ // store answers `unavailable` and blocks nothing. Only a fresh header that
19
+ // reports running or queued work makes a session busy.
20
+
21
+ const { readGentlePresence, hashSessionId, PRESENCE_FRESH_MS } = require('./worktree-session-guard.js');
22
+
23
+ // How long the command front waits for the published counters to drain before
24
+ // it refuses, and how often it re-reads. Overridable so a session can shorten
25
+ // or lengthen its own wait; the default keeps a warp from stalling a turn.
26
+ const DEFAULT_WAIT_MS = 60000;
27
+ const POLL_MS = 2000;
28
+ const MAX_WAIT_MS = 600000;
29
+
30
+ const isCount = (value) => Number.isSafeInteger(value) && value >= 0;
31
+
32
+ function waitBudgetMs(env) {
33
+ const source = env && typeof env === 'object' ? env : {};
34
+ const raw = source.ODD_WORKTREE_WARP_BUSY_WAIT_MS;
35
+ if (raw === undefined || raw === null || raw === '') return DEFAULT_WAIT_MS;
36
+ const parsed = Number.parseInt(String(raw), 10);
37
+ if (!Number.isSafeInteger(parsed) || parsed < 0) return DEFAULT_WAIT_MS;
38
+ return Math.min(parsed, MAX_WAIT_MS);
39
+ }
40
+
41
+ function idleActivity(status) {
42
+ return { status, running: 0, queued: 0, waiting: 0, label: null, heartbeat: null, busy: false };
43
+ }
44
+
45
+ // This session's own counters, or an idle/unavailable answer. Never rejects.
46
+ async function sessionActivity(options = {}) {
47
+ const opts = options && typeof options === 'object' ? options : {};
48
+ const hash = hashSessionId(opts.sessionId);
49
+ if (hash === null) return idleActivity('unknown');
50
+ let read;
51
+ try {
52
+ read = await readGentlePresence({ env: opts.env, now: opts.now });
53
+ } catch {
54
+ return idleActivity('unavailable');
55
+ }
56
+ if (!read || read.status !== 'ok') return idleActivity(read && read.status === 'unavailable' ? 'unavailable' : 'unknown');
57
+ let freshest = null;
58
+ for (const entry of Array.isArray(read.entries) ? read.entries : []) {
59
+ if (!entry || entry.sessionHash !== hash || entry.recent !== true) continue;
60
+ if (!entry.counts || !isCount(entry.counts.running) || !isCount(entry.counts.queued) || !isCount(entry.counts.waiting)) continue;
61
+ if (freshest === null || entry.heartbeat > freshest.heartbeat) freshest = entry;
62
+ }
63
+ if (freshest === null) return idleActivity('ok');
64
+ const running = freshest.counts.running;
65
+ const queued = freshest.counts.queued;
66
+ return {
67
+ status: 'ok',
68
+ running,
69
+ queued,
70
+ waiting: freshest.counts.waiting,
71
+ label: typeof freshest.label === 'string' ? freshest.label : null,
72
+ heartbeat: freshest.heartbeat,
73
+ busy: running > 0 || queued > 0,
74
+ };
75
+ }
76
+
77
+ // The one sentence both fronts use. It names the counters it actually read and
78
+ // says plainly that background jobs are not visible to it.
79
+ function busyText(activity) {
80
+ const running = activity && isCount(activity.running) ? activity.running : 0;
81
+ const queued = activity && isCount(activity.queued) ? activity.queued : 0;
82
+ const parts = [];
83
+ if (running > 0) parts.push(`${running} running`);
84
+ if (queued > 0) parts.push(`${queued} queued`);
85
+ const workload = parts.length > 0 ? parts.join(' and ') : 'no';
86
+ const label = activity && typeof activity.label === 'string' && activity.label.length > 0
87
+ ? ` on "${activity.label}"`
88
+ : '';
89
+ return `this session still has ${workload} agent task(s)${label}: the warp waits for them to finish (as of the last published heartbeat); background jobs are not visible here, so confirm they finished too`;
90
+ }
91
+
92
+ // Poll until this session has no running or queued agent task, or the budget
93
+ // runs out. `read` and `sleep` are injected so a test never waits for real.
94
+ async function waitForSessionActivity(options = {}) {
95
+ const opts = options && typeof options === 'object' ? options : {};
96
+ const read = typeof opts.read === 'function' ? opts.read : () => sessionActivity({ sessionId: opts.sessionId, env: opts.env });
97
+ const sleep = typeof opts.sleep === 'function' ? opts.sleep : (ms) => new Promise((resolve) => setTimeout(resolve, ms));
98
+ const budget = isCount(opts.waitMs) ? opts.waitMs : waitBudgetMs(opts.env);
99
+ const poll = isCount(opts.pollMs) && opts.pollMs > 0 ? opts.pollMs : POLL_MS;
100
+ let activity = await read();
101
+ let waited = 0;
102
+ while (activity && activity.busy === true && waited < budget) {
103
+ const step = Math.min(poll, budget - waited);
104
+ if (step <= 0) break;
105
+ await sleep(step);
106
+ waited += step;
107
+ activity = await read();
108
+ }
109
+ return { activity, waited, waitedOut: !!(activity && activity.busy === true) };
110
+ }
111
+
112
+ module.exports = {
113
+ DEFAULT_WAIT_MS,
114
+ MAX_WAIT_MS,
115
+ busyText,
116
+ sessionActivity,
117
+ waitBudgetMs,
118
+ waitForSessionActivity,
119
+ };
@@ -158,6 +158,33 @@ function sessionPoint(entries, leafId) {
158
158
  return { tipId, count };
159
159
  }
160
160
 
161
+ // Does the ACTIVE branch carry a CONVERSATION? The host's own notion of
162
+ // content is a `message` entry whose role is user or assistant
163
+ // (SessionManager._persist only flushes a session file once an assistant
164
+ // message exists), and everything the warp appends — the transition marker,
165
+ // the confirmation notice, the session-worktree registration, a rename — is
166
+ // bookkeeping, so a session that holds only those is still an empty one: it
167
+ // has nothing to carry and therefore changes PATH instead of forking. The
168
+ // branch is the same filtered walk `sessionPoint` uses, so an abandoned leg
169
+ // never counts. The role is read from the persisted `entry.message.role` and
170
+ // from a bare `entry.role` alike, because reading an entry as content must
171
+ // not depend on one spelling. A read that throws answers `true`: an
172
+ // unreadable session keeps the ordinary move (fork) instead of silently
173
+ // dropping context it may hold.
174
+ function hasConversation(entries, leafId) {
175
+ try {
176
+ for (const entry of activeBranch(entries, leafId)) {
177
+ if (!entry || isWarpBookkeepingEntry(entry) || entry.type !== 'message') continue;
178
+ const role = typeof entry.role === 'string' ? entry.role
179
+ : entry.message && typeof entry.message === 'object' ? entry.message.role : null;
180
+ if (role === 'user' || role === 'assistant') return true;
181
+ }
182
+ return false;
183
+ } catch {
184
+ return true;
185
+ }
186
+ }
187
+
161
188
  // Point evidence for the R3 fork-vs-resume rule: the source session's
162
189
  // FILTERED position (S23 sessionPoint) when the record was written. Older
163
190
  // records carry {leafId, entryCount} from the raw-leaf comparison; they
@@ -559,6 +586,7 @@ module.exports = {
559
586
  recordWarpSession,
560
587
  isWarpBookkeepingEntry,
561
588
  sessionPoint,
589
+ hasConversation,
562
590
  samePoint,
563
591
  asPoint,
564
592
  pointOfSessionFile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-odd-worktree",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Pi package for odd-worktree: the Agent Worktree tools (create, list, close, warp), the /warp worktree panel, the /odd-list ODD viewer, and the /issues GitHub issue reader. Works in Pi alone; Herdr is optional.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -0,0 +1,161 @@
1
+ 'use strict';
2
+ // S1/S2: no creation path reaches a Herdr binary.
3
+ //
4
+ // The package no longer resolves or builds Herdr argv at all, so this suite pins
5
+ // the claim two ways: a `herdr` sentinel FIRST on PATH records any argv it is
6
+ // given (an empty log is the assertion), and a source scan proves no file
7
+ // outside the git seam builds Herdr argv or resolves the binary. Both creation
8
+ // fronts are exercised because they share one creation helper.
9
+ const { test, describe, before, after } = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+ const { spawnSync } = require('node:child_process');
15
+ const { loadExtension, SRC, LIB_DIR } = require('./fixtures/load-extension.js');
16
+
17
+ const savedEnv = { ...process.env };
18
+ const scratch = [];
19
+ const track = (entry) => { scratch.push(entry); return entry; };
20
+ after(() => {
21
+ for (const key of Object.keys(process.env)) if (!(key in savedEnv)) delete process.env[key];
22
+ for (const [key, value] of Object.entries(savedEnv)) process.env[key] = value;
23
+ for (const dir of scratch) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} }
24
+ });
25
+
26
+ const BIN = track(fs.mkdtempSync(path.join(os.tmpdir(), 'no-herdr-bin-')));
27
+ const HERDR_LOG = path.join(BIN, 'calls.log');
28
+ fs.writeFileSync(
29
+ path.join(BIN, 'herdr'),
30
+ '#!/bin/sh\nprintf "%s\\n" "$*" >> "$HERDR_LOG"\nexit 1\n',
31
+ { mode: 0o755 },
32
+ );
33
+ fs.writeFileSync(path.join(BIN, 'codegraph'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
34
+
35
+ function herdrCalls() {
36
+ try {
37
+ return fs.readFileSync(HERDR_LOG, 'utf8').trim();
38
+ } catch {
39
+ return '';
40
+ }
41
+ }
42
+ const resetHerdr = () => fs.writeFileSync(HERDR_LOG, '');
43
+
44
+ function git(cwd, args) {
45
+ const out = spawnSync('git', args, { cwd, encoding: 'utf8' });
46
+ if (out.status !== 0) throw new Error(`git ${args.join(' ')}: ${out.stderr}`);
47
+ return out.stdout.trim();
48
+ }
49
+
50
+ function makeRepo() {
51
+ const dir = track(fs.mkdtempSync(path.join(os.tmpdir(), 'no-herdr-repo-')));
52
+ git(dir, ['init', '-b', 'main']);
53
+ git(dir, ['config', 'user.email', 'no-herdr@example.test']);
54
+ git(dir, ['config', 'user.name', 'No Herdr']);
55
+ fs.writeFileSync(path.join(dir, 'README.md'), '# fixture\n');
56
+ git(dir, ['add', 'README.md']);
57
+ git(dir, ['commit', '-m', 'init']);
58
+ return fs.realpathSync(dir);
59
+ }
60
+
61
+ const resultText = (result) => (result.content || []).map((part) => (part && part.text) || '').join('\n');
62
+
63
+ function ctxFor(repo, notes, ui) {
64
+ return {
65
+ cwd: repo,
66
+ hasUI: true,
67
+ mode: 'tui',
68
+ sessionManager: { getSessionId: () => 'no-herdr-session' },
69
+ ui: { confirm: async () => true, notify: (text, level) => notes.push([level, text]), ...(ui || {}) },
70
+ };
71
+ }
72
+
73
+ // `/warp` create: the selector's create action, the create panel's answer, then
74
+ // the closing selector. Only those answers are scripted.
75
+ function warpCreateUi(panelAnswer) {
76
+ let call = 0;
77
+ return {
78
+ custom: async () => {
79
+ call += 1;
80
+ if (call === 1) return { create: true };
81
+ if (call === 2) return panelAnswer;
82
+ return undefined;
83
+ },
84
+ };
85
+ }
86
+
87
+ let tools;
88
+ let commands;
89
+ before(async () => {
90
+ process.env.PATH = `${BIN}${path.delimiter}${savedEnv.PATH || ''}`;
91
+ process.env.GENTLE_WORKTREE_THREAD = 'no-herdr-session';
92
+ ({ tools, commands } = await loadExtension({}));
93
+ });
94
+
95
+ describe('no creation path invokes Herdr', () => {
96
+ for (const existing of [false, true]) {
97
+ const kind = existing ? 'an existing' : 'a new';
98
+
99
+ test(`worktree_create never invokes Herdr for ${kind} branch`, async () => {
100
+ resetHerdr();
101
+ const repo = makeRepo();
102
+ const branch = existing ? 'feat/adopted' : 'feat/fresh';
103
+ if (existing) git(repo, ['branch', branch]);
104
+ const result = await tools.get('worktree_create').execute(
105
+ 'no-herdr-tool',
106
+ { branch, changes: 'none' },
107
+ undefined,
108
+ undefined,
109
+ ctxFor(repo, []),
110
+ );
111
+ assert.notEqual(result.isError, true, resultText(result));
112
+ assert.equal(result.details.branchCreated, !existing);
113
+ assert.equal(herdrCalls(), '', `Herdr was invoked: ${herdrCalls()}`);
114
+ assert.doesNotMatch(resultText(result), /herdr (workspace|association)/i, 'no Herdr workspace is reported');
115
+ });
116
+
117
+ test(`/warp create never invokes Herdr for ${kind} branch`, async () => {
118
+ resetHerdr();
119
+ const repo = makeRepo();
120
+ const name = existing ? 'adopted' : 'fresh';
121
+ if (existing) git(repo, ['branch', `feat/${name}`]);
122
+ const notes = [];
123
+ await commands.get('warp').handler('', ctxFor(repo, notes, warpCreateUi({ ok: true, type: 'feat', name })));
124
+ const target = path.join(path.dirname(repo), `${path.basename(repo)}-worktrees`, `feat-${name}`);
125
+ assert.ok(fs.existsSync(target), `the /warp create made no checkout (notifications: ${JSON.stringify(notes)})`);
126
+ assert.equal(herdrCalls(), '', `Herdr was invoked: ${herdrCalls()}`);
127
+ for (const [, text] of notes) assert.doesNotMatch(text, /herdr (workspace|association)/i, `Herdr reported: ${text}`);
128
+ });
129
+ }
130
+
131
+ test('a refused create writes nothing and never invokes Herdr', async () => {
132
+ resetHerdr();
133
+ const repo = makeRepo();
134
+ const before = git(repo, ['branch', '--format=%(refname:short)']);
135
+ const result = await tools.get('worktree_create').execute(
136
+ 'no-herdr-bad',
137
+ { branch: 'bad..name', changes: 'none' },
138
+ undefined,
139
+ undefined,
140
+ ctxFor(repo, []),
141
+ );
142
+ assert.equal(result.isError, true);
143
+ assert.match(resultText(result), /^invalid branch name/);
144
+ assert.equal(git(repo, ['branch', '--format=%(refname:short)']), before);
145
+ assert.equal(herdrCalls(), '', `Herdr was invoked: ${herdrCalls()}`);
146
+ });
147
+
148
+ // S1 is an AUDIT claim: no source file outside the git seam reaches a Herdr
149
+ // binary. Pinned as text so a reintroduced call cannot hide in a helper.
150
+ test('no source file outside lib/worktree-git.js calls a Herdr binary', () => {
151
+ const files = [
152
+ SRC,
153
+ ...fs.readdirSync(LIB_DIR).filter((name) => name.endsWith('.js')).map((name) => path.join(LIB_DIR, name)),
154
+ ].filter((file) => !file.endsWith(`${path.sep}worktree-git.js`));
155
+ for (const file of files) {
156
+ const source = fs.readFileSync(file, 'utf8');
157
+ assert.doesNotMatch(source, /herdrArgv\s*\(/, `${file} builds Herdr argv`);
158
+ assert.doesNotMatch(source, /resolveHerdrBinary\s*\(/, `${file} resolves the Herdr binary`);
159
+ }
160
+ });
161
+ });