dsh-graft-plugin 0.2.1 → 0.3.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.
@@ -81,6 +81,14 @@ export function resolveGraftCommand(env = process.env, exists = existsSync, exec
81
81
  const cli = join(dir, 'node_modules', '@nanonets', 'graft', 'dist', 'cli.js')
82
82
  if (exists(cli)) return { command: execPath, prefix: [cli], shell: false }
83
83
  }
84
+ // A bun global install puts only the shim on PATH; the package lives under
85
+ // $BUN_INSTALL/install/global. Missing it left prefix empty, which auto-sync
86
+ // used to turn into a fatal dirname(undefined).
87
+ const bunRoot = env.BUN_INSTALL ?? join(env.HOME ?? env.USERPROFILE ?? '.', '.bun')
88
+ if (typeof bunRoot === 'string' && bunRoot !== '') {
89
+ const bunCli = join(bunRoot, 'install', 'global', 'node_modules', '@nanonets', 'graft', 'dist', 'cli.js')
90
+ if (exists(bunCli)) return { command: execPath, prefix: [bunCli], shell: false }
91
+ }
84
92
  return { command: 'graft', prefix: [], shell: process.platform === 'win32' }
85
93
  }
86
94
 
@@ -189,6 +197,9 @@ function graftTool(spec, deps) {
189
197
  'Run `graft build` in it once — it needs no API key — and this tool will work from then on.',
190
198
  }
191
199
  }
200
+ // Announced BEFORE the run, so the chip's dot flickers while graft is
201
+ // actually working, not only after it has answered.
202
+ deps.noteUse?.(found)
192
203
  const result = await runGraft([...spec.argv(args), found], found, deps)
193
204
  const saved = savedTokensIn(result.text)
194
205
  if (saved > 0) deps.recordSavings?.(found, saved)
package/src/index.js CHANGED
@@ -44,11 +44,11 @@
44
44
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
45
45
  import { spawn } from 'node:child_process'
