pi-odd-worktree 1.0.0 → 1.1.1

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.
@@ -0,0 +1,457 @@
1
+ 'use strict';
2
+ // S1-S6/S8: the session gate, the git-toplevel root cache, and the background
3
+ // sync scheduler. Every child process and every git read is injected, so this
4
+ // suite never starts a real process and never reaches the network; the only
5
+ // real filesystem is a throwaway temp directory outside the worktree (the
6
+ // `.codegraph/` marker, the lock and the daemon pid are files on disk).
7
+ const { test, describe, beforeEach, after } = require('node:test');
8
+ const assert = require('node:assert/strict');
9
+ const fs = require('node:fs');
10
+ const os = require('node:os');
11
+ const path = require('node:path');
12
+ const { EventEmitter } = require('node:events');
13
+
14
+ const session = require('../lib/codegraph-session.js');
15
+ const hooksLib = require('../lib/worktree-hooks.js');
16
+
17
+ const scratch = [];
18
+ function tempDir(prefix = 'cg-session-') {
19
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
20
+ scratch.push(dir);
21
+ return dir;
22
+ }
23
+
24
+ after(() => {
25
+ for (const dir of scratch) {
26
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* teardown is best effort */ }
27
+ }
28
+ });
29
+
30
+ beforeEach(() => {
31
+ session.resetCodegraphSession();
32
+ });
33
+
34
+ const FORCED_ENV = { PATH: '', CODEGRAPH_FORCE: '1' };
35
+
36
+ function makeRoot() {
37
+ const root = tempDir('cg-root-');
38
+ fs.mkdirSync(path.join(root, '.codegraph'));
39
+ return root;
40
+ }
41
+
42
+ // deps for one scheduler test. `onSpawn` lets a test hold a run open (no
43
+ // close event) or release it; `overrides` replaces any other seam.
44
+ function makeDeps(root, overrides = {}) {
45
+ const { onSpawn, spawn: launch, ...rest } = overrides;
46
+ const spawns = [];
47
+ const children = [];
48
+ const deps = {
49
+ env: FORCED_ENV,
50
+ execFile: (bin) => {
51
+ assert.equal(bin, 'git');
52
+ return `${root}\n`;
53
+ },
54
+ debounceMs: 10,
55
+ retryDelayMs: 30,
56
+ ...rest,
57
+ spawn: (bin, args, opts) => {
58
+ spawns.push({ bin, args, opts });
59
+ const child = typeof launch === 'function' ? launch(bin, args, opts) : new EventEmitter();
60
+ if (!child) return child;
61
+ const unref = child.unref;
62
+ child.unref = () => {
63
+ child.unrefed = true;
64
+ if (typeof unref === 'function') unref.call(child);
65
+ };
66
+ children.push(child);
67
+ if (typeof onSpawn === 'function') onSpawn(child, children.length - 1);
68
+ return child;
69
+ },
70
+ };
71
+ return { deps, spawns, children };
72
+ }
73
+
74
+ const closeSoon = (child) => setImmediate(() => child.emit('close'));
75
+
76
+ async function waitFor(predicate, timeoutMs = 3000) {
77
+ const started = Date.now();
78
+ while (!predicate()) {
79
+ if (Date.now() - started > timeoutMs) throw new Error('timed out waiting for condition');
80
+ await new Promise((resolve) => setTimeout(resolve, 5));
81
+ }
82
+ }
83
+
84
+ describe('session gate (S3/S4)', () => {
85
+ test('probes PATH once and caches the answer', () => {
86
+ const bin = tempDir('cg-bin-');
87
+ const env = { PATH: `${bin}${path.delimiter}/cg-session-empty-dir` };
88
+ let probes = 0;
89
+ const deps = {
90
+ binaryExists: (file) => {
91
+ probes += 1;
92
+ return path.dirname(file) === bin && path.basename(file) === 'codegraph';
93
+ },
94
+ };
95
+ assert.equal(session.hasCodegraph(env, deps), true);
96
+ const first = probes;
97
+ assert.ok(first > 0, 'the first answer is probed');
98
+ assert.equal(session.hasCodegraph(env, deps), true);
99
+ assert.equal(probes, first, 'a second question never re-probes');
100
+ // A PATH that changed mid-session is not re-read either.
101
+ assert.equal(session.hasCodegraph({ PATH: '/cg-session-other' }, deps), true);
102
+ assert.equal(probes, first);
103
+ });
104
+
105
+ test('caches a missing CLI as false', () => {
106
+ const env = { PATH: tempDir('cg-empty-') };
107
+ let probes = 0;
108
+ const deps = { binaryExists: () => { probes += 1; return false; } };
109
+ assert.equal(session.hasCodegraph(env, deps), false);
110
+ const first = probes;
111
+ assert.equal(session.hasCodegraph(env, deps), false);
112
+ assert.equal(probes, first);
113
+ });
114
+
115
+ test('CODEGRAPH_FORCE=1 answers present without probing', () => {
116
+ const mustNotProbe = { binaryExists: () => { throw new Error('must not probe'); } };
117
+ assert.equal(session.hasCodegraph({ PATH: '', CODEGRAPH_FORCE: '1' }, mustNotProbe), true);
118
+ // ...and it escapes an answer already cached as false in this session.
119
+ assert.equal(session.hasCodegraph({ PATH: '' }, { binaryExists: () => false }), false);
120
+ assert.equal(session.hasCodegraph({ PATH: '', CODEGRAPH_FORCE: '1' }, mustNotProbe), true);
121
+ });
122
+
123
+ test('a false gate means no spawn ever', async () => {
124
+ const root = makeRoot();
125
+ const { deps, spawns } = makeDeps(root, { env: { PATH: tempDir('cg-empty-') } });
126
+ const decision = session.scheduleSync(path.join(root, 'src', 'a.js'), deps);
127
+ assert.deepEqual(decision, { scheduled: false, reason: 'no-codegraph' });
128
+ await session.whenIdle();
129
+ assert.equal(spawns.length, 0);
130
+ });
131
+
132
+ test('the gate fails closed when codegraph is absent from the process PATH', () => {
133
+ // One probe per process is the contract; a forced env is the only escape.
134
+ assert.equal(session.hasCodegraph({ PATH: tempDir('cg-empty-') }), false);
135
+ });
136
+ });
137
+
138
+ describe('root resolution (S1)', () => {
139
+ test('returns the git toplevel, cached per directory', () => {
140
+ const root = tempDir('cg-repo-');
141
+ const sub = path.join(root, 'src');
142
+ fs.mkdirSync(sub);
143
+ let calls = 0;
144
+ const deps = { execFile: () => { calls += 1; return `${root}\n`; } };
145
+ assert.equal(session.resolveProjectRoot(sub, deps), root);
146
+ assert.equal(session.resolveProjectRoot(sub, deps), root);
147
+ assert.equal(calls, 1);
148
+ assert.equal(session.resolveProjectRoot(root, deps), root);
149
+ assert.equal(calls, 2, 'each directory is its own cache entry');
150
+ });
151
+
152
+ test('returns null outside git and caches the null', () => {
153
+ const notRepo = tempDir('cg-not-repo-');
154
+ let failures = 0;
155
+ const deps = { execFile: () => { failures += 1; throw new Error('fatal: not a git repository'); } };
156
+ assert.equal(session.resolveProjectRoot(notRepo, deps), null);
157
+ assert.equal(session.resolveProjectRoot(notRepo, deps), null);
158
+ assert.equal(failures, 1);
159
+ });
160
+
161
+ test('a non-zero git answer resolves to null', () => {
162
+ const dir = tempDir('cg-git-exit-');
163
+ assert.equal(session.resolveProjectRoot(dir, { execFile: () => '' }), null);
164
+ });
165
+ });
166
+
167
+ describe('scheduler (S1/S2/S6/S8)', () => {
168
+ test('two rapid edits produce exactly one sync, detached and unref\'d', async () => {
169
+ const root = makeRoot();
170
+ const file = path.join(root, 'src', 'a.js');
171
+ const { deps, spawns, children } = makeDeps(root, { onSpawn: closeSoon });
172
+ const first = session.scheduleSync(file, deps);
173
+ const second = session.scheduleSync(file, deps);
174
+ assert.equal(first.scheduled, true);
175
+ assert.equal(second.scheduled, true);
176
+ await session.whenIdle();
177
+ assert.equal(spawns.length, 1, 'the second edit only resets the debounce');
178
+ assert.equal(spawns[0].bin, 'codegraph');
179
+ assert.deepEqual(spawns[0].args, ['sync', root]);
180
+ assert.equal(spawns[0].opts.detached, true);
181
+ assert.equal(spawns[0].opts.stdio, 'ignore');
182
+ assert.equal(children[0].unrefed, true, 'the child is unref\'d, never awaited');
183
+ });
184
+
185
+ test('an edit during a running sync produces exactly one more', async () => {
186
+ const root = makeRoot();
187
+ const file = path.join(root, 'src', 'a.js');
188
+ const { deps, spawns, children } = makeDeps(root);
189
+ assert.equal(session.scheduleSync(file, deps).scheduled, true);
190
+ await waitFor(() => spawns.length === 1);
191
+ assert.equal(session.scheduleSync(file, deps).reason, 'queued');
192
+ assert.equal(spawns.length, 1, 'nothing starts while the first run is open');
193
+ children[0].emit('close');
194
+ await waitFor(() => spawns.length === 2);
195
+ children[1].emit('close');
196
+ await session.whenIdle();
197
+ assert.equal(spawns.length, 2);
198
+ });
199
+
200
+ test('a held foreign lock skips and the deferred retry fires exactly once', async () => {
201
+ const root = makeRoot();
202
+ const lock = path.join(root, '.codegraph', 'pi-sync.lock');
203
+ fs.writeFileSync(lock, JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
204
+ let lockReads = 0;
205
+ const { deps, spawns } = makeDeps(root, {
206
+ readFile: (file) => {
207
+ if (file === lock) lockReads += 1;
208
+ return fs.readFileSync(file, 'utf8');
209
+ },
210
+ });
211
+ assert.equal(session.scheduleSync(path.join(root, 'a.js'), deps).scheduled, true);
212
+ await session.whenIdle();
213
+ assert.equal(spawns.length, 0, 'a live foreign lock is never taken over');
214
+ assert.equal(lockReads, 2, 'the first attempt plus exactly one deferred retry');
215
+ assert.ok(fs.existsSync(lock), 'the foreign lock is left alone');
216
+ });
217
+
218
+ test('a lock released before the retry lets exactly one sync land', async () => {
219
+ const root = makeRoot();
220
+ const lock = path.join(root, '.codegraph', 'pi-sync.lock');
221
+ fs.writeFileSync(lock, JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
222
+ let lockReads = 0;
223
+ const { deps, spawns } = makeDeps(root, {
224
+ retryDelayMs: 60,
225
+ onSpawn: closeSoon,
226
+ readFile: (file) => {
227
+ if (file === lock) lockReads += 1;
228
+ return fs.readFileSync(file, 'utf8');
229
+ },
230
+ });
231
+ assert.equal(session.scheduleSync(path.join(root, 'a.js'), deps).scheduled, true);
232
+ await waitFor(() => lockReads === 1);
233
+ assert.equal(spawns.length, 0);
234
+ fs.unlinkSync(lock);
235
+ await session.whenIdle();
236
+ assert.equal(spawns.length, 1);
237
+ });
238
+
239
+ test('a dead-pid lock is taken over', async () => {
240
+ const root = makeRoot();
241
+ const lock = path.join(root, '.codegraph', 'pi-sync.lock');
242
+ fs.writeFileSync(lock, JSON.stringify({ pid: 424242, startedAt: Date.now() }));
243
+ const { deps, spawns } = makeDeps(root, { isAlive: () => false, onSpawn: closeSoon });
244
+ assert.equal(session.scheduleSync(path.join(root, 'a.js'), deps).scheduled, true);
245
+ await session.whenIdle();
246
+ assert.equal(spawns.length, 1);
247
+ });
248
+
249
+ test('an expired lock TTL is taken over even with a live pid', async () => {
250
+ const root = makeRoot();
251
+ const lock = path.join(root, '.codegraph', 'pi-sync.lock');
252
+ const now = 1_000_000_000_000;
253
+ fs.writeFileSync(lock, JSON.stringify({ pid: process.pid, startedAt: now - (10 * 60 * 1000) }));
254
+ const { deps, spawns } = makeDeps(root, { now: () => now, onSpawn: closeSoon });
255
+ assert.equal(session.scheduleSync(path.join(root, 'a.js'), deps).scheduled, true);
256
+ await session.whenIdle();
257
+ assert.equal(spawns.length, 1);
258
+ });
259
+
260
+ test('a root without .codegraph/ spawns nothing (S6)', async () => {
261
+ const root = tempDir('cg-no-index-');
262
+ const { deps, spawns } = makeDeps(root);
263
+ const decision = session.scheduleSync(path.join(root, 'a.js'), deps);
264
+ assert.deepEqual(decision, { scheduled: false, reason: 'no-index' });
265
+ await session.whenIdle();
266
+ assert.equal(spawns.length, 0);
267
+ });
268
+
269
+ test('a live daemon.pid spawns nothing (S5)', async () => {
270
+ const root = makeRoot();
271
+ fs.writeFileSync(path.join(root, '.codegraph', 'daemon.pid'), `${process.pid}\n`);
272
+ const { deps, spawns } = makeDeps(root);
273
+ const decision = session.scheduleSync(path.join(root, 'a.js'), deps);
274
+ assert.deepEqual(decision, { scheduled: false, reason: 'daemon' });
275
+ await session.whenIdle();
276
+ assert.equal(spawns.length, 0);
277
+ });
278
+
279
+ test('the daemon pidfile in its JSON form counts as live too (S5)', async () => {
280
+ const root = makeRoot();
281
+ // CodeGraph 1.6 writes {pid, version, socketPath, startedAt}; a plain
282
+ // decimal is the legacy shape. Both must be understood.
283
+ fs.writeFileSync(path.join(root, '.codegraph', 'daemon.pid'), JSON.stringify({
284
+ pid: process.pid, version: '1.6.0', socketPath: '/tmp/cg.sock', startedAt: Date.now(),
285
+ }));
286
+ const { deps, spawns } = makeDeps(root);
287
+ assert.deepEqual(session.scheduleSync(path.join(root, 'a.js'), deps), { scheduled: false, reason: 'daemon' });
288
+ await session.whenIdle();
289
+ assert.equal(spawns.length, 0);
290
+ });
291
+
292
+ test('a sync that never closes is killed by the run watchdog and the root stays usable (S2)', async () => {
293
+ const root = makeRoot();
294
+ const killed = [];
295
+ const { deps, spawns } = makeDeps(root, {
296
+ runTimeoutMs: 25,
297
+ onSpawn: (child) => { child.kill = (signal) => { killed.push(signal); }; },
298
+ });
299
+ assert.equal(session.scheduleSync(path.join(root, 'a.js'), deps).scheduled, true);
300
+ // The watchdog timer is unref'd on purpose (a settled sync must never keep
301
+ // the host alive), so this test holds the loop itself instead of relying on
302
+ // the runner: without a live handle the loop can drain before 25ms on some
303
+ // Node versions, which would cancel the pending whenIdle().
304
+ await Promise.race([
305
+ session.whenIdle(),
306
+ new Promise((resolve) => setTimeout(resolve, 500)),
307
+ ]);
308
+ assert.deepEqual(killed, ['SIGTERM'], 'the stuck child is terminated');
309
+ // The lock came back and the root is not wedged: the next edit syncs.
310
+ const next = makeDeps(root, { onSpawn: closeSoon });
311
+ assert.equal(session.scheduleSync(path.join(root, 'b.js'), next.deps).scheduled, true);
312
+ await session.whenIdle();
313
+ assert.equal(next.spawns.length, 1);
314
+ });
315
+
316
+ test('a dead daemon.pid does not suppress the sync', async () => {
317
+ const root = makeRoot();
318
+ fs.writeFileSync(path.join(root, '.codegraph', 'daemon.pid'), '424242\n');
319
+ const { deps, spawns } = makeDeps(root, { isAlive: () => false, onSpawn: closeSoon });
320
+ assert.equal(session.scheduleSync(path.join(root, 'a.js'), deps).scheduled, true);
321
+ await session.whenIdle();
322
+ assert.equal(spawns.length, 1);
323
+ });
324
+
325
+ test('a thrown spawn error does not propagate and leaves the root usable', async () => {
326
+ const root = makeRoot();
327
+ const lock = path.join(root, '.codegraph', 'pi-sync.lock');
328
+ let attempts = 0;
329
+ const { deps, spawns } = makeDeps(root, {
330
+ spawn: () => { attempts += 1; throw new Error('boom'); },
331
+ });
332
+ assert.doesNotThrow(() => session.scheduleSync(path.join(root, 'a.js'), deps));
333
+ await session.whenIdle();
334
+ assert.equal(attempts, 1);
335
+ assert.equal(spawns.length, 1, 'one launch was attempted');
336
+ assert.ok(!fs.existsSync(lock), 'the lock is released after a failed spawn');
337
+ assert.equal(session.scheduleSync(path.join(root, 'a.js'), deps).scheduled, true);
338
+ await session.whenIdle();
339
+ assert.equal(attempts, 2, 'the root is not stuck in a running state');
340
+ });
341
+
342
+ test('a missing or unknown path is a decision, never a throw', async () => {
343
+ const { deps, spawns } = makeDeps(makeRoot());
344
+ assert.doesNotThrow(() => session.scheduleSync(undefined, deps));
345
+ assert.deepEqual(session.scheduleSync('', deps), { scheduled: false, reason: 'no-path' });
346
+ await session.whenIdle();
347
+ assert.equal(spawns.length, 0);
348
+ });
349
+ });
350
+
351
+ // The creation hooks consult the one session gate (S3). An injected
352
+ // `hasBinary` stays authoritative, so every pre-existing caller keeps its
353
+ // behavior.
354
+ describe('codegraph init follows the session gate (S3)', () => {
355
+ function roots() {
356
+ return { principalRoot: tempDir('cg-principal-'), worktreePath: tempDir('cg-worktree-') };
357
+ }
358
+
359
+ test('a false gate skips init with a reason that names the session gate', () => {
360
+ const { principalRoot, worktreePath } = roots();
361
+ const runs = [];
362
+ const out = hooksLib.runCreationHooks({
363
+ principalRoot,
364
+ worktreePath,
365
+ env: { PATH: tempDir('cg-empty-') },
366
+ run: (bin, args) => { runs.push([bin, ...args]); return { status: 1, stdout: '', stderr: '', error: null }; },
367
+ unversionedCandidates: [],
368
+ });
369
+ const step = out.steps.find((entry) => entry.name === 'codegraph-init');
370
+ assert.equal(step.status, 'skipped');
371
+ assert.match(step.detail, /session gate/);
372
+ assert.ok(!runs.some(([bin]) => bin === 'codegraph'), 'no codegraph child runs');
373
+ });
374
+
375
+ test('the async twin reads the same gate decision', async () => {
376
+ const { principalRoot, worktreePath } = roots();
377
+ const runs = [];
378
+ const out = await hooksLib.runCreationHooksAsync({
379
+ principalRoot,
380
+ worktreePath,
381
+ env: { PATH: tempDir('cg-empty-') },
382
+ runAsync: async (bin, args) => {
383
+ runs.push([bin, ...args]);
384
+ return { status: 1, stdout: '', stderr: '', error: null };
385
+ },
386
+ unversionedCandidates: [],
387
+ });
388
+ const step = out.steps.find((entry) => entry.name === 'codegraph-init');
389
+ assert.equal(step.status, 'skipped');
390
+ assert.match(step.detail, /session gate/);
391
+ assert.ok(!runs.some(([bin]) => bin === 'codegraph'));
392
+ });
393
+
394
+ test('an injected hasBinary still decides, unchanged', () => {
395
+ const { principalRoot, worktreePath } = roots();
396
+ const runs = [];
397
+ const out = hooksLib.runCreationHooks({
398
+ principalRoot,
399
+ worktreePath,
400
+ env: { PATH: '' },
401
+ hasBinary: (bin) => bin === 'codegraph',
402
+ run: (bin, args) => {
403
+ runs.push([bin, ...args]);
404
+ return { status: bin === 'codegraph' ? 0 : 1, stdout: '', stderr: '', error: null };
405
+ },
406
+ unversionedCandidates: [],
407
+ });
408
+ const step = out.steps.find((entry) => entry.name === 'codegraph-init');
409
+ assert.equal(step.status, 'done');
410
+ assert.ok(runs.some(([bin, arg]) => bin === 'codegraph' && arg === 'init'));
411
+ });
412
+
413
+ test('an injected hasBinary that says no keeps the old skip reason', () => {
414
+ const { principalRoot, worktreePath } = roots();
415
+ const out = hooksLib.runCreationHooks({
416
+ principalRoot,
417
+ worktreePath,
418
+ env: { PATH: '' },
419
+ hasBinary: () => false,
420
+ run: () => ({ status: 1, stdout: '', stderr: '', error: null }),
421
+ unversionedCandidates: [],
422
+ });
423
+ const step = out.steps.find((entry) => entry.name === 'codegraph-init');
424
+ assert.equal(step.status, 'skipped');
425
+ assert.equal(step.detail, 'codegraph not on PATH');
426
+ });
427
+ });
428
+
429
+ // T2: the extension turns a successful edit/write into one scheduling call and
430
+ // nothing else. scheduleSync is stubbed here so the assertion is only about the
431
+ // wiring (its own behavior is covered above).
432
+ describe('extension wiring (T2)', () => {
433
+ test('a successful edit and write schedule a sync; everything else does not', async () => {
434
+ const { loadExtension } = require('./fixtures/load-extension.js');
435
+ const { hooks } = await loadExtension({});
436
+ const handlers = hooks.filter(([name]) => name === 'tool_result').map(([, handler]) => handler);
437
+ assert.ok(handlers.length >= 1, 'a tool_result handler is registered');
438
+ const calls = [];
439
+ const original = session.scheduleSync;
440
+ session.scheduleSync = (filePath) => { calls.push(filePath); return { scheduled: true, reason: 'scheduled' }; };
441
+ try {
442
+ const ctx = { cwd: os.tmpdir() };
443
+ for (const handler of handlers) {
444
+ await handler({ toolName: 'edit', isError: false, input: { path: '/tmp/a.js' } }, ctx);
445
+ await handler({ toolName: 'write', isError: false, input: { path: '/tmp/b.js' } }, ctx);
446
+ await handler({ toolName: 'edit', isError: true, input: { path: '/tmp/c.js' } }, ctx);
447
+ await handler({ toolName: 'bash', isError: false, input: { command: 'ls' } }, ctx);
448
+ await handler({ toolName: 'edit', isError: false, input: {} }, ctx);
449
+ await handler({ toolName: 'edit', isError: false }, ctx);
450
+ await handler({}, ctx);
451
+ }
452
+ } finally {
453
+ session.scheduleSync = original;
454
+ }
455
+ assert.deepEqual(calls, ['/tmp/a.js', '/tmp/b.js']);
456
+ });
457
+ });
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+ // S7: the `## ODD worktrees` section and its append-only injection. The two
3
+ // texts are the contract, so they are asserted verbatim; the gate is injected
4
+ // so no test depends on whether the machine has the CLI.
5
+ const { test, describe, after } = require('node:test');
6
+ const assert = require('node:assert/strict');
7
+ const os = require('node:os');
8
+
9
+ const prompt = require('../lib/odd-worktrees-prompt.js');
10
+
11
+ const WITH_CODEGRAPH = [
12
+ '## ODD worktrees',
13
+ '',
14
+ 'Each ODD feature has its own worktree. Before starting implementation, check',
15
+ 'whether that ODD already has an assigned worktree: if it does, resume it there;',
16
+ 'if it does not, create one before starting.',
17
+ '',
18
+ '`worktree_create` places the checkout at `<repo-parent>/<repo-name>-worktrees/<name>`',
19
+ 'and runs `codegraph init` there, so the new checkout has its own index from the',
20
+ 'start. Edits and writes trigger a background `codegraph sync` for that checkout.',
21
+ 'Every worktree keeps its own `.codegraph/`; never copy, symlink or reuse another',
22
+ "checkout's index.",
23
+ '',
24
+ 'Keep all work for one ODD in its assigned worktree. Do not create a second one',
25
+ 'to continue it.',
26
+ ].join('\n');
27
+
28
+ const WITHOUT_CODEGRAPH = [
29
+ '## ODD worktrees',
30
+ '',
31
+ 'Each ODD feature has its own worktree. Before starting implementation, check',
32
+ 'whether that ODD already has an assigned worktree: if it does, resume it there;',
33
+ 'if it does not, create one before starting.',
34
+ '',
35
+ '`worktree_create` places the checkout at `<repo-parent>/<repo-name>-worktrees/<name>`.',
36
+ '',
37
+ 'Keep all work for one ODD in its assigned worktree. Do not create a second one',
38
+ 'to continue it.',
39
+ ].join('\n');
40
+
41
+ const savedEnv = { ...process.env };
42
+ after(() => {
43
+ for (const key of Object.keys(process.env)) {
44
+ if (!(key in savedEnv)) delete process.env[key];
45
+ }
46
+ for (const [key, value] of Object.entries(savedEnv)) process.env[key] = value;
47
+ });
48
+
49
+ function clearEnv() {
50
+ delete process.env.ODD_WORKTREE_PROMPT;
51
+ delete process.env.GENTLE_PI_AGENTS_CHILD;
52
+ }
53
+
54
+ function run(event, deps = {}) {
55
+ return prompt.appendOddWorktreesSection(event, deps);
56
+ }
57
+
58
+ const present = () => true;
59
+ const absent = () => false;
60
+ const primaryEvent = (options) => ({ systemPromptOptions: options });
61
+
62
+ describe('section text', () => {
63
+ test('the CodeGraph variant is exactly the specified text', () => {
64
+ const options = {};
65
+ assert.equal(run(primaryEvent(options), { env: {}, hasCodegraph: present }), undefined);
66
+ assert.equal(options.appendSystemPrompt, WITH_CODEGRAPH);
67
+ });
68
+
69
+ test('the variant without CodeGraph is exactly the specified text', () => {
70
+ const options = {};
71
+ run(primaryEvent(options), { env: {}, hasCodegraph: absent });
72
+ assert.equal(options.appendSystemPrompt, WITHOUT_CODEGRAPH);
73
+ });
74
+ });
75
+
76
+ describe('append-only injection', () => {
77
+ test('existing harness text is preserved verbatim and the block follows it', () => {
78
+ const harness = 'You are Pi.\n\nUse the tools.';
79
+ const options = { appendSystemPrompt: harness };
80
+ run(primaryEvent(options), { env: {}, hasCodegraph: absent });
81
+ assert.ok(options.appendSystemPrompt.startsWith(harness), 'existing text is the prefix');
82
+ assert.equal(options.appendSystemPrompt, `${harness}\n\n${WITHOUT_CODEGRAPH}`);
83
+ });
84
+
85
+ test('a trailing newline in the existing text keeps the separator minimal', () => {
86
+ const harness = 'You are Pi.\n';
87
+ const options = { appendSystemPrompt: harness };
88
+ run(primaryEvent(options), { env: {}, hasCodegraph: absent });
89
+ assert.equal(options.appendSystemPrompt, `${harness}\n${WITHOUT_CODEGRAPH}`);
90
+ });
91
+
92
+ test('never returns or assigns systemPrompt', () => {
93
+ const event = { systemPromptOptions: {}, systemPrompt: 'harness-owned' };
94
+ const returned = run(event, { env: {}, hasCodegraph: absent });
95
+ assert.equal(returned, undefined);
96
+ assert.equal(event.systemPrompt, 'harness-owned');
97
+ assert.ok(!('systemPrompt' in event.systemPromptOptions));
98
+ });
99
+
100
+ test('idempotent across two calls', () => {
101
+ const options = {};
102
+ run(primaryEvent(options), { env: {}, hasCodegraph: present });
103
+ const once = options.appendSystemPrompt;
104
+ run(primaryEvent(options), { env: {}, hasCodegraph: present });
105
+ assert.equal(options.appendSystemPrompt, once);
106
+ assert.equal(options.appendSystemPrompt, WITH_CODEGRAPH);
107
+ });
108
+
109
+ test('a variant switch mid-session never duplicates the heading', () => {
110
+ const options = {};
111
+ run(primaryEvent(options), { env: {}, hasCodegraph: absent });
112
+ run(primaryEvent(options), { env: {}, hasCodegraph: present });
113
+ const headings = options.appendSystemPrompt.split('## ODD worktrees').length - 1;
114
+ assert.equal(headings, 1, 'the section is injected once, whatever the variant');
115
+ });
116
+ });
117
+
118
+ describe('session gating', () => {
119
+ test('a named agent gets nothing', () => {
120
+ for (const event of [
121
+ { agentName: 'worker', systemPromptOptions: {} },
122
+ { agent: 'worker', systemPromptOptions: {} },
123
+ { agent: { name: 'worker' }, systemPromptOptions: {} },
124
+ { name: 'worker', systemPromptOptions: {} },
125
+ { subagent: { name: 'worker' }, systemPromptOptions: {} },
126
+ ]) {
127
+ run(event, { env: {}, hasCodegraph: present });
128
+ assert.equal(event.systemPromptOptions.appendSystemPrompt, undefined);
129
+ }
130
+ });
131
+
132
+ test('a GENTLE_PI_AGENTS_CHILD worker gets nothing', () => {
133
+ clearEnv();
134
+ process.env.GENTLE_PI_AGENTS_CHILD = '1';
135
+ const options = {};
136
+ run(primaryEvent(options), { env: process.env, hasCodegraph: present });
137
+ assert.equal(options.appendSystemPrompt, undefined);
138
+ });
139
+
140
+ test('ODD_WORKTREE_PROMPT=0 suppresses the block entirely', () => {
141
+ clearEnv();
142
+ process.env.ODD_WORKTREE_PROMPT = '0';
143
+ const options = {};
144
+ run(primaryEvent(options), { env: process.env, hasCodegraph: present });
145
+ assert.equal(options.appendSystemPrompt, undefined);
146
+ });
147
+
148
+ test('missing systemPromptOptions is a no-op that never throws', () => {
149
+ assert.doesNotThrow(() => run({}, { env: {}, hasCodegraph: present }));
150
+ assert.doesNotThrow(() => run(undefined, { env: {}, hasCodegraph: present }));
151
+ assert.doesNotThrow(() => run({ systemPromptOptions: null }, { env: {}, hasCodegraph: present }));
152
+ });
153
+ });
154
+
155
+ describe('extension wiring (T3)', () => {
156
+ test('before_agent_start appends the section to the prompt options', async () => {
157
+ clearEnv();
158
+ const { loadExtension } = require('./fixtures/load-extension.js');
159
+ const { hooks } = await loadExtension({});
160
+ const handlers = hooks.filter(([name]) => name === 'before_agent_start').map(([, handler]) => handler);
161
+ assert.equal(handlers.length, 1, 'exactly one before_agent_start handler');
162
+ const options = {};
163
+ await handlers[0]({ systemPromptOptions: options }, { cwd: os.tmpdir() });
164
+ assert.ok(
165
+ options.appendSystemPrompt === WITH_CODEGRAPH || options.appendSystemPrompt === WITHOUT_CODEGRAPH,
166
+ 'the injected block is one of the two exact variants',
167
+ );
168
+ assert.doesNotThrow(() => handlers[0]({}, {}));
169
+ });
170
+ });
@@ -1,7 +1,9 @@
1
1
  'use strict';
2
- // S2/S4 surface: the standalone package registers exactly three commands
3
- // (/warp, /odd-list and /issues) and the four agent worktree tools, loads
4
- // without any Herdr binary, and ships none of the removed functionality
2
+ // S2/S4 surface: the standalone package always registers /warp, /odd-list and
3
+ // /issues plus the four agent worktree tools, loads without any Herdr binary,
4
+ // and ships none of the removed functionality. S9 adds three /codegraph-*
5
+ // commands that exist ONLY when the session gate finds the CLI, so the exact
6
+ // command list depends on the machine and is asserted conditionally.
5
7
  // (customize panel, plugin-tools toggles, worktree mode file, perf tracing).
6
8
  // The issues reader, preview formatter and view are the migrated T2 units and
7
9
  // are shipped; `/issues` is the T3 command that mounts them, without any
@@ -76,7 +78,14 @@ describe('registration', () => {
76
78
  process.env.HERDR_BIN_PATH = NO_HERDR;
77
79
  delete process.env.GENTLE_WORKTREE_THREAD;
78
80
  const { commands, tools, messageRenderers } = await loadExtension({});
79
- assert.deepEqual([...commands.keys()].sort(), ['issues', 'odd-list', 'warp']);
81
+ const registered = [...commands.keys()].sort();
82
+ const base = ['issues', 'odd-list', 'warp'];
83
+ const codegraph = ['codegraph-hook', 'codegraph-init', 'codegraph-sync'];
84
+ for (const name of base) assert.ok(registered.includes(name), `/${name} is registered`);
85
+ // S9: the codegraph triple is gated on the CLI, never on anything else.
86
+ const { hasCodegraph } = require('../lib/codegraph-session.js');
87
+ const expected = hasCodegraph(process.env) ? [...base, ...codegraph].sort() : base;
88
+ assert.deepEqual(registered, expected, 'the command list is the base three plus the codegraph triple exactly when the CLI is present');
80
89
  assert.deepEqual([...tools.keys()].sort(), ['worktree_close', 'worktree_create', 'worktree_list', 'worktree_warp']);
81
90
  assert.deepEqual([...messageRenderers.keys()], ['worktree-warp']);
82
91
  });