dsh-graft-plugin 0.2.1 → 0.3.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,160 @@
1
+ /**
2
+ * The standing instruction must reach the model on CURRENT dsh.
3
+ *
4
+ * dsh `0.1.7-rc.2` removed the `agent/session-start` event the guidance used to
5
+ * ride: the listener stayed registered and the suite stayed green, while no
6
+ * agent ever fired it, so `graft-status:usage` silently vanished from every
7
+ * prompt. This suite runs `apply()` against a REAL Cordis context and drives
8
+ * the event dsh actually emits now (`agent/created`, the same seam dsh's own
9
+ * file-reference plugin uses), so an upstream rename fails here instead of
10
+ * disappearing into a prompt nobody diffs.
11
+ *
12
+ * Cordis resolves injects asynchronously, so every check after a trigger
13
+ * waits a tick first — the assertions test what a live session would see,
14
+ * not what a synchronous read-back would flatter.
15
+ *
16
+ * Run: node scripts/test-guidance.mjs (part of `npm test`)
17
+ */
18
+ import { readFile } from 'node:fs/promises'
19
+ import { Context, Service } from '@deepseek-ai/cordis'
20
+
21
+ import { apply } from '../src/index.js'
22
+
23
+ let failures = 0
24
+ const check = (label, ok, extra = '') => {
25
+ console.log((ok ? 'PASS ' : 'FAIL ') + label + (extra ? ' -> ' + extra : ''))
26
+ if (!ok) failures += 1
27
+ }
28
+ /** Let pending Cordis inject fibers and disposals settle. */
29
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
30
+
31
+ /** The whole prompt section this plugin registers, as the tests see it. */
32
+ const SECTION_NAME = 'graft-status:usage'
33
+ const GUIDANCE_ORDER = 950
34
+
35
+ /**
36
+ * A prompt-registry stand-in that behaves like the real `systemPrompt`
37
+ * service where it matters. It is a Cordis Service, so the traceable-context
38
+ * overlay applies, and `section()` creates its undo effect on the CALLER's
39
+ * context — the same ownership `SystemPrompt.section()` gets through
40
+ * `ScopedLayers`, and the reason an agent's section can die with the agent.
41
+ */
42
+ function fakePromptRegistry(sections, removed) {
43
+ return class FakePromptRegistry extends Service {
44
+ constructor(ctx) {
45
+ super(ctx, 'systemPrompt')
46
+ }
47
+
48
+ section(section) {
49
+ sections.push(section)
50
+ return this.ctx.effect(() => () => {
51
+ removed.push(section.name)
52
+ }, `fake.section(${section.name})`)
53
+ }
54
+ }
55
+ }
56
+
57
+ /** Mount the stand-in on a context as the `systemPrompt` service. */
58
+ function providePromptRegistry(ctx, sections, removed) {
59
+ ctx.plugin(fakePromptRegistry(sections, removed))
60
+ }
61
+
62
+ console.log('--- apply() against a real Cordis context ---')
63
+ const root = new Context()
64
+ const registeredTools = []
65
+ const sections = []
66
+ const removed = []
67
+ root.plugin(fakePromptRegistry(sections, removed))
68
+ root.reflect.provide('tools', {
69
+ register(tool) {
70
+ registeredTools.push(tool)
71
+ return () => {}
72
+ },
73
+ })
74
+
75
+ let threw
76
+ try {
77
+ await apply(root, {})
78
+ } catch (error) {
79
+ threw = error
80
+ }
81
+ check('apply() mounts without throwing', threw === undefined, threw ? String(threw) : '')
82
+ check('the five graft tools register', registeredTools.length === 5, registeredTools.map((t) => t.name).join(', '))
83
+
84
+ console.log('\n--- the guidance rides agent/created, not the removed agent/session-start ---')
85
+ check(
86
+ 'the source never names the removed event',
87
+ !(await readFile(new URL('../src/index.js', import.meta.url), 'utf8')).includes('agent/session-start'),
88
+ )
89
+ check('no guidance before any agent exists', sections.length === 0, JSON.stringify(sections))
90
+
91
+ console.log('\n--- an agent with the prompt registry gets the section ---')
92
+ const agent = { ctx: new Context() }
93
+ providePromptRegistry(agent.ctx, sections, removed)
94
+ root.emit('agent/created', { agent })
95
+ await settle()
96
+ check('the section registered on the agent scope', sections.length === 1, JSON.stringify(sections[0]?.name))
97
+ check('under its own name', sections[0]?.name === SECTION_NAME, sections[0]?.name)
98
+ check('at the documented order', sections[0]?.order === GUIDANCE_ORDER, String(sections[0]?.order))
99
+ check('with the graft guidance text', /graft_ask/.test(sections[0]?.text ?? ''), (sections[0]?.text ?? '').split('\n')[0])
100
+
101
+ console.log('\n--- a second announce does not duplicate it ---')
102
+ root.emit('agent/created', { agent })
103
+ await settle()
104
+ check('the section registered exactly once', sections.length === 1, String(sections.length))
105
+
106
+ console.log('\n--- agent disposal takes the section down ---')
107
+ root.emit('agent/disposed', { agent })
108
+ await settle()
109
+ check('the section was disposed with the agent', removed.length === 1, JSON.stringify(removed))
110
+
111
+ console.log('\n--- an agent WITHOUT the prompt registry only loses the sentence ---')
112
+ const bare = { ctx: new Context() }
113
+ let bareThrew
114
+ try {
115
+ root.emit('agent/created', { agent: bare })
116
+ await settle()
117
+ } catch (error) {
118
+ bareThrew = error
119
+ }
120
+ check('a registry-less agent is survived, not crashed', bareThrew === undefined, bareThrew ? String(bareThrew) : '')
121
+ check('and it adds no section', sections.length === 1, String(sections.length))
122
+
123
+ console.log('\n--- agents that already exist when the row mounts are briefed too ---')
124
+ const lateRoot = new Context()
125
+ lateRoot.reflect.provide('tools', { register: () => () => {} })
126
+ providePromptRegistry(lateRoot, sections, removed)
127
+ lateRoot.reflect.provide('agents', { list: () => [agent, bare] })
128
+ const before = sections.length
129
+ let lateThrew
130
+ try {
131
+ await apply(lateRoot, {})
132
+ await settle()
133
+ } catch (error) {
134
+ lateThrew = error
135
+ }
136
+ check('apply() with an agents service does not throw', lateThrew === undefined, lateThrew ? String(lateThrew) : '')
137
+ check('pre-existing agents got the section at mount', sections.length === before + 1, `${sections.length - before} added`)
138
+ check('and the registry-less one stayed silent', sections.every((s) => s.name === SECTION_NAME))
139
+
140
+ console.log('\n--- promptSection: false registers nothing ---')
141
+ const quietRoot = new Context()
142
+ quietRoot.reflect.provide('tools', { register: () => () => {} })
143
+ providePromptRegistry(quietRoot, sections, removed)
144
+ quietRoot.reflect.provide('agents', { list: () => [agent] })
145
+ await apply(quietRoot, { promptSection: false })
146
+ quietRoot.emit('agent/created', { agent })
147
+ await settle()
148
+ check('no guidance section from a suppressed row', sections.length === before + 1, `${sections.length - before - 1} added`)
149
+
150
+ console.log('\n--- teardown ---')
151
+ for (const ctx of [root, agent.ctx, lateRoot, quietRoot]) {
152
+ try {
153
+ await ctx.fiber?.dispose?.()
154
+ } catch {
155
+ // A test context holds only fake services; nothing here may block exit.
156
+ }
157
+ }
158
+
159
+ console.log(failures === 0 ? '\nALL CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`)
160
+ process.exit(failures === 0 ? 0 : 1)
@@ -14,7 +14,8 @@
14
14
  import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
15
15
  import { tmpdir } from 'node:os'
16
16
  import { join } from 'node:path'
17
- import { computeStatus, countWiring, findGraftRoot, freshnessOf } from '../src/index.js'
17
+ import { computeStatus, countWiring, createToolPulse, findGraftRoot, freshnessOf } from '../src/index.js'
18
+ import { graftTools } from '../src/graft-tools.js'
18
19
 
19
20
  let failures = 0
20
21
  const check = (label, ok, extra = '') => {
@@ -113,6 +114,44 @@ check('and a space in the path does not break it', OTHER.includes(' '))
113
114
  const nowhere = await findGraftRoot(BARE, exists)
114
115
  check('an unindexed tree resolves to nothing', nowhere === undefined, String(nowhere))
115
116
 
117
+ console.log('\n--- git worktrees are their own graft root ---')
118
+ // A linked worktree's .git is a FILE pointing at its admin dir, whose
119
+ // commondir names the shared .git. The one that matters is NESTED inside the
120
+ // main checkout: without the boundary, resolution walked straight past it and
121
+ // the tools answered from the main checkout's graph — wrong paths for this
122
+ // branch's files.
123
+ await mkdir(join(REPO, '.git'), { recursive: true })
124
+ const wt = join(REPO, 'wt')
125
+ const gitDir = join(fixture, 'main.git', 'worktrees', 'wt')
126
+ await mkdir(join(wt, 'src'), { recursive: true })
127
+ await mkdir(gitDir, { recursive: true })
128
+ await writeFile(join(wt, '.git'), `gitdir: ${gitDir}\n`, 'utf8')
129
+ await writeFile(join(gitDir, 'commondir'), '../..\n', 'utf8')
130
+
131
+ const fromWt = await findGraftRoot(join(wt, 'src'), exists)
132
+ check('a nested worktree never answers from the main graph above it', fromWt === undefined, String(fromWt))
133
+
134
+ const mainAgain = await findGraftRoot(REPO, exists)
135
+ check('the main checkout itself still resolves', samePath(mainAgain, REPO), String(mainAgain))
136
+
137
+ const mainStatus = await computeStatus(REPO, io)
138
+ check('a main checkout is not reported as a worktree', mainStatus.worktreeOf === undefined, String(mainStatus.worktreeOf))
139
+
140
+ // Once the worktree has its own index (graft seeds it from main on build),
141
+ // resolution stays inside the worktree and the status names its repo.
142
+ await mkdir(join(wt, 'graft', '.cache'), { recursive: true })
143
+ await writeFile(join(wt, 'graft', 'INDEX.md'), '# graft repo map\n')
144
+ await writeFile(
145
+ join(wt, 'graft', '.cache', 'stats.json'),
146
+ JSON.stringify({ nodeCount: 12, edgeCount: 7, dirty: false, staleCount: 0, syncedAt: '2026-09-11T09:00:00.000Z' }),
147
+ )
148
+ const wtIndexed = await findGraftRoot(join(wt, 'src'), exists)
149
+ check('a worktree with its own index resolves to itself', samePath(wtIndexed, wt), String(wtIndexed))
150
+
151
+ const wtStatus = await computeStatus(wt, io)
152
+ check('status names the main checkout the worktree belongs to', samePath(wtStatus.worktreeOf, fixture), String(wtStatus.worktreeOf))
153
+ check('and still serves the worktree graph', wtStatus.nodeCount === 12, String(wtStatus.nodeCount))
154
+
116
155
  console.log('\n--- status from the cache ---')
117
156
  const status = await computeStatus(REPO, io)
118
157
  check('reports the cache node count', status.nodeCount === 1284, String(status.nodeCount))
@@ -199,6 +238,53 @@ const cannotSpawn = await startViz('C:/x', 4400, {
199
238
  })
200
239
  check('an unspawnable graft is a reason, not a throw', cannotSpawn.ok === false && /not on PATH/.test(cannotSpawn.reason))
201
240
 
241
+ /* ========================================================================== *
242
+ * Tool-use pulses: any graft_* call must reach the chip's dot
243
+ * ========================================================================== */
244
+
245
+ console.log('\n--- tool use is recorded against its repo ---')
246
+ const pulse = createToolPulse()
247
+ check('an unused repo has no pulse', pulse.get(REPO) === undefined)
248
+ pulse.note(REPO)
249
+ const first = pulse.get(REPO)
250
+ pulse.note(REPO)
251
+ const second = pulse.get(REPO)
252
+ check('repeated uses count up per root', first.count === 1 && second.count === 2, JSON.stringify(first) + ' vs ' + JSON.stringify(second))
253
+ check('the pulse carries when it last happened', typeof first.at === 'number' && second.at >= first.at)
254
+ check('another repo pulses independently', pulse.get(OTHER) === undefined)
255
+ pulse.note('')
256
+ check('an empty root is ignored', pulse.get('') === undefined)
257
+
258
+ console.log('\n--- every graft tool announces its own use ---')
259
+ const pulsed = []
260
+ const spawnedArgv = []
261
+ const fakeSpawn = (command, argv) => {
262
+ spawnedArgv.push([command, argv])
263
+ return {
264
+ stdout: { on() {} },
265
+ stderr: { on() {} },
266
+ on(event, fn) { if (event === 'close') fn() },
267
+ kill() {},
268
+ }
269
+ }
270
+ const pulseTools = graftTools({
271
+ fallbackCwd: BARE,
272
+ // BARE has no index: the no-graph branch. Everything else resolves to REPO.
273
+ findRoot: async (start) => (start === BARE ? undefined : REPO),
274
+ noteUse: (root) => pulsed.push(root),
275
+ command: { command: 'graft-fake', prefix: [], shell: false },
276
+ spawn: fakeSpawn,
277
+ })
278
+ const ask = pulseTools.find((t) => t.name === 'graft_ask')
279
+ const answered = await ask.execute({ query: 'anything' }, { agent: { session: { header: { cwd: OTHER } } } })
280
+ check('the call still answers', typeof answered.text === 'string' && answered.text !== '', answered.text.slice(0, 60))
281
+ check('rooted at the session repo, not the process cwd', samePath(spawnedArgv[0]?.[1]?.at(-1), REPO), String(spawnedArgv[0]?.[1]?.at(-1)))
282
+ check('one call, one pulse, for the right root', pulsed.length === 1 && samePath(pulsed[0], REPO), JSON.stringify(pulsed))
283
+
284
+ const noGraph = await ask.execute({ query: 'anything' }, { agent: { session: { header: { cwd: BARE } } } })
285
+ check('a workspace with no graph explains itself', /No graft index/.test(noGraph.text))
286
+ check('and does not pulse — there is no green dot to light', pulsed.length === 1, JSON.stringify(pulsed))
287
+
202
288
  /* ========================================================================== *
203
289
  * The new-tab handle (a source-level guard)
204
290
  * ========================================================================== */
@@ -255,6 +341,102 @@ check('the type and its body register under the same id', clientSource.includes(
255
341
  check('that dependency is soft too', clientSource.includes('ctx.inject(["sidebarRightTabs", "slots"]') && !/const inject = [[]/.test('') && clientSource.indexOf('const inject = ["slots", "remote"]') !== -1)
256
342
  check('the body reads the session from either host', clientSource.includes('props?.sessionId ?? props?.scope?.sessionId'))
257
343
  check('the browser tab survives only as the last resort', clientSource.includes('neither sidebar'))
344
+ // The viz panel bar shows which checkout a worktree graph belongs to.
345
+ check('the viz panel names the worktree when the graph serves one', clientSource.includes('"worktree of " + wt') && clientSource.includes('status.worktreeOf'))
346
+
347
+ console.log('\n--- the dot flickers blue while a graft tool runs ---')
348
+ // The pulse rides the status poll, so it must bypass the five-second status
349
+ // cache: a blink that waited out `cacheMs` would land after the call ended.
350
+ const indexSource = await readFile(new URL('../src/index.js', import.meta.url), 'utf8')
351
+ check('the status payload carries the pulse', /toolUse: pulses\.get\(root\)/.test(indexSource))
352
+ check('on the cache-hit path too', (indexSource.match(/toolUse: pulses\.get\(root\)/g) ?? []).length === 2)
353
+ check('the flicker style exists and sits on the dot', clientSource.includes('.gs-chip.gs-live .gs-dot{color:#8b5cf6}'))
354
+ check('the client counts incoming pulses', clientSource.includes('toolUse?.count'))
355
+ check('a fresh pulse blinks, on and off, not solid', /for \(const ms of \[0, 175, 350, 525, 700, 875, 1050, 1225, 1400, 1575, 1750, 1925\]\)/.test(clientSource))
356
+ // Toggles alternate on/off, so an ODD number of marks ends ON — which shipped
357
+ // once as a dot stuck purple after every blink. An even count ends off.
358
+ const marks = clientSource.match(/for \(const ms of \[((?:\d+)(?:, \d+)*)\]\)/)?.[1]?.split(', ').map(Number) ?? []
359
+ check('the blink ends off, back at the resting colour', marks.length > 0 && marks.length % 2 === 0, JSON.stringify(marks))
360
+ check('polling speeds up while graft tools are active', clientSource.includes('settled && !recent ? 4000 : 2000'))
361
+ check('a pulse keeps the fast poll for a tail, not just the blink', clientSource.includes('window.setTimeout(() => setRecent(false), 20000)'))
362
+ check('the flicker timers are cleaned up on unmount', clientSource.includes('for (const t of flickTimers.current) window.clearTimeout(t)'))
363
+
364
+ console.log('\n--- the chip label truncates, the popup builds ---')
365
+ // A long workspace name must collapse to an ellipsis in the trailing-row
366
+ // chip, not stretch the composer. The popup keeps the full path.
367
+ check(
368
+ 'the chip label carries its own ellipsis rule',
369
+ clientSource.includes('.gs-chip .gs-label{overflow:hidden;text-overflow:ellipsis'),
370
+ )
371
+ check('and the chip renders the label under that class', clientSource.includes('h("span", { className: "gs-label" }'))
372
+ // The popup's Build button calls a `build` remote method — the same plain
373
+ // offline `graft build` the bare /graft runs — with a busy state while it
374
+ // is in flight, then a status re-read so the card shows the fresh graph.
375
+ check('the client declares a build descriptor', clientSource.includes('descriptor("build", ["sessionId"])'))
376
+ check('the host serves a build method', indexSource.includes('markRemoteMethod(GraftStatusRemote.prototype, \'build\')'))
377
+ // The button rebuilds through the SAME sync-run.js the automatic hooks run —
378
+ // never a bare `graft build`, which writes the graph but NOT stats.json, so
379
+ // the pill kept reading the old `dirty` flag and never flipped to IN SYNC.
380
+ // That shipped once as a press that changed nothing.
381
+ check('the build goes through the shared rebuild engine', /build[\s\S]{0,600}autoSync\.rebuild\(root\)/.test(indexSource))
382
+ check('a finished build drops the stale cache', /build[\s\S]*?cache\.delete\(root\)/.test(indexSource))
383
+ check('the popup has a Build button', clientSource.includes('"Build"'))
384
+ check('it disables while the build runs', clientSource.includes('disabled: building'))
385
+ // The rebuild button sits IN the header row, right of the status pill — not
386
+ // in a footer row of its own.
387
+ check('no footer row survives the move', !clientSource.includes('gs-pop-buildrow'))
388
+ check(
389
+ 'one header group, rendered in both heads',
390
+ (clientSource.match(/headGroup\(h\(/g) ?? []).length === 2,
391
+ String((clientSource.match(/headGroup\(h\(/g) ?? []).length),
392
+ )
393
+ // Pill and Build travel as one group pinned right: a wrapper carries the
394
+ // row's only auto margin, so the flex gap is the only thing between the
395
+ // pair and nothing can split them — either apart or across the header.
396
+ check('the header pills travel in one right-pinned group', clientSource.includes('.gs-pop-group{margin-left:auto'))
397
+ check('the pill no longer spaces itself away from the button', !clientSource.includes('.gs-pill{margin-left:auto'))
398
+ // The card itself is click-through (pointer-events:none), which was fine
399
+ // while it was pure text — but any button inside it is dead unless it
400
+ // re-enables events on itself. That shipped once as a button that could
401
+ // never be pressed; this guard is the reason it will not ship again.
402
+ check('the Build button re-enables pointer events on itself', clientSource.includes('.gs-build{') && /\.gs-build\{[^}]*pointer-events:auto/.test(clientSource))
403
+ // The button holds Building… until OBSERVED freshness flips to synced, not
404
+ // until the build promise lands: a build that exits while stats.json still
405
+ // reads dirty (plain `graft build` never patches it) must not re-enable the
406
+ // button against a pill that still says STALE. That shipped once as a press
407
+ // that changed nothing; this guard is the reason it will not ship again.
408
+ check('the button waits for observed sync, not the promise', clientSource.includes('waitForSynced'))
409
+ check('with a bounded wait, not forever', clientSource.includes('SYNC_WAIT_MS'))
410
+ check('the seat hands the waiter to the card', clientSource.includes('onSynced: waitForSynced'))
411
+ check('a build that never reports sync re-enables with an error', clientSource.includes('did not report in sync'))
412
+
413
+ console.log('\n--- Build in a workspace with no index confirms first ---')
414
+ // The prompt is dsh's own themed Modal, not the browser's ugly box: the card
415
+ // requires the primitives package permission-presets already proves a client
416
+ // bundle can require. Guarded — if the package is ever absent the prompt
417
+ // falls back to window.confirm, so the gate can never go missing silently.
418
+ check('the card prefers dsh\'s own Modal', clientSource.includes('Primitives.Modal') && clientSource.includes('dsh-client-ui-primitives'))
419
+ check('the Modal carries the confirm copy', clientSource.includes('Build graft index?'))
420
+ check('its footer holds both accept and decline', clientSource.includes('Build index') && clientSource.includes('"Cancel"'))
421
+ // Guarded require: if primitives is ever unresolvable the gate falls back to
422
+ // the ugly box rather than going missing silently. The fallback is the ONLY
423
+ // window.confirm in the file.
424
+ check('only the fallback uses the browser box', (clientSource.match(/window\.confirm\(/g) ?? []).length === 1)
425
+ // The modal accept must START the build while the modal is still open: it may
426
+ // only close the modal after the build promise is underway. Closing first
427
+ // (unmounting the card's modal subtree in the same tick) shipped once as an
428
+ // accept that dismissed the dialog and built nothing.
429
+ check('the accept starts the build while the modal is open', /startBuild[\s\S]{0,300}setConfirming\(false\)/.test(clientSource))
430
+ check('the modal never closes before the build promise exists', !/setConfirming\(false\)[\s\S]{0,60}Promise\.resolve\(runBuild/.test(clientSource))
431
+ // The confirm modal portals to document.body — OUTSIDE the card's wrap — so
432
+ // the seat's capture-phase click-away closer would unmount the card (modal
433
+ // included) before the accept's own click dispatches. That shipped once as an
434
+ // accept that hid the dialog and built nothing; the closer must let modal
435
+ // clicks through.
436
+ check('the click-away closer lets modal clicks through', /closest\(['"]\[role="dialog"\]/.test(clientSource))
437
+ check('the accept runs the plain offline initialise', /runGraft\(\['build', from\], from/.test(indexSource))
438
+ check('a failed initialise reports graft\'s own message', /produced no index/.test(indexSource))
439
+ check('a fresh initialise resolves as initialising', /initialising: true/.test(indexSource))
258
440
 
259
441
  // The remote descriptors must survive dsh 0.2's typert codec validation, which
260
442
  // runs inside `ctx.remote.$mount`: a strict codec needs a typeSymbol and a
@@ -280,7 +462,7 @@ function codecProblem(codec, { parameter }) {
280
462
  if (typeof codec.schema?.parse !== 'function') return 'no 0.1 schema fallback'
281
463
  return undefined
282
464
  }
283
- check('declares the status and viz descriptors', CONTRIBUTION.descriptors.map((d) => d.method).join() === 'status,viz')
465
+ check('declares the status, viz and build descriptors', CONTRIBUTION.descriptors.map((d) => d.method).join() === 'status,viz,build')
284
466
  for (const d of CONTRIBUTION.descriptors) {
285
467
  for (const p of d.parameters) {
286
468
  const problem = codecProblem(p.codec, { parameter: true })
package/src/auto-sync.js CHANGED
@@ -71,6 +71,10 @@ const DEFAULT_WRITE_TOOLS = ['write', 'edit', 'str_replace_editor', 'pwsh', 'bas
71
71
 
72
72
  /** Graft's own state helpers, loaded from wherever graft is installed. */
73
73
  async function loadGraftState(cliPath) {
74
+ // A cli we cannot locate (bare shim on PATH) must degrade to "unavailable",
75
+ // never throw: this runs inside fire-and-forget middleware and a rejection
76
+ // here is fatal to dsh.
77
+ if (typeof cliPath !== 'string' || cliPath === '') return undefined
74
78
  const claudeDir = join(dirname(cliPath), 'claude')
75
79
  const statePath = join(claudeDir, 'state.js')
76
80
  const syncRun = join(claudeDir, 'sync-run.js')
@@ -132,7 +136,11 @@ export function createAutoSync(deps = {}) {
132
136
  let loaded
133
137
  const graft = async () => {
134
138
  if (loaded === undefined) {
135
- loaded = deps.graft !== undefined ? deps.graft : await loadGraftState(deps.cliPath)
139
+ try {
140
+ loaded = deps.graft !== undefined ? await deps.graft : await loadGraftState(deps.cliPath)
141
+ } catch {
142
+ loaded = null // any loader failure counts as "graft unavailable"
143
+ }
136
144
  if (loaded === undefined) loaded = null
137
145
  }
138
146
  return loaded
@@ -161,7 +169,7 @@ export function createAutoSync(deps = {}) {
161
169
  root,
162
170
  setTimer(() => {
163
171
  timers.delete(root)
164
- void api.syncIfDirty(root)
172
+ void api.syncIfDirty(root).catch(() => {})
165
173
  }, debounceMs),
166
174
  )
167
175
  }
@@ -201,6 +209,30 @@ export function createAutoSync(deps = {}) {
201
209
  }
202
210
  if (stats?.dirty !== true && !pending.has(root)) return 'clean'
203
211
 
212
+ return api.rebuild(root)
213
+ },
214
+
215
+ /**
216
+ * Rebuild one repo NOW, whether or not it looks dirty.
217
+ *
218
+ * The popup's Build button lands here, not in syncIfDirty: its gate reads
219
+ * graft's `dirty` flag, and a repo rebuilt by a plain `graft build` — which
220
+ * never patches stats.json — can read clean while being stale, answering
221
+ * 'clean' and spawning nothing. A pressed button that changes nothing is
222
+ * the failure this exists to prevent. The lock and the interval floor
223
+ * still apply: forced means "skip the dirt check", not "race a running
224
+ * build".
225
+ *
226
+ * @returns why it did or did not run, for the harness and the log.
227
+ */
228
+ rebuild: async (root) => {
229
+ if (typeof root !== 'string' || root === '') return 'no-root'
230
+ const g = await graft()
231
+ if (g === null) return 'graft-unavailable'
232
+
233
+ const since = now() - (lastRun.get(root) ?? -Infinity)
234
+ if (since < minIntervalMs) return 'too-soon'
235
+
204
236
  // The lock is graft's, so a rebuild already running — started here, or by
205
237
  // Claude Code in the same repo — is left alone rather than raced.
206
238
  try {