46
46
  import { access, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'
47
- import { dirname, join } from 'node:path'
47
+ import { dirname, join, resolve } from 'node:path'
48
48
 
49
49
  import { createAutoSync } from './auto-sync.js'
50
50
  import { createGraftCommand } from './command.js'
51
- import { graftTools, resolveGraftCommand } from './graft-tools.js'
51
+ import { graftTools, resolveGraftCommand, runGraft } from './graft-tools.js'
52
52
 
53
53
  export const name = 'graft-status'
54
54
 
@@ -70,6 +70,31 @@ export const inject = ['tools']
70
70
  */
71
71
  const CACHE_MS = 5000
72
72
 
73
+ const readUtf8 = async (path) => {
74
+ try {
75
+ return await readFile(path, 'utf8')
76
+ } catch {
77
+ return undefined // missing, or a directory (a main checkout's .git)
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The main checkout's root when `dir` is a linked git worktree, else undefined.
83
+ *
84
+ * Pure reads, no `git` child: in a worktree `.git` is a FILE pointing at the
85
+ * worktree's admin dir, whose `commondir` names the shared `.git`.
86
+ */
87
+ async function linkedWorktreeMain(dir, readText = readUtf8) {
88
+ const dotGit = await readText(join(dir, '.git'))
89
+ if (typeof dotGit !== 'string') return undefined
90
+ const match = /^\s*gitdir:\s*(.+?)\s*$/m.exec(dotGit)
91
+ if (match === null) return undefined
92
+ const gitdir = resolve(dir, match[1])
93
+ const common = await readText(join(gitdir, 'commondir'))
94
+ if (typeof common !== 'string' || common.trim() === '') return undefined
95
+ return dirname(resolve(gitdir, common.trim()))
96
+ }
97
+
73
98
  /**
74
99
  * The repository root graft would serve from `start`.
75
100
  *
@@ -78,12 +103,18 @@ const CACHE_MS = 5000
78
103
  * directory alone, because an empty `graft/` folder left behind by a removed
79
104
  * index would otherwise shadow a real one higher up.
80
105
  */
81
- export async function findGraftRoot(start, exists) {
106
+ export async function findGraftRoot(start, exists, readText = readUtf8) {
82
107
  let dir = start
83
108
  for (;;) {
84
109
  for (const probe of ['.cache/stats.json', '.graph/wiring.json', 'INDEX.md']) {
85
110
  if (await exists(join(dir, 'graft', probe))) return dir
86
111
  }
112
+ if (await exists(join(dir, '.git'))) {
113
+ // Repo boundary. A linked worktree owns its graph: answer only from it,
114
+ // never from the main checkout above — graft seeds a worktree's own
115
+ // first graph from there, and main-branch paths are wrong for this one.
116
+ if ((await linkedWorktreeMain(dir, readText)) !== undefined) return undefined
117
+ }
87
118
  const parent = dirname(dir)
88
119
  // `dirname` of a filesystem root returns the root itself; that fixed point
89
120
  // is the only reliable stop signal across platforms, and it is why this
@@ -214,14 +245,14 @@ const GRAFT_GUIDANCE = [
214
245
  'This workspace has a graft index: a prebuilt graph of every symbol, its',
215
246
  'file:line span, and who calls what.',
216
247
  '',
217
- 'Prefer the graft_* tools over reading or grepping files:',
248
+ 'Must use the graft_* tools over reading or grepping files:',
218
249
  '- graft_ask to find code or understand how something works — one call usually',
219
250
  ' replaces several file reads.',
220
251
  '- graft_callers before renaming or changing a symbol, to see what depends on it.',
221
252
  '- graft_skeleton instead of reading a whole file when signatures are enough.',
222
253
  '- graft_map to orient in an unfamiliar part of the tree.',
223
254
  '',
224
- 'Fall back to read/grep when graft returns nothing useful, and say so rather',
255
+ 'Fall back to read/grep (or ast-grep, if it is installed) when graft returns nothing useful, and say so rather',
225
256
  'than searching twice in silence. The graph rebuilds itself after edits, so it',
226
257
  'is normally current; `/graft` rebuilds it on demand.',
227
258
  ].join('\n')
@@ -257,6 +288,30 @@ export function totalSaved(graftOwn, ours) {
257
288
  return Math.round(a + b)
258
289
  }
259
290
 
291
+ /**
292
+ * Per-root tally of graft tool calls, for the chip's violet flicker.
293
+ *
294
+ * Every graft_* execute() notes its root here, and the next status poll for
295
+ * that repo carries the count; the client flickers the dot when it moves. The
296
+ * tally lives OUTSIDE the status cache on purpose: a pulse that waited out
297
+ * `cacheMs` would blink long after the tool call it describes had finished.
298
+ */
299
+ export function createToolPulse() {
300
+ const counts = new Map()
301
+ return {
302
+ /** The model invoked a graft tool whose graph lives at `root`. */
303
+ note(root) {
304
+ if (typeof root !== 'string' || root === '') return
305
+ const hit = counts.get(root)
306
+ counts.set(root, { count: (hit?.count ?? 0) + 1, at: Date.now() })
307
+ },
308
+ /** undefined until the first use: no pulse, no flicker, no field. */
309
+ get(root) {
310
+ return counts.get(root)
311
+ },
312
+ }
313
+ }
314
+
260
315
  /**
261
316
  * The whole status for one repo root.
262
317
  *
@@ -267,20 +322,22 @@ export function totalSaved(graftOwn, ours) {
267
322
  * disagree about the same repo.
268
323
  */
269
324
  export async function computeStatus(root, io) {
325
+ const worktreeOf = await linkedWorktreeMain(root, io.readText ?? readUtf8)
270
326
  const stats = await io.readJson(join(root, 'graft', '.cache', 'stats.json'))
271
327
  const base = stats !== undefined && typeof stats.nodeCount === 'number' && stats.nodeCount > 0
272
328
  ? { nodeCount: stats.nodeCount, edgeCount: stats.edgeCount ?? 0, freshness: freshnessOf(stats), syncedAt: stats.syncedAt ?? null }
273
329
  : undefined
274
330
 
275
331
  if (base !== undefined) {
276
- return { ...base, root, savedTokens: totalSaved(await io.readSavedTokens(root), io.ourSavings?.(root)), source: 'cache' }
332
+ return { ...base, root, worktreeOf, savedTokens: totalSaved(await io.readSavedTokens(root), io.ourSavings?.(root)), source: 'cache' }
277
333
  }
278
334
 
279
335
  const counted = countWiring(await io.readJson(join(root, 'graft', '.graph', 'wiring.json')))
280
- if (counted === undefined) return { root, ok: false, reason: 'no graph' }
336
+ if (counted === undefined) return { root, ok: false, reason: 'no graph', worktreeOf }
281
337
  return {
282
338
  ...counted,
283
339
  root,
340
+ worktreeOf,
284
341
  // The graph carries no drift signal of its own, so this reads as synced
285
342
  // until something repopulates the cache — the same concession graft's
286
343
  // statusline documents for this exact fallback.
@@ -298,6 +355,15 @@ export async function computeStatus(root, io) {
298
355
  /** graft viz's own default. Kept so a URL a user already has still works. */
299
356
  const VIZ_PORT = 4400
300
357
 
358
+ /**
359
+ * How long an initialise from the popup may take before it gives up, in ms.
360
+ *
361
+ * The same bound as the bare /graft: a small repo finishes in about a
362
+ * second, a large one can take minutes, and a wedged graft must not hold
363
+ * the button busy forever.
364
+ */
365
+ const BUILD_TIMEOUT_MS = 180_000
366
+
301
367
  /** Is something already answering on this port? */
302
368
  async function vizAlive(port) {
303
369
  try {
@@ -382,6 +448,12 @@ class GraftStatusRemote extends TypertRemoteService {
382
448
  if (liveOps === null) throw new Error('graft-status is not ready')
383
449
  return liveOps.viz(sessionId)
384
450
  }
451
+
452
+ /** Rebuild that session's repo, or initialise it when there is none. */
453
+ build(sessionId) {
454
+ if (liveOps === null) throw new Error('graft-status is not ready')
455
+ return liveOps.build(sessionId)
456
+ }
385
457
  }
386
458
 
387
459
  /** Emulate the @Remote decorator without decorator syntax. */
@@ -407,6 +479,7 @@ function markRemoteMethod(prototype, method) {
407
479
 
408
480
  markRemoteMethod(GraftStatusRemote.prototype, 'status')
409
481
  markRemoteMethod(GraftStatusRemote.prototype, 'viz')
482
+ markRemoteMethod(GraftStatusRemote.prototype, 'build')
410
483
 
411
484
  export function apply(ctx, config = {}) {
412
485
  const fallbackFrom = typeof config.cwd === 'string' && config.cwd.length > 0 ? config.cwd : process.cwd()
@@ -423,6 +496,9 @@ export function apply(ctx, config = {}) {
423
496
  let savingsLoaded = false
424
497
  let savingsDirty = false
425
498
 
499
+ /** Per-root graft tool-use pulses; the chip's dot blinks violet when one moves. */
500
+ const pulses = createToolPulse()
501
+
426
502
  const loadSavings = async () => {
427
503
  if (savingsLoaded) return
428
504
  savingsLoaded = true
@@ -452,6 +528,7 @@ export function apply(ctx, config = {}) {
452
528
  const io = {
453
529
  readJson,
454
530
  readSavedTokens,
531
+ readText: readUtf8,
455
532
  ourSavings: (root) => savings.get(root) ?? 0,
456
533
  exists: async (path) => {
457
534
  try {
@@ -479,6 +556,17 @@ export function apply(ctx, config = {}) {
479
556
  /** root -> in-flight start, so a double click cannot race two spawns. */
480
557
  const vizPending = new Map()
481
558
 
559
+ /**
560
+ * The rebuild engine, shared by the automatic hooks below and the popup's
561
+ * Build button. Hoisted out of the `autoBuild` block on purpose: the button
562
+ * must rebuild even in a profile that turned the automatic hooks off.
563
+ */
564
+ const autoSync = createAutoSync({
565
+ cliPath: resolveGraftCommand().prefix[0],
566
+ minIntervalMs: config.autoBuildMinIntervalMs,
567
+ writeTools: config.autoBuildTools,
568
+ })
569
+
482
570
  const vizDeps = {
483
571
  alive: vizAlive,
484
572
  wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
@@ -510,7 +598,7 @@ export function apply(ctx, config = {}) {
510
598
  const from = cwdFor(sessionId)
511
599
  let root
512
600
  try {
513
- root = await findGraftRoot(from, io.exists)
601
+ root = await findGraftRoot(from, io.exists, io.readText)
514
602
  } catch (error) {
515
603
  return { ok: false, reason: String(error?.message ?? error), from }
516
604
  }
@@ -518,7 +606,9 @@ export function apply(ctx, config = {}) {
518
606
 
519
607
  const now = Date.now()
520
608
  const hit = cache.get(root)
521
- if (hit !== undefined && now - hit.at < cacheMs) return hit.value
609
+ // The pulse rides ALONGSIDE the cached body, outside it: a blink that
610
+ // waited out cacheMs would land after the tool call had already ended.
611
+ if (hit !== undefined && now - hit.at < cacheMs) return { ...hit.value, toolUse: pulses.get(root) }
522
612
 
523
613
  let value
524
614
  try {
@@ -528,12 +618,12 @@ export function apply(ctx, config = {}) {
528
618
  value = { ok: false, reason: String(error?.message ?? error), from }
529
619
  }
530
620
  cache.set(root, { value, at: now })
531
- return value
621
+ return { ...value, toolUse: pulses.get(root) }
532
622
  },
533
623
 
534
624
  viz: async (sessionId) => {
535
625
  const from = cwdFor(sessionId)
536
- const root = await findGraftRoot(from, io.exists)
626
+ const root = await findGraftRoot(from, io.exists, io.readText)
537
627
  if (root === undefined) return { ok: false, reason: 'no graft index above ' + from, from }
538
628
 
539
629
  // Already serving this repo: reuse it. Checked before the pending map so
@@ -567,6 +657,44 @@ export function apply(ctx, config = {}) {
567
657
  vizPending.delete(root)
568
658
  }
569
659
  },
660
+
661
+ build: async (sessionId) => {
662
+ const from = cwdFor(sessionId)
663
+ let root
664
+ try {
665
+ root = await findGraftRoot(from, io.exists, io.readText)
666
+ } catch (error) {
667
+ return { ok: false, reason: String(error?.message ?? error), from }
668
+ }
669
+ // No index above the workspace: initialise the workspace itself, plain
670
+ // offline `graft build` — the same argv the bare /graft runs, $0, no
671
+ // API key. sync-run cannot do this job (no index yet to sync), so this
672
+ // is the one place a bare build is correct.
673
+ if (root === undefined) {
674
+ const result = await runGraft(['build', from], from, { timeoutMs: BUILD_TIMEOUT_MS })
675
+ const built = await findGraftRoot(from, io.exists, io.readText)
676
+ if (built === undefined) {
677
+ return { ok: false, reason: `graft build produced no index in ${from}.\n\n${result.text.slice(0, 1200)}`, root: from }
678
+ }
679
+ cache.delete(from)
680
+ return { ok: true, root: from, initialising: true }
681
+ }
682
+ // The SAME rebuild the automatic hooks run — graft's own sync-run.js,
683
+ // which patches stats.json on the way out (clearing `dirty`, stamping
684
+ // `syncedAt`, writing the new counts). A bare `graft build` was tried
685
+ // here first and shipped as a button that changed nothing: it writes the
686
+ // graph but NOT stats.json, so the pill kept reading the old `dirty`
687
+ // flag and never flipped to IN SYNC.
688
+ const outcome = await autoSync.rebuild(root)
689
+ if (outcome !== 'started') {
690
+ return { ok: false, reason: `graft rebuild did not start (${outcome})`, root }
691
+ }
692
+ // Dropped so the next status poll reads the rebuilding graph, not the
693
+ // pre-build counts for the rest of the cache window. sync-run sets
694
+ // `syncing` itself, so the pill shows syncing → in sync for free.
695
+ cache.delete(root)
696
+ return { ok: true, root }
697
+ },
570
698
  }
571
699
 
572
700
  new GraftStatusRemote(ctx)
@@ -577,7 +705,8 @@ export function apply(ctx, config = {}) {
577
705
  if (config.tools !== false) {
578
706
  const toolDeps = {
579
707
  fallbackCwd: fallbackFrom,
580
- findRoot: (start) => findGraftRoot(start, io.exists),
708
+ findRoot: (start) => findGraftRoot(start, io.exists, io.readText),
709
+ noteUse: (root) => pulses.note(root),
581
710
  recordSavings: (root, saved) => {
582
711
  savings.set(root, (savings.get(root) ?? 0) + saved)
583
712
  savingsDirty = true
@@ -603,7 +732,7 @@ export function apply(ctx, config = {}) {
603
732
  scope.commands.register(
604
733
  createGraftCommand({
605
734
  fallbackCwd: fallbackFrom,
606
- findRoot: (start) => findGraftRoot(start, io.exists),
735
+ findRoot: (start) => findGraftRoot(start, io.exists, io.readText),
607
736
  readJson,
608
737
  countWiring,
609
738
  }),
@@ -616,37 +745,59 @@ export function apply(ctx, config = {}) {
616
745
  // The standing instruction, registered on each agent's OWN context so no
617
746
  // preset can shadow it (see GRAFT_GUIDANCE for why the persona could not).
618
747
  if (config.promptSection !== false) {
619
- /** Agent contexts already carrying the section; a duplicate name throws. */
620
- const briefed = new WeakSet()
748
+ /** Agent contexts already carrying the section -> the registering fiber. */
749
+ const briefed = new WeakMap()
621
750
 
622
- ctx.on('agent/session-start', (payload) => {
623
- const agentCtx = payload?.agent?.ctx
751
+ const installGuidance = (agent) => {
752
+ const agentCtx = agent?.ctx
624
753
  if (agentCtx === undefined || agentCtx === null || briefed.has(agentCtx)) return
625
- briefed.add(agentCtx)
626
754
  try {
627
- // `get` rather than `agentCtx.systemPrompt`: the property accessor
628
- // throws without a declared inject, and this plugin declares none for
629
- // it on purpose — a deployment without the service should lose the
630
- // sentence, not the chip and the tools.
631
- const prompt = agentCtx.get?.('systemPrompt')
632
- if (typeof prompt?.section !== 'function') return
633
- agentCtx.effect?.(
634
- () => prompt.section({ name: 'graft-status:usage', order: GUIDANCE_ORDER, text: GRAFT_GUIDANCE }),
635
- 'graft-status: prompt guidance',
636
- )
755
+ // dsh 0.1.7 removed the old session-start event; `agent/created` is
756
+ // the seam its own plugins use. `inject` rather than `agentCtx.get`:
757
+ // an agent scope without the prompt registry never calls this back —
758
+ // it loses the sentence, not the session.
759
+ const fiber = agentCtx.inject?.(['systemPrompt'], (scope) => {
760
+ try {
761
+ scope.systemPrompt?.section?.({
762
+ name: 'graft-status:usage',
763
+ order: GUIDANCE_ORDER,
764
+ text: GRAFT_GUIDANCE,
765
+ })
766
+ } catch {
767
+ // Already registered (e.g. after a plugin reload).
768
+ }
769
+ })
770
+ if (fiber !== undefined) briefed.set(agentCtx, fiber)
637
771
  } catch {
638
772
  // A section that fails to register costs a nudge, never the session.
639
773
  }
774
+ }
775
+
776
+ const uninstallGuidance = (agent) => {
777
+ const agentCtx = agent?.ctx
778
+ const fiber = agentCtx === undefined || agentCtx === null ? undefined : briefed.get(agentCtx)
779
+ if (fiber === undefined) return
780
+ briefed.delete(agentCtx)
781
+ try {
782
+ void fiber.dispose?.()
783
+ } catch {
784
+ // Teardown must not throw.
785
+ }
786
+ }
787
+
788
+ // Agents that already exist when this row mounts (plugin reload).
789
+ ctx.inject?.(['agents'], (scope) => {
790
+ for (const agent of scope.agents?.list?.() ?? []) installGuidance(agent)
640
791
  })
792
+ ctx.on('agent/created', ({ agent }) => installGuidance(agent))
793
+ ctx.on('agent/disposed', ({ agent }) => uninstallGuidance(agent))
641
794
  }
642
795
 
643
796
  // Auto-rebuild: graft's two Claude Code hooks, on dsh's own seams.
797
+ // The autoSync instance itself is shared (see above): this block only wires
798
+ // the AUTOMATIC triggers, so `autoBuild: false` silences the hooks while
799
+ // the popup's manual Build keeps working.
644
800
  if (config.autoBuild !== false) {
645
- const autoSync = createAutoSync({
646
- cliPath: resolveGraftCommand().prefix[0],
647
- minIntervalMs: config.autoBuildMinIntervalMs,
648
- writeTools: config.autoBuildTools,
649
- })
650
801
 
651
802
  ctx.on('tools/execute', async (exec, next) => {
652
803
  const result = await next()
@@ -657,8 +808,11 @@ export function apply(ctx, config = {}) {
657
808
  // Going via the sessions registry would be a lookup to reach a value
658
809
  // already in hand, and would differ if the two ever disagreed.
659
810
  const from = exec?.agent?.session?.header?.cwd ?? fallbackFrom
660
- const root = await findGraftRoot(from, io.exists)
661
- if (root !== undefined) void autoSync.markDirty(root)
811
+ const root = await findGraftRoot(from, io.exists, io.readText)
812
+ if (root !== undefined) {
813
+ // A rejected promise here would be unhandled — fatal to dsh.
814
+ void autoSync.markDirty(root).catch(() => {})
815
+ }
662
816
  }
663
817
  return result
664
818
  })
@@ -667,7 +821,7 @@ export function apply(ctx, config = {}) {
667
821
 
668
822
  ctx.on('agent/turn-stopping', async (payload) => {
669
823
  const cwd = payload?.agent?.session?.header?.cwd ?? fallbackFrom
670
- const root = await findGraftRoot(cwd, io.exists)
824
+ const root = await findGraftRoot(cwd, io.exists, io.readText)
671
825
  if (root === undefined) return
672
826
  const outcome = await autoSync.syncIfDirty(root)
673
827
  if (outcome === 'started') {