dsh-graft-plugin 0.1.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.
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Terminal harness for graft-status.
3
+ *
4
+ * Everything runs against fixtures, including a synthetic graft repo built in
5
+ * a temp directory, so the suite passes on a fresh clone with no graft index
6
+ * and no graft installed anywhere on the machine.
7
+ *
8
+ * That synthetic repo is laid out exactly as index.js probes for and reads,
9
+ * so it exercises the real contract rather than a mock of it. `npm run
10
+ * test:live` is where the same code is checked against a genuine index.
11
+ *
12
+ * Run: npm test
13
+ */
14
+ import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
15
+ import { tmpdir } from 'node:os'
16
+ import { join } from 'node:path'
17
+ import { computeStatus, countWiring, findGraftRoot, freshnessOf } from '../src/index.js'
18
+
19
+ let failures = 0
20
+ const check = (label, ok, extra = '') => {
21
+ console.log((ok ? 'PASS ' : 'FAIL ') + label + (extra ? ' -> ' + extra : ''))
22
+ if (!ok) failures += 1
23
+ }
24
+
25
+ const exists = async (path) => {
26
+ try {
27
+ await access(path)
28
+ return true
29
+ } catch {
30
+ return false
31
+ }
32
+ }
33
+ const readJson = async (path) => {
34
+ try {
35
+ return JSON.parse(await readFile(path, 'utf8'))
36
+ } catch {
37
+ return undefined
38
+ }
39
+ }
40
+ const io = { exists, readJson, readSavedTokens: async () => 0 }
41
+
42
+ /**
43
+ * Same directory, however it happens to be spelled.
44
+ *
45
+ * `path.join` emits backslashes on Windows while the literals here use forward
46
+ * slashes, so a raw `===` would fail on a walk that is actually correct. The
47
+ * product never compares these strings — it only shows the basename — so
48
+ * normalising in the assertion tests the claim that matters (which directory)
49
+ * rather than an incidental one (which separator).
50
+ */
51
+ const samePath = (a, b) =>
52
+ typeof a === 'string' && a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase()
53
+
54
+ console.log('--- freshness ---')
55
+ check('dirty reads as stale', freshnessOf({ dirty: true }) === 'stale')
56
+ check('syncing outranks dirty', freshnessOf({ dirty: true, syncing: true }) === 'syncing')
57
+ check('staleCount alone is stale', freshnessOf({ dirty: false, staleCount: 3 }) === 'stale')
58
+ check('clean reads as synced', freshnessOf({ dirty: false, staleCount: 0 }) === 'synced')
59
+ check('no stats is unknown', freshnessOf(null) === 'unknown')
60
+
61
+ console.log('\n--- wiring counts ---')
62
+ check('counts nodes and edges', JSON.stringify(countWiring({ nodes: [1, 2, 3], edges: [1] })) === '{"nodeCount":3,"edgeCount":1}')
63
+ check('missing edges is zero, not a crash', countWiring({ nodes: [] })?.edgeCount === 0)
64
+ check('a non-graph yields undefined', countWiring({ what: 1 }) === undefined)
65
+ check('null yields undefined', countWiring(null) === undefined)
66
+
67
+ /* ------------------------------------------------------------------------ *
68
+ * A synthetic graft repo, so this suite runs anywhere.
69
+ *
70
+ * The layout is the one index.js actually probes for, not an invention:
71
+ *
72
+ * <repo>/graft/INDEX.md one of the three root markers
73
+ * <repo>/graft/.cache/stats.json the live cache, the preferred source
74
+ * <repo>/graft/.graph/wiring.json the graph, the fallback source
75
+ *
76
+ * One fixture repo deliberately carries a SPACE in its name. Every path here
77
+ * reaches a shell-free spawn eventually, and a space is what breaks that.
78
+ * ------------------------------------------------------------------------ */
79
+ const fixture = await mkdtemp(join(tmpdir(), 'dsh-graft-test-'))
80
+ const REPO = join(fixture, 'repo')
81
+ const OTHER = join(fixture, 'other repo')
82
+ const BARE = join(fixture, 'bare')
83
+
84
+ await mkdir(join(REPO, 'graft', '.cache'), { recursive: true })
85
+ await mkdir(join(REPO, 'graft', '.graph'), { recursive: true })
86
+ await mkdir(join(REPO, 'profiles', 'web'), { recursive: true })
87
+ await mkdir(join(OTHER, 'graft'), { recursive: true })
88
+ await mkdir(BARE, { recursive: true })
89
+ await writeFile(join(REPO, 'graft', 'INDEX.md'), '# graft repo map\n')
90
+ await writeFile(
91
+ join(REPO, 'graft', '.cache', 'stats.json'),
92
+ JSON.stringify({ nodeCount: 1284, edgeCount: 5312, dirty: false, staleCount: 0, syncedAt: '2026-09-11T09:00:00.000Z' }),
93
+ )
94
+ await writeFile(
95
+ join(REPO, 'graft', '.graph', 'wiring.json'),
96
+ JSON.stringify({ nodes: Array.from({ length: 97 }, (_, i) => i), edges: Array.from({ length: 41 }, (_, i) => i) }),
97
+ )
98
+ await writeFile(join(OTHER, 'graft', 'INDEX.md'), '# graft repo map\n')
99
+
100
+ console.log('\n--- root resolution ---')
101
+ const fromRepo = await findGraftRoot(REPO, exists)
102
+ check('finds an indexed repo', samePath(fromRepo, REPO), String(fromRepo))
103
+
104
+ // The property the chip depends on: launched deeper in the tree, it must walk
105
+ // UP to the same repo — this is exactly what `graft mcp` does from dsh's cwd.
106
+ const fromNested = await findGraftRoot(join(REPO, 'profiles', 'web'), exists)
107
+ check('walks up from a nested dir to the same root', samePath(fromNested, REPO), String(fromNested))
108
+
109
+ const fromOther = await findGraftRoot(OTHER, exists)
110
+ check('finds a second repo, not the first', samePath(fromOther, OTHER), String(fromOther))
111
+ check('and a space in the path does not break it', OTHER.includes(' '))
112
+
113
+ const nowhere = await findGraftRoot(BARE, exists)
114
+ check('an unindexed tree resolves to nothing', nowhere === undefined, String(nowhere))
115
+
116
+ console.log('\n--- status from the cache ---')
117
+ const status = await computeStatus(REPO, io)
118
+ check('reports the cache node count', status.nodeCount === 1284, String(status.nodeCount))
119
+ check('reports the cache edge count', status.edgeCount === 5312, String(status.edgeCount))
120
+ check('carries the root back', samePath(status.root, REPO))
121
+ check('freshness is a known word', ['synced', 'stale', 'syncing', 'unknown'].includes(status.freshness), status.freshness)
122
+ check('came from the live cache', status.source === 'cache', status.source)
123
+
124
+ console.log('\n--- the graph fallback ---')
125
+ // stats.json absent: must fall back to the wiring graph rather than report
126
+ // "no graph", which is the bug graft's own statusline documents.
127
+ const noCache = await computeStatus(REPO, {
128
+ ...io,
129
+ readJson: async (path) => (path.endsWith('stats.json') ? undefined : readJson(path)),
130
+ })
131
+ check('falls back to the wiring graph', noCache.source === 'graph', noCache.source)
132
+ check('counts from the graph, not the cache', noCache.nodeCount === 97, String(noCache.nodeCount))
133
+ check('admits it has no drift signal', noCache.freshness === 'synced')
134
+
135
+ console.log('\n--- a repo with no graft at all ---')
136
+ const bare = await computeStatus(BARE, io)
137
+ check('reports not-ok rather than throwing', bare.ok === false, JSON.stringify(bare))
138
+
139
+ /* ========================================================================== *
140
+ * The visualiser
141
+ * ========================================================================== */
142
+
143
+ console.log('\n--- the workspace comes from the SESSION, not the launch dir ---')
144
+ const { cwdForSession } = await import('../src/index.js')
145
+ // The exact failure reported: dsh launched from a directory with no graft index
146
+ // above it, while the session's workspace had one.
147
+ const HOME = BARE
148
+ const fakeSessions = { get: (id) => (id === 'sess-street' ? { header: { cwd: OTHER } } : undefined) }
149
+ check('a session cwd wins over the launch dir', cwdForSession(fakeSessions, 'sess-street', HOME) === OTHER)
150
+ check('and it is the one that resolves a graft root', (await findGraftRoot(cwdForSession(fakeSessions, 'sess-street', HOME), exists)) !== undefined)
151
+ check('while the launch dir resolves nothing', (await findGraftRoot(HOME, exists)) === undefined)
152
+ check('an unknown session falls back', cwdForSession(fakeSessions, 'nope', HOME) === HOME)
153
+ check('a session with no cwd falls back', cwdForSession({ get: () => ({ header: {} }) }, 'x', HOME) === HOME)
154
+ check('no sessions service at all falls back', cwdForSession(undefined, 'x', HOME) === HOME)
155
+ check('a throwing service falls back', cwdForSession({ get: () => { throw new Error('nope') } }, 'x', HOME) === HOME)
156
+ check('no session id falls back', cwdForSession(fakeSessions, undefined, HOME) === HOME)
157
+
158
+ console.log('\n--- graft viz: port choice and startup ---')
159
+ const { startViz, pickPort } = await import('../src/index.js')
160
+
161
+ const never = { exitCode: null }
162
+ const wait = async () => {}
163
+
164
+ // One `graft viz` serves ONE repo, so a server this plugin did not start is
165
+ // useless to it: from the outside there is no way to tell which repo the thing
166
+ // on that port is serving. An earlier version adopted it anyway, which is
167
+ // invisible and wrong the moment a second repo is open. Free ports instead.
168
+ check('takes the base port when it is free', (await pickPort(4400, { alive: async () => false })) === 4400)
169
+ check(
170
+ 'steps over a port something else holds',
171
+ (await pickPort(4400, { alive: async (p) => p < 4402 })) === 4402,
172
+ )
173
+ check(
174
+ 'gives up rather than looping forever',
175
+ (await pickPort(4400, { alive: async () => true }, 3)) === undefined,
176
+ )
177
+
178
+ // Not yet listening, then listening: must wait for the socket, not the banner.
179
+ let polls = 0
180
+ const started = await startViz('C:/x', 4400, {
181
+ alive: async () => (polls++ > 1),
182
+ wait,
183
+ spawn: () => never,
184
+ })
185
+ check('waits for the port, then reports the url', started.ok === true && started.url === 'http://127.0.0.1:4400/', JSON.stringify(started))
186
+
187
+ // A child that dies must fail immediately rather than burn the whole deadline.
188
+ const died = await startViz('C:/x', 4400, {
189
+ alive: async () => false,
190
+ wait,
191
+ spawn: () => ({ exitCode: 9 }),
192
+ })
193
+ check('a child that exits is reported, not awaited', died.ok === false && /exited with code 9/.test(died.reason), String(died.reason))
194
+
195
+ const cannotSpawn = await startViz('C:/x', 4400, {
196
+ alive: async () => false,
197
+ wait,
198
+ spawn: () => { throw new Error('graft not on PATH') },
199
+ })
200
+ check('an unspawnable graft is a reason, not a throw', cannotSpawn.ok === false && /not on PATH/.test(cannotSpawn.reason))
201
+
202
+ /* ========================================================================== *
203
+ * The new-tab handle (a source-level guard)
204
+ * ========================================================================== */
205
+
206
+ console.log('\n--- window.open must keep its handle ---')
207
+ // Asserted against the SOURCE because this failure is unreachable from Node and
208
+ // invisible at runtime: `window.open` returns null when `noopener` is set — that
209
+ // is the flag's whole purpose — so a caller that needs the handle and passes it
210
+ // anyway opens a tab it can never write to or navigate. That shipped once and
211
+ // showed up as a permanent blank page, with no error anywhere.
212
+ const clientSource = await readFile(new URL('../src/client.js', import.meta.url), 'utf8')
213
+ const opens = clientSource.match(/window\.open\([^)]*\)/g) ?? []
214
+ check('the tab is opened exactly once', opens.length === 1, opens.join(' | '))
215
+ check('and never with noopener', opens.every((call) => !call.includes('noopener')), opens.join(' | '))
216
+ check('and opened blank, to be navigated later', (opens[0] ?? '').includes('"", "_blank"'), opens[0])
217
+
218
+ console.log('\n--- the sidebar tab is a consumer, not a patch ---')
219
+ // dsh-better-sidebar is never edited by this plugin: the tab is contributed
220
+ // through its documented `ctx.betterSidebar` service, which its README states
221
+ // third-party tabs and its own built-in eight share.
222
+ check('registers a tab through the public service', clientSource.includes('betterSidebar.registerTab('))
223
+ // Resolved through the inject-free accessor, then called — `ctx.betterSidebar`
224
+ // would throw without a declared inject, which is exactly what must not happen
225
+ // in a profile that lacks the sidebar.
226
+ check('resolves the service without requiring an inject', clientSource.includes('ctx.get("betterSidebar")'))
227
+ check('opens the tab through it', /\.openTab\(\{ type: TAB_TYPE/.test(clientSource))
228
+ // A HARD dependency would stop this whole plugin — chip included — from
229
+ // activating in a profile without the sidebar. It must stay a soft one.
230
+ check(
231
+ 'declares the dependency softly (ctx.inject, not the module inject list)',
232
+ clientSource.includes('ctx.inject(["betterSidebar"]') && /const inject = \[[^\]]*\]/.exec(clientSource)?.[0].includes('betterSidebar') === false,
233
+ /const inject = \[[^\]]*\]/.exec(clientSource)?.[0],
234
+ )
235
+ check('the browser tab survives only as the fallback', clientSource.includes('no dsh-better-sidebar'))
236
+
237
+ await rm(fixture, { recursive: true, force: true })
238
+
239
+ console.log(failures === 0 ? '\nALL CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`)
240
+ process.exit(failures === 0 ? 0 : 1)
@@ -0,0 +1,53 @@
1
+ import { graftTools, repoFor, runGraft } from '../src/graft-tools.js'
2
+ import { findGraftRoot } from '../src/index.js'
3
+ import { access } from 'node:fs/promises'
4
+
5
+ let failures = 0
6
+ const check = (label, ok, extra = '') => {
7
+ console.log((ok ? 'PASS ' : 'FAIL ') + label + (extra ? ' -> ' + extra : ''))
8
+ if (!ok) failures += 1
9
+ }
10
+ const exists = async (p) => { try { await access(p); return true } catch { return false } }
11
+
12
+ const HOME = 'C:/Users/adith'
13
+ const STREET = 'B:/Research/Local_LLmtest/Street Yeet'
14
+ const deps = { fallbackCwd: HOME, findRoot: (s) => findGraftRoot(s, exists) }
15
+ const tools = graftTools(deps)
16
+ const byName = new Map(tools.map((t) => [t.name, t]))
17
+
18
+ console.log('--- the repo comes from the CALL, not the process ---')
19
+ check('session cwd wins', repoFor({ agent: { session: { header: { cwd: STREET } } } }, HOME) === STREET)
20
+ check('no agent falls back', repoFor(undefined, HOME) === HOME)
21
+ check('agent with no cwd falls back', repoFor({ agent: { session: { header: {} } } }, HOME) === HOME)
22
+
23
+ console.log('\n--- the five tools are registered ---')
24
+ for (const n of ['graft_ask', 'graft_grep', 'graft_callers', 'graft_skeleton', 'graft_map']) {
25
+ check(n + ' exists', byName.has(n))
26
+ }
27
+ check('none declares a boolean parameter (the leak bug)',
28
+ tools.every((t) => Object.values(t.parameters?.properties ?? {}).every((p) => p.type !== 'boolean')))
29
+ check('all declare a timeout so dsh bounds them', tools.every((t) => typeof t.timeoutMs === 'number'))
30
+
31
+ console.log('\n--- real calls against the REAL repos ---')
32
+ // dsh's process cwd is deliberately HOME here: the exact broken situation.
33
+ const street = { agent: { session: { header: { cwd: STREET } } } }
34
+ const map = await byName.get('graft_map').execute({ max_dirs: 3 }, street)
35
+ check('graft_map answers about Street Yeet', /tokens saved/.test(map.text) && !/no graph/i.test(map.text), map.text.split('\n')[0].slice(0, 80))
36
+
37
+ const ask = await byName.get('graft_ask').execute({ query: 'punch animation' }, street)
38
+ check('graft_ask finds code there', /tokens saved/.test(ask.text), ask.text.split('\n')[0].slice(0, 80))
39
+
40
+ const grep = await byName.get('graft_grep').execute({ pattern: 'def ', match: 'literal' }, street)
41
+ check('graft_grep runs with an enum flag', !/error|unknown option/i.test(grep.text), grep.text.split('\n')[0].slice(0, 80))
42
+
43
+ // A different session in the SAME dsh must get a different repo.
44
+ const dsh = { agent: { session: { header: { cwd: 'C:/Users/adith/.dsh' } } } }
45
+ const dshMap = await byName.get('graft_map').execute({ max_dirs: 3 }, dsh)
46
+ check('a second workspace gets ITS own graph', /tokens saved/.test(dshMap.text) && dshMap.text !== map.text)
47
+
48
+ console.log('\n--- a workspace with no index explains itself ---')
49
+ const bare = await byName.get('graft_map').execute({}, { agent: { session: { header: { cwd: 'C:/Windows' } } } })
50
+ check('says how to fix it, does not just fail', /Run `graft build`/.test(bare.text), bare.text.split('\n')[0].slice(0, 90))
51
+
52
+ console.log(failures === 0 ? '\nALL CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`)
53
+ process.exit(failures === 0 ? 0 : 1)
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Keep the graft graph current as the model edits code.
3
+ *
4
+ * WHAT CLAUDE CODE GETS THAT dsh DOES NOT
5
+ *
6
+ * `graft init` installs two hooks there: a post-edit hook that marks the graph
7
+ * dirty, and a Stop hook that rebuilds it at the end of a turn. dsh is not one
8
+ * of the agents `graft init` knows (`--list-agents`: agents, adal, cursor,
9
+ * gemini, grok, hermes, antigravity, copilot, kiro, windsurf, claude), so a dsh
10
+ * user is left running `graft build` by hand and, in practice, forgetting to.
11
+ *
12
+ * This is the same two hooks on dsh's own seams — `tools/execute` for the edit
13
+ * signal, `agent/turn-stopping` for the rebuild — plus a third trigger graft
14
+ * does not need: a debounced rebuild DURING a turn. Claude Code turns are short;
15
+ * one turn here can run half an hour across dozens of edits, and waiting for its
16
+ * end would leave the model querying a graph it had already invalidated.
17
+ *
18
+ * IT REUSES GRAFT'S OWN SCRIPT, NOT A REIMPLEMENTATION
19
+ *
20
+ * The rebuild is graft's `dist/claude/sync-run.js`, spawned exactly as its Stop
21
+ * hook spawns it, and the dirty/lock bookkeeping goes through graft's own
22
+ * `state.js`. That matters for three reasons: `sync-run` already patches
23
+ * `stats.json` on the way out (clearing `dirty`, stamping `syncedAt`, writing
24
+ * the new counts), which is the file the status chip reads — so the chip shows
25
+ * "syncing" then "in sync" for free; the lock is graft's, so a dsh rebuild and
26
+ * a Claude Code rebuild in the same repo cannot run over each other; and its
27
+ * money guard travels with it.
28
+ *
29
+ * THE MONEY GUARD
30
+ *
31
+ * `sync-run.js` carries the comment "MONEY GUARD: plain `graft build` only —
32
+ * structural, $0, offline. Never --deep." Calling that script rather than
33
+ * assembling an argv here is what keeps that promise true: an automatic
34
+ * rebuild can never start billing an LLM, because the code that decides the
35
+ * arguments is graft's, not this plugin's.
36
+ *
37
+ * @module graft-status/auto-sync
38
+ */
39
+
40
+ import { spawn } from 'node:child_process'
41
+ import { existsSync } from 'node:fs'
42
+ import { dirname, join } from 'node:path'
43
+ import { pathToFileURL } from 'node:url'
44
+
45
+ /**
46
+ * Tools whose success means the working tree may have moved.
47
+ *
48
+ * THESE ARE dsh's REAL WIRE NAMES. The list previously read `write_file`,
49
+ * `terminal` and `shell` — none of which dsh registers — while omitting `write`
50
+ * and `str_replace_editor`, which it does. The effect was worst in exactly the
51
+ * case it should have handled best: in a NEW repo every file arrives through
52
+ * `write`, so nothing was ever marked dirty and the graph stayed at whatever
53
+ * `/graft` first built until someone ran `graft build` by hand. `edit` and the
54
+ * shells worked, which is why it looked intermittent rather than broken.
55
+ *
56
+ * Source of truth, from the packages that register them:
57
+ * dsh-tool-fs write · edit (also read, read_image)
58
+ * dsh-tool-str-replace-editor str_replace_editor
59
+ * dsh-tool-bash / -pwsh bash · pwsh
60
+ *
61
+ * The shells are on the list deliberately. graft's own post-edit hook watches
62
+ * file-editing tools, but in this deployment most files arrive from a script
63
+ * the model runs — a Blender export, a Godot build — and an edit-tools-only
64
+ * trigger would miss exactly the changes that matter most. The cost of being
65
+ * broad is bounded by the design: the flag only marks the graph dirty, and the
66
+ * debounce collapses a burst of writes into a single rebuild.
67
+ *
68
+ * Override with the row's `autoBuildTools` if a deployment registers others.
69
+ */
70
+ const DEFAULT_WRITE_TOOLS = ['write', 'edit', 'str_replace_editor', 'pwsh', 'bash']
71
+
72
+ /** Graft's own state helpers, loaded from wherever graft is installed. */
73
+ async function loadGraftState(cliPath) {
74
+ const claudeDir = join(dirname(cliPath), 'claude')
75
+ const statePath = join(claudeDir, 'state.js')
76
+ const syncRun = join(claudeDir, 'sync-run.js')
77
+ if (!existsSync(statePath) || !existsSync(syncRun)) return undefined
78
+ try {
79
+ const state = await import(pathToFileURL(statePath).href)
80
+ if (typeof state.readStats !== 'function' || typeof state.patchStats !== 'function') return undefined
81
+ return { state, syncRun }
82
+ } catch {
83
+ // A graft too old or too new to expose these. Auto-sync simply does not
84
+ // run; every other part of this plugin is unaffected.
85
+ return undefined
86
+ }
87
+ }
88
+
89
+ /**
90
+ * @param deps.cliPath - graft's `dist/cli.js`, from which its `claude/` dir is found.
91
+ * @param deps.minIntervalMs - floor between rebuilds of the same repo.
92
+ */
93
+ export function createAutoSync(deps = {}) {
94
+ const writeTools = new Set(Array.isArray(deps.writeTools) ? deps.writeTools : DEFAULT_WRITE_TOOLS)
95
+ /**
96
+ * Floor between rebuild ATTEMPTS on one repo.
97
+ *
98
+ * Was 30s, which was wrong: a structural build of a normal repo takes about a
99
+ * second, so a 30s floor meant that in a quick back-and-forth most turns were
100
+ * refused with `too-soon` and the graph simply stayed stale — the opposite of
101
+ * the point. Overlapping builds are already prevented by graft's own lock,
102
+ * which is what that lock is for; this is only a brake on a spawn storm if
103
+ * the lock is ever unavailable.
104
+ */
105
+ const minIntervalMs = Number.isInteger(deps.minIntervalMs) ? deps.minIntervalMs : 5_000
106
+ const spawnFn = deps.spawn ?? spawn
107
+ const now = deps.now ?? (() => Date.now())
108
+
109
+ /**
110
+ * Quiet period after the last write before rebuilding mid-turn.
111
+ *
112
+ * The turn boundary alone is not enough. One agentic turn here routinely runs
113
+ * for tens of minutes across dozens of edits — a graph marked dirty in its
114
+ * first minute then stayed stale for the next thirty-five, and every
115
+ * `graft_ask` the model made in between answered from it. Waiting for a lull
116
+ * instead of for the turn means a burst of edits still costs ONE rebuild,
117
+ * while a long turn no longer reasons about its own stale graph.
118
+ *
119
+ * Set to 0 to go back to turn-end-only.
120
+ */
121
+ const debounceMs = Number.isInteger(deps.debounceMs) ? deps.debounceMs : 20_000
122
+
123
+ /** Roots edited since their last rebuild. */
124
+ const pending = new Set()
125
+ /** root -> when a rebuild was last STARTED, for the interval floor. */
126
+ const lastRun = new Map()
127
+ /** root -> pending debounce timer. */
128
+ const timers = new Map()
129
+ const setTimer = deps.setTimeout ?? setTimeout
130
+ const clearTimer = deps.clearTimeout ?? clearTimeout
131
+
132
+ let loaded
133
+ const graft = async () => {
134
+ if (loaded === undefined) {
135
+ loaded = deps.graft !== undefined ? deps.graft : await loadGraftState(deps.cliPath)
136
+ if (loaded === undefined) loaded = null
137
+ }
138
+ return loaded
139
+ }
140
+
141
+ const api = {
142
+ /** Whether a finished tool call means the tree may have changed. */
143
+ touches: (toolName) => writeTools.has(toolName),
144
+
145
+ /**
146
+ * Record that a repo's code moved.
147
+ *
148
+ * Written through graft's `patchStats` so the flag lands in the same field,
149
+ * in the same file, that graft's own hooks and `graft check` use — a chip
150
+ * reading `dirty` cannot then disagree with the CLI about the same repo.
151
+ */
152
+ markDirty: async (root) => {
153
+ if (typeof root !== 'string' || root === '') return
154
+ pending.add(root)
155
+
156
+ // Restarted on every write, so a run of edits rebuilds once at the end of
157
+ // the burst rather than once per file.
158
+ if (debounceMs > 0) {
159
+ clearTimer(timers.get(root))
160
+ timers.set(
161
+ root,
162
+ setTimer(() => {
163
+ timers.delete(root)
164
+ void api.syncIfDirty(root)
165
+ }, debounceMs),
166
+ )
167
+ }
168
+
169
+ const g = await graft()
170
+ if (g === null) return
171
+ try {
172
+ g.state.patchStats(root, { dirty: true })
173
+ } catch {
174
+ // The in-memory `pending` set still carries the turn-end trigger, so a
175
+ // failed write costs the chip's live dirty flag, not the rebuild.
176
+ }
177
+ },
178
+
179
+ /**
180
+ * Rebuild one repo if it needs it. Called at the turn boundary, and from
181
+ * the debounce timer once writing has gone quiet mid-turn.
182
+ *
183
+ * @returns why it did or did not run, for the harness and the log.
184
+ */
185
+ syncIfDirty: async (root) => {
186
+ if (typeof root !== 'string' || root === '') return 'no-root'
187
+ const g = await graft()
188
+ if (g === null) return 'graft-unavailable'
189
+
190
+ const since = now() - (lastRun.get(root) ?? -Infinity)
191
+ if (since < minIntervalMs) return 'too-soon'
192
+
193
+ // graft's own gate: its `dirty` flag is the shared source of truth, so an
194
+ // edit made in another client counts, and a repo nothing touched is
195
+ // never rebuilt just because a turn ended here.
196
+ let stats
197
+ try {
198
+ stats = g.state.readStats(root)
199
+ } catch {
200
+ return 'unreadable'
201
+ }
202
+ if (stats?.dirty !== true && !pending.has(root)) return 'clean'
203
+
204
+ // The lock is graft's, so a rebuild already running — started here, or by
205
+ // Claude Code in the same repo — is left alone rather than raced.
206
+ try {
207
+ if (typeof g.state.acquireLock === 'function' && !g.state.acquireLock(root)) return 'locked'
208
+ g.state.patchStats(root, { syncing: true })
209
+ } catch {
210
+ return 'unreadable'
211
+ }
212
+
213
+ pending.delete(root)
214
+ lastRun.set(root, now())
215
+ try {
216
+ /*
217
+ * NOT detached, and that is load-bearing on Windows.
218
+ *
219
+ * `sync-run.js` does its actual work through
220
+ *
221
+ * execFileSync(process.execPath, [cli, 'build', '.'], { stdio: 'ignore' })
222
+ *
223
+ * with no `windowsHide`. A console process started without
224
+ * CREATE_NO_WINDOW inherits its parent's console — but DETACHED_PROCESS
225
+ * (which is what `detached: true` means here) leaves this child with no
226
+ * console at all, so there is nothing to inherit and Windows gives the
227
+ * grandchild a brand new, VISIBLE one.
228
+ *
229
+ * That is the terminal that flashes on every rebuild, and a new console
230
+ * window takes foreground: it minimises a fullscreen game. Measured,
231
+ * `detached: true` leaves the child with no console while
232
+ * `windowsHide: true` alone gives it its own CREATE_NO_WINDOW console —
233
+ * invisible, and inherited by the grandchild, which is exactly what is
234
+ * wanted.
235
+ *
236
+ * The cost is that a rebuild no longer survives dsh exiting mid-build.
237
+ * That is cheap: the repo simply stays marked dirty and the next write
238
+ * triggers it again. `unref()` still keeps it from holding dsh open,
239
+ * and `sync-run` clears `syncing` and releases the lock on its own way
240
+ * out either way.
241
+ */
242
+ const child = spawnFn(process.execPath, [g.syncRun, root], {
243
+ stdio: 'ignore',
244
+ windowsHide: true,
245
+ })
246
+ child.on?.('error', () => {})
247
+ child.unref?.()
248
+ } catch {
249
+ return 'spawn-failed'
250
+ }
251
+ return 'started'
252
+ },
253
+
254
+ /** Drop any pending debounce timers, so a disposed plugin fires nothing. */
255
+ dispose: () => {
256
+ for (const timer of timers.values()) clearTimer(timer)
257
+ timers.clear()
258
+ },
259
+ }
260
+
261
+ return api
262
+ }