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.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/cordis.patch.yml +24 -0
- package/package.json +79 -0
- package/screenshots.json +5 -0
- package/scripts/postinstall.mjs +109 -0
- package/scripts/test-autosync.mjs +117 -0
- package/scripts/test-command.mjs +104 -0
- package/scripts/test-harness.mjs +240 -0
- package/scripts/test-tools.mjs +53 -0
- package/src/auto-sync.js +262 -0
- package/src/client.js +843 -0
- package/src/command.js +147 -0
- package/src/graft-tools.js +346 -0
- package/src/index.js +701 -0
|
@@ -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)
|
package/src/auto-sync.js
ADDED
|
@@ -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
|
+
}
|