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.
- package/LICENSE +21 -21
- package/README.md +46 -6
- package/README.zh-CN.md +218 -0
- package/assets/icon.png +0 -0
- package/package.json +16 -9
- package/scripts/test-autosync.mjs +66 -1
- package/scripts/test-guidance.mjs +160 -0
- package/scripts/test-harness.mjs +184 -2
- package/src/auto-sync.js +34 -2
- package/src/client.js +436 -67
- package/src/graft-tools.js +11 -0
- package/src/index.js +190 -36
|
@@ -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)
|
package/scripts/test-harness.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 {
|