@nanonets/graft 0.21.1 → 0.22.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.
Files changed (348) hide show
  1. package/README.md +549 -315
  2. package/dist/app/review-process.d.ts.map +1 -1
  3. package/dist/app/review-process.js +6 -5
  4. package/dist/app/review-process.js.map +1 -1
  5. package/dist/app/sources.js +2 -2
  6. package/dist/app/sources.js.map +1 -1
  7. package/dist/ask/ask.d.ts +12 -6
  8. package/dist/ask/ask.d.ts.map +1 -1
  9. package/dist/ask/ask.js +28 -38
  10. package/dist/ask/ask.js.map +1 -1
  11. package/dist/bin/graft.d.ts +3 -0
  12. package/dist/bin/graft.d.ts.map +1 -0
  13. package/dist/bin/graft.js +8 -0
  14. package/dist/bin/graft.js.map +1 -0
  15. package/dist/bin/trail.d.ts +3 -0
  16. package/dist/bin/trail.d.ts.map +1 -0
  17. package/dist/bin/trail.js +6 -0
  18. package/dist/bin/trail.js.map +1 -0
  19. package/dist/blast/blast-cli.d.ts.map +1 -1
  20. package/dist/blast/blast-cli.js +2 -1
  21. package/dist/blast/blast-cli.js.map +1 -1
  22. package/dist/blast/render.d.ts.map +1 -1
  23. package/dist/blast/render.js +3 -2
  24. package/dist/blast/render.js.map +1 -1
  25. package/dist/brand.d.ts +97 -0
  26. package/dist/brand.d.ts.map +1 -0
  27. package/dist/brand.js +254 -0
  28. package/dist/brand.js.map +1 -0
  29. package/dist/check/local-check.d.ts +104 -0
  30. package/dist/check/local-check.d.ts.map +1 -0
  31. package/dist/check/local-check.js +830 -0
  32. package/dist/check/local-check.js.map +1 -0
  33. package/dist/check/types.d.ts +31 -0
  34. package/dist/check/types.d.ts.map +1 -0
  35. package/dist/check/types.js +2 -0
  36. package/dist/check/types.js.map +1 -0
  37. package/dist/claude/format.d.ts +44 -1
  38. package/dist/claude/format.d.ts.map +1 -1
  39. package/dist/claude/format.js +113 -19
  40. package/dist/claude/format.js.map +1 -1
  41. package/dist/claude/hook-layers.d.ts +8 -0
  42. package/dist/claude/hook-layers.d.ts.map +1 -0
  43. package/dist/claude/hook-layers.js +72 -0
  44. package/dist/claude/hook-layers.js.map +1 -0
  45. package/dist/claude/hooks.d.ts +34 -0
  46. package/dist/claude/hooks.d.ts.map +1 -1
  47. package/dist/claude/hooks.js +220 -12
  48. package/dist/claude/hooks.js.map +1 -1
  49. package/dist/claude/init.d.ts +12 -1
  50. package/dist/claude/init.d.ts.map +1 -1
  51. package/dist/claude/init.js +48 -19
  52. package/dist/claude/init.js.map +1 -1
  53. package/dist/claude/session-metrics.d.ts +1 -1
  54. package/dist/claude/session-metrics.d.ts.map +1 -1
  55. package/dist/claude/session-metrics.js +11 -8
  56. package/dist/claude/session-metrics.js.map +1 -1
  57. package/dist/claude/settings-merge.d.ts +1 -0
  58. package/dist/claude/settings-merge.d.ts.map +1 -1
  59. package/dist/claude/settings-merge.js +21 -18
  60. package/dist/claude/settings-merge.js.map +1 -1
  61. package/dist/claude/shim-template.d.ts +17 -2
  62. package/dist/claude/shim-template.d.ts.map +1 -1
  63. package/dist/claude/shim-template.js +116 -22
  64. package/dist/claude/shim-template.js.map +1 -1
  65. package/dist/claude/skill-template.d.ts +10 -1
  66. package/dist/claude/skill-template.d.ts.map +1 -1
  67. package/dist/claude/skill-template.js +132 -1
  68. package/dist/claude/skill-template.js.map +1 -1
  69. package/dist/claude/state.d.ts +8 -0
  70. package/dist/claude/state.d.ts.map +1 -1
  71. package/dist/claude/state.js.map +1 -1
  72. package/dist/claude/statusline.d.ts.map +1 -1
  73. package/dist/claude/statusline.js +4 -1
  74. package/dist/claude/statusline.js.map +1 -1
  75. package/dist/claude/tally.js +1 -1
  76. package/dist/claude/tally.js.map +1 -1
  77. package/dist/cli-epilogue.d.ts +0 -6
  78. package/dist/cli-epilogue.d.ts.map +1 -1
  79. package/dist/cli-epilogue.js +5 -4
  80. package/dist/cli-epilogue.js.map +1 -1
  81. package/dist/cli-meta.d.ts +8 -0
  82. package/dist/cli-meta.d.ts.map +1 -1
  83. package/dist/cli-meta.js +28 -8
  84. package/dist/cli-meta.js.map +1 -1
  85. package/dist/cli-picker.d.ts.map +1 -1
  86. package/dist/cli-picker.js +11 -7
  87. package/dist/cli-picker.js.map +1 -1
  88. package/dist/cli.d.ts +0 -5
  89. package/dist/cli.d.ts.map +1 -1
  90. package/dist/cli.js +1039 -559
  91. package/dist/cli.js.map +1 -1
  92. package/dist/cloud/diff.d.ts +18 -0
  93. package/dist/cloud/diff.d.ts.map +1 -0
  94. package/dist/cloud/diff.js +58 -0
  95. package/dist/cloud/diff.js.map +1 -0
  96. package/dist/cloud/nudge.d.ts +66 -0
  97. package/dist/cloud/nudge.d.ts.map +1 -0
  98. package/dist/cloud/nudge.js +269 -0
  99. package/dist/cloud/nudge.js.map +1 -0
  100. package/dist/cloud/workspace-sync.d.ts +58 -0
  101. package/dist/cloud/workspace-sync.d.ts.map +1 -0
  102. package/dist/cloud/workspace-sync.js +268 -0
  103. package/dist/cloud/workspace-sync.js.map +1 -0
  104. package/dist/cloud/workspace.d.ts +159 -0
  105. package/dist/cloud/workspace.d.ts.map +1 -0
  106. package/dist/cloud/workspace.js +134 -0
  107. package/dist/cloud/workspace.js.map +1 -0
  108. package/dist/context/check.d.ts +1 -1
  109. package/dist/context/check.d.ts.map +1 -1
  110. package/dist/context/check.js +9 -8
  111. package/dist/context/check.js.map +1 -1
  112. package/dist/context/node-file.js +1 -1
  113. package/dist/context/node-file.js.map +1 -1
  114. package/dist/context/savings.d.ts.map +1 -1
  115. package/dist/context/savings.js +10 -7
  116. package/dist/context/savings.js.map +1 -1
  117. package/dist/engine.d.ts.map +1 -1
  118. package/dist/engine.js +3 -1
  119. package/dist/engine.js.map +1 -1
  120. package/dist/graph/build.d.ts +30 -0
  121. package/dist/graph/build.d.ts.map +1 -1
  122. package/dist/graph/build.js +65 -19
  123. package/dist/graph/build.js.map +1 -1
  124. package/dist/graph/cards.d.ts.map +1 -1
  125. package/dist/graph/cards.js +4 -2
  126. package/dist/graph/cards.js.map +1 -1
  127. package/dist/graph/check.d.ts.map +1 -1
  128. package/dist/graph/check.js +17 -18
  129. package/dist/graph/check.js.map +1 -1
  130. package/dist/graph/container.d.ts.map +1 -1
  131. package/dist/graph/container.js +7 -2
  132. package/dist/graph/container.js.map +1 -1
  133. package/dist/graph/extract-cache.d.ts +4 -0
  134. package/dist/graph/extract-cache.d.ts.map +1 -1
  135. package/dist/graph/extract-cache.js.map +1 -1
  136. package/dist/graph/extract.d.ts +2 -0
  137. package/dist/graph/extract.d.ts.map +1 -1
  138. package/dist/graph/extract.js +5 -32
  139. package/dist/graph/extract.js.map +1 -1
  140. package/dist/graph/generic.d.ts +7 -1
  141. package/dist/graph/generic.d.ts.map +1 -1
  142. package/dist/graph/generic.js +48 -8
  143. package/dist/graph/generic.js.map +1 -1
  144. package/dist/graph/native.d.ts +38 -0
  145. package/dist/graph/native.d.ts.map +1 -0
  146. package/dist/graph/native.js +169 -0
  147. package/dist/graph/native.js.map +1 -0
  148. package/dist/graph/refresh.d.ts.map +1 -1
  149. package/dist/graph/refresh.js +3 -2
  150. package/dist/graph/refresh.js.map +1 -1
  151. package/dist/graph/traverse-cli.d.ts.map +1 -1
  152. package/dist/graph/traverse-cli.js +4 -3
  153. package/dist/graph/traverse-cli.js.map +1 -1
  154. package/dist/graph/workspace-cli.d.ts +2 -0
  155. package/dist/graph/workspace-cli.d.ts.map +1 -1
  156. package/dist/graph/workspace-cli.js +13 -1
  157. package/dist/graph/workspace-cli.js.map +1 -1
  158. package/dist/graph/workspace.d.ts.map +1 -1
  159. package/dist/graph/workspace.js +6 -5
  160. package/dist/graph/workspace.js.map +1 -1
  161. package/dist/hosts/antigravity.d.ts +10 -3
  162. package/dist/hosts/antigravity.d.ts.map +1 -1
  163. package/dist/hosts/antigravity.js +27 -10
  164. package/dist/hosts/antigravity.js.map +1 -1
  165. package/dist/hosts/claude-global.d.ts +27 -4
  166. package/dist/hosts/claude-global.d.ts.map +1 -1
  167. package/dist/hosts/claude-global.js +90 -9
  168. package/dist/hosts/claude-global.js.map +1 -1
  169. package/dist/hosts/codex-hooks.d.ts.map +1 -1
  170. package/dist/hosts/codex-hooks.js +1 -2
  171. package/dist/hosts/codex-hooks.js.map +1 -1
  172. package/dist/hosts/config-write.d.ts.map +1 -1
  173. package/dist/hosts/config-write.js +2 -1
  174. package/dist/hosts/config-write.js.map +1 -1
  175. package/dist/hosts/cursor-hooks.d.ts +5 -2
  176. package/dist/hosts/cursor-hooks.d.ts.map +1 -1
  177. package/dist/hosts/cursor-hooks.js +14 -9
  178. package/dist/hosts/cursor-hooks.js.map +1 -1
  179. package/dist/hosts/init.d.ts +3 -0
  180. package/dist/hosts/init.d.ts.map +1 -1
  181. package/dist/hosts/init.js +32 -10
  182. package/dist/hosts/init.js.map +1 -1
  183. package/dist/hosts/instructions.d.ts +9 -4
  184. package/dist/hosts/instructions.d.ts.map +1 -1
  185. package/dist/hosts/instructions.js +19 -16
  186. package/dist/hosts/instructions.js.map +1 -1
  187. package/dist/hosts/mcp-config.d.ts +19 -4
  188. package/dist/hosts/mcp-config.d.ts.map +1 -1
  189. package/dist/hosts/mcp-config.js +81 -30
  190. package/dist/hosts/mcp-config.js.map +1 -1
  191. package/dist/hosts/plan.d.ts.map +1 -1
  192. package/dist/hosts/plan.js +11 -9
  193. package/dist/hosts/plan.js.map +1 -1
  194. package/dist/hosts/registry.d.ts +25 -2
  195. package/dist/hosts/registry.d.ts.map +1 -1
  196. package/dist/hosts/registry.js +53 -0
  197. package/dist/hosts/registry.js.map +1 -1
  198. package/dist/hosts/retract.d.ts.map +1 -1
  199. package/dist/hosts/retract.js +73 -33
  200. package/dist/hosts/retract.js.map +1 -1
  201. package/dist/hosts/sections.d.ts +17 -1
  202. package/dist/hosts/sections.d.ts.map +1 -1
  203. package/dist/hosts/sections.js +52 -2
  204. package/dist/hosts/sections.js.map +1 -1
  205. package/dist/mcp/instructions.d.ts +1 -25
  206. package/dist/mcp/instructions.d.ts.map +1 -1
  207. package/dist/mcp/instructions.js +6 -4
  208. package/dist/mcp/instructions.js.map +1 -1
  209. package/dist/mcp/server.d.ts.map +1 -1
  210. package/dist/mcp/server.js +7 -3
  211. package/dist/mcp/server.js.map +1 -1
  212. package/dist/mcp/tool-names.d.ts +10 -3
  213. package/dist/mcp/tool-names.d.ts.map +1 -1
  214. package/dist/mcp/tool-names.js +13 -5
  215. package/dist/mcp/tool-names.js.map +1 -1
  216. package/dist/mcp/tools.d.ts.map +1 -1
  217. package/dist/mcp/tools.js +6 -5
  218. package/dist/mcp/tools.js.map +1 -1
  219. package/dist/notes/ask-notes.d.ts +8 -0
  220. package/dist/notes/ask-notes.d.ts.map +1 -0
  221. package/dist/notes/ask-notes.js +18 -0
  222. package/dist/notes/ask-notes.js.map +1 -0
  223. package/dist/notes/branches.d.ts +85 -0
  224. package/dist/notes/branches.d.ts.map +1 -0
  225. package/dist/notes/branches.js +238 -0
  226. package/dist/notes/branches.js.map +1 -0
  227. package/dist/notes/cold-start.d.ts +127 -0
  228. package/dist/notes/cold-start.d.ts.map +1 -0
  229. package/dist/notes/cold-start.js +476 -0
  230. package/dist/notes/cold-start.js.map +1 -0
  231. package/dist/notes/git-facts.d.ts +17 -0
  232. package/dist/notes/git-facts.d.ts.map +1 -0
  233. package/dist/notes/git-facts.js +117 -0
  234. package/dist/notes/git-facts.js.map +1 -0
  235. package/dist/notes/home.d.ts +87 -0
  236. package/dist/notes/home.d.ts.map +1 -0
  237. package/dist/notes/home.js +423 -0
  238. package/dist/notes/home.js.map +1 -0
  239. package/dist/notes/notes.d.ts +178 -0
  240. package/dist/notes/notes.d.ts.map +1 -0
  241. package/dist/notes/notes.js +498 -0
  242. package/dist/notes/notes.js.map +1 -0
  243. package/dist/notes/repo-trail.d.ts +16 -0
  244. package/dist/notes/repo-trail.d.ts.map +1 -0
  245. package/dist/notes/repo-trail.js +74 -0
  246. package/dist/notes/repo-trail.js.map +1 -0
  247. package/dist/notes/session-cost.d.ts +21 -0
  248. package/dist/notes/session-cost.d.ts.map +1 -0
  249. package/dist/notes/session-cost.js +162 -0
  250. package/dist/notes/session-cost.js.map +1 -0
  251. package/dist/search/grep-cli.d.ts.map +1 -1
  252. package/dist/search/grep-cli.js +4 -3
  253. package/dist/search/grep-cli.js.map +1 -1
  254. package/dist/skills/skills.d.ts +118 -0
  255. package/dist/skills/skills.d.ts.map +1 -0
  256. package/dist/skills/skills.js +370 -0
  257. package/dist/skills/skills.js.map +1 -0
  258. package/dist/telemetry/contract.d.ts +2 -14
  259. package/dist/telemetry/contract.d.ts.map +1 -1
  260. package/dist/telemetry/contract.js +39 -24
  261. package/dist/telemetry/contract.js.map +1 -1
  262. package/dist/telemetry/gate.d.ts.map +1 -1
  263. package/dist/telemetry/gate.js +2 -1
  264. package/dist/telemetry/gate.js.map +1 -1
  265. package/dist/telemetry/notice.d.ts +1 -1
  266. package/dist/telemetry/notice.d.ts.map +1 -1
  267. package/dist/telemetry/notice.js +6 -5
  268. package/dist/telemetry/notice.js.map +1 -1
  269. package/dist/telemetry/track.d.ts.map +1 -1
  270. package/dist/telemetry/track.js +5 -0
  271. package/dist/telemetry/track.js.map +1 -1
  272. package/dist/upkeep-run.d.ts +7 -0
  273. package/dist/upkeep-run.d.ts.map +1 -1
  274. package/dist/upkeep-run.js +52 -12
  275. package/dist/upkeep-run.js.map +1 -1
  276. package/dist/upkeep.d.ts +9 -19
  277. package/dist/upkeep.d.ts.map +1 -1
  278. package/dist/upkeep.js +38 -59
  279. package/dist/upkeep.js.map +1 -1
  280. package/dist/util/browser.d.ts +3 -0
  281. package/dist/util/browser.d.ts.map +1 -0
  282. package/dist/util/browser.js +14 -0
  283. package/dist/util/browser.js.map +1 -0
  284. package/dist/util/front-matter.d.ts +19 -0
  285. package/dist/util/front-matter.d.ts.map +1 -0
  286. package/dist/util/front-matter.js +21 -0
  287. package/dist/util/front-matter.js.map +1 -0
  288. package/dist/util/state.d.ts +1 -18
  289. package/dist/util/state.d.ts.map +1 -1
  290. package/dist/util/state.js +4 -2
  291. package/dist/util/state.js.map +1 -1
  292. package/dist/viz/assemble.js +1 -1
  293. package/dist/viz/assemble.js.map +1 -1
  294. package/dist/viz/serve.d.ts.map +1 -1
  295. package/dist/viz/serve.js +4 -3
  296. package/dist/viz/serve.js.map +1 -1
  297. package/package.json +7 -7
  298. package/scripts/pack-trail.mjs +53 -0
  299. package/scripts/postinstall.mjs +17 -2
  300. package/scripts/run-tests.mjs +4 -1
  301. package/dist/brain/attach.d.ts +0 -44
  302. package/dist/brain/attach.d.ts.map +0 -1
  303. package/dist/brain/attach.js +0 -63
  304. package/dist/brain/attach.js.map +0 -1
  305. package/dist/brain/claude-md.d.ts +0 -61
  306. package/dist/brain/claude-md.d.ts.map +0 -1
  307. package/dist/brain/claude-md.js +0 -171
  308. package/dist/brain/claude-md.js.map +0 -1
  309. package/dist/brain/connect.d.ts +0 -59
  310. package/dist/brain/connect.d.ts.map +0 -1
  311. package/dist/brain/connect.js +0 -80
  312. package/dist/brain/connect.js.map +0 -1
  313. package/dist/brain/context-files.d.ts +0 -79
  314. package/dist/brain/context-files.d.ts.map +0 -1
  315. package/dist/brain/context-files.js +0 -245
  316. package/dist/brain/context-files.js.map +0 -1
  317. package/dist/brain/link.d.ts +0 -106
  318. package/dist/brain/link.d.ts.map +0 -1
  319. package/dist/brain/link.js +0 -167
  320. package/dist/brain/link.js.map +0 -1
  321. package/dist/brain/pull.d.ts +0 -48
  322. package/dist/brain/pull.d.ts.map +0 -1
  323. package/dist/brain/pull.js +0 -257
  324. package/dist/brain/pull.js.map +0 -1
  325. package/dist/brain/push.d.ts +0 -155
  326. package/dist/brain/push.d.ts.map +0 -1
  327. package/dist/brain/push.js +0 -304
  328. package/dist/brain/push.js.map +0 -1
  329. package/dist/brain/signup.d.ts +0 -117
  330. package/dist/brain/signup.d.ts.map +0 -1
  331. package/dist/brain/signup.js +0 -276
  332. package/dist/brain/signup.js.map +0 -1
  333. package/dist/brain/upload.d.ts +0 -61
  334. package/dist/brain/upload.d.ts.map +0 -1
  335. package/dist/brain/upload.js +0 -210
  336. package/dist/brain/upload.js.map +0 -1
  337. package/dist/brain/watch.d.ts +0 -209
  338. package/dist/brain/watch.d.ts.map +0 -1
  339. package/dist/brain/watch.js +0 -397
  340. package/dist/brain/watch.js.map +0 -1
  341. package/dist/brain/wire.d.ts +0 -38
  342. package/dist/brain/wire.d.ts.map +0 -1
  343. package/dist/brain/wire.js +0 -133
  344. package/dist/brain/wire.js.map +0 -1
  345. package/dist/legacy-args.d.ts +0 -12
  346. package/dist/legacy-args.d.ts.map +0 -1
  347. package/dist/legacy-args.js +0 -31
  348. package/dist/legacy-args.js.map +0 -1
package/dist/cli.js CHANGED
@@ -1,12 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * `graft` CLI. Commands: build, ask, check, viz, mcp, callers, skeleton, grep,
4
- * map, init. Git is the sync: commit graft/ and a clone has the graph. A
5
- * workspace parent (≥2 git children) federates query commands across children.
3
+ * The `trail` CLI, also run as `graft`, its old name. Commands: build, ask,
4
+ * check, viz, mcp, callers, skeleton, grep, map, init. Git is the sync: commit
5
+ * graft/ and a clone has the graph. A workspace parent (≥2 git children)
6
+ * federates query commands across children.
7
+ *
8
+ * Both names run this file. Under `graft` every command keeps its old name and
9
+ * meaning; under `trail` the layout changes (see `TRAIL` below and brand.ts).
6
10
  */
11
+ import { spawnSync } from "node:child_process";
7
12
  import "dotenv/config";
8
13
  import { Command } from "commander";
9
- import { join, relative, resolve } from "node:path";
14
+ import { statSync } from "node:fs";
15
+ import { basename, dirname, join, relative, resolve } from "node:path";
10
16
  import { fileURLToPath } from "node:url";
11
17
  import { Graft } from "./engine.js";
12
18
  import { resolveConfig } from "./ai/providers.js";
@@ -16,16 +22,10 @@ import { buildGraphIfMissing, runInit } from "./claude/init.js";
16
22
  import { statuslineWanted } from "./claude/settings-merge.js";
17
23
  import { runHostsInit } from "./hosts/init.js";
18
24
  import { hostIds } from "./hosts/registry.js";
19
- import { parseBrainArg, connectBrain, pullBrain, brainStatus } from "./brain/connect.js";
20
- import { rulesForPointers } from "./brain/attach.js";
21
- import { clearLink } from "./brain/link.js";
22
- import { buildEarlyDigest, buildLocalDigest, fetchExpectedRepo, pushContext, pushDigest, pushEarlyDigest, repoSlugFromGit, sameRepo, uploadCaps, } from "./brain/push.js";
23
- import { apiBaseUrl, clearPendingSignup, readLink, readPendingSignup, writeLink, writePendingSignup } from "./brain/link.js";
24
- import { withLegacyNames } from "./legacy-args.js";
25
- import { currentStage, DOING_LABEL, rulesSoFar, watchBuild } from "./brain/watch.js";
26
- import { AGENT_WAIT_MS, brainUrl, newSignupState, openBrowser, PENDING_SIGNUP_TTL_MS, reviewUrl, signupUrl, startHandoff, waitForSignup, } from "./brain/signup.js";
27
- import { runTrailPull, suggestionsLine } from "./brain/pull.js";
28
- import { startSpinner } from "./util/spinner.js";
25
+ import { checkoutRoot, ensureRepoHome, keepsNotes, rememberRepo, repoName, repoPlace, shownPath } from "./notes/home.js";
26
+ import { brand, cmd, graftNotice, strandedRepoNotice, tag } from "./brand.js";
27
+ import { branchDiff } from "./cloud/diff.js";
28
+ import { openBrowser } from "./util/browser.js";
29
29
  import { contextDirFor } from "./context/node-file.js";
30
30
  import { loadGraphCached } from "./graph/load.js";
31
31
  import { ensureFreshChildren, ensureFreshGraph, refreshNote } from "./graph/refresh.js";
@@ -40,15 +40,51 @@ import { planRetract, runRetract, changed } from "./hosts/retract.js";
40
40
  import { compactWrites, formatNonInteractiveHelp, formatPlan, runPicker } from "./cli-picker.js";
41
41
  import { buildGraphWithProgress } from "./claude/build-progress.js";
42
42
  import { homedir } from "node:os";
43
- import { formatUpgradeReport, formatVersionReport, getNpmViewVersion, readCurrentVersion, runUpgrade } from "./cli-meta.js";
43
+ import { formatUpgradeReport, formatVersionReport, getNpmViewVersion, readCurrentVersion, readPackageName, runUpgrade } from "./cli-meta.js";
44
+ import { nativeFallbackNotice } from "./graph/native.js";
44
45
  import { patchBuildConfig } from "./util/state.js";
45
46
  import { normalizePathPrefix } from "./util/paths.js";
46
47
  import { latestSession, formatSessionStats, sessionInputRate } from "./claude/session-metrics.js";
48
+ import { listSessionIds, readSession, sessionDir } from "./claude/state.js";
49
+ import { costLabel, listNotes, noteName, notePlace, noteTargets, readingTokens, savedByNote, commitLines, PERSONAL_SAVED_LINE, SECTIONS, sectionLead, shortDate, tokensLabel, writeNote, mentionedFiles } from "./notes/notes.js";
50
+ import { currentSessionCost } from "./notes/session-cost.js";
51
+ import { beginPastRead, finishPastRead } from "./notes/cold-start.js";
52
+ import { currentBranch, noteAuthor, teammatesOn } from "./notes/git-facts.js";
53
+ import { confirm, fold, getSkill, learn, listSkills, publish, skillForAgent, skillName, skillStatus } from "./skills/skills.js";
54
+ import { clearPending, cloudLines, loginLink, sessionKey, waitForConnect } from "./cloud/nudge.js";
55
+ import { addRepo, clearCloud, cloudEnabled, fetchMe, fetchTeam, isError, readCloud, writeCloud } from "./cloud/workspace.js";
56
+ import { inWorkspace, maybeSyncInBackground, recordActivity, runSyncChild, syncRepo } from "./cloud/workspace-sync.js";
57
+ import { checkLocally } from "./check/local-check.js";
58
+ import { formatOverlap, maybeRefreshBranches, overlapsWith, pushedWhen, readBranchCache, refreshBranches } from "./notes/branches.js";
59
+ import { ensureRepoTrail, LEARNINGS_DIR } from "./notes/repo-trail.js";
47
60
  import { setInputRate } from "./context/savings.js";
48
- import { formatUpdateNudge, maybeRefreshInBackground, readStamp, readUpdateCache, refreshUpdateCache, wiredHostIds, writeStamp } from "./upkeep.js";
49
- import { errorCode, filesBucket, durationBucket, formatDebug, formatStatus, firstRunNotice, isTrackedCommand, langsValue, offReason, maybeFlushInBackground, patchState, runFlush, track, trackFirstRunIfNew, } from "./telemetry/index.js";
61
+ import { formatUpdateNudge, maybeRefreshInBackground, readStamp, readUpdateCache, refreshUpdateCache, wiredHostIds, wiringOpts, writeStamp } from "./upkeep.js";
62
+ import { shouldMoveToTrail } from "./upkeep-run.js";
63
+ import { countBucket, savedTokensBucket, errorCode, filesBucket, durationBucket, formatDebug, formatStatus, firstRunNotice, isTrackedCommand, langsValue, offReason, maybeFlushInBackground, patchState, runFlush, track, trackFirstRunIfNew, } from "./telemetry/index.js";
50
64
  const program = new Command();
51
65
  const currentVersion = readCurrentVersion(import.meta.url);
66
+ /**
67
+ * Started as `trail` rather than `graft`. Decided once, before any command is
68
+ * registered, because it changes which commands exist: under trail the old
69
+ * `graft trail …` group moves to the top level, `check` hands its name to the
70
+ * team check (the freshness check is `build --check`), and `stats` and
71
+ * `trail status` become one `status`. Under graft nothing moves.
72
+ */
73
+ const TRAIL = brand() === "trail";
74
+ /** Help-screen groups under trail; graft keeps its one flat list. */
75
+ const GROUP = {
76
+ find: "Find code · your agent runs these:",
77
+ memory: "Team memory:",
78
+ setup: "Setup:",
79
+ cloud: "Workspace · your team's view, across repos:",
80
+ ci: "CI:",
81
+ };
82
+ /** Put a command in a help group, under trail only. */
83
+ function grouped(c, heading) {
84
+ if (TRAIL)
85
+ c.helpGroup(heading);
86
+ return c;
87
+ }
52
88
  /**
53
89
  * What the `query` telemetry event will say, filled in by the command as it runs
54
90
  * and emitted once from the `postAction` hook below.
@@ -74,8 +110,11 @@ function noteHit(found) {
74
110
  queryNote.hit = found ? "yes" : "no";
75
111
  }
76
112
  program
77
- .name("graft")
78
- .description("Build a repo's context graph as linked markdown, and keep it in sync with the code.")
113
+ .name(brand())
114
+ .description(TRAIL
115
+ ? "Memory for your coding agents: a code map, and the learnings each session\n" +
116
+ "leaves in your repo's .trail/, so your whole team's Claude starts from them."
117
+ : "Build a repo's context graph as linked markdown, and keep it in sync with the code.")
79
118
  .version(currentVersion, "-v, --version")
80
119
  .option("--dir <path>", "context graph directory (default: <repo>/graft)")
81
120
  .option("--provider <name>", "LLM wire format: openai | anthropic | litellm | orcarouter (env GRAFT_PROVIDER)")
@@ -124,7 +163,7 @@ function queryRoot(dir) {
124
163
  return resolve(dir);
125
164
  const { root, levels } = nearestGraftRoot(process.cwd(), program.opts().dir);
126
165
  if (levels > 0)
127
- console.error(`[graft] no graft/ here — answering from ${root}/graft`);
166
+ console.error(`${tag()} no graft/ here — answering from ${root}/graft`);
128
167
  return root;
129
168
  }
130
169
  /**
@@ -173,7 +212,7 @@ function parseTabs(raw) {
173
212
  * not editorialize on stderr at startup (`mcp` runs its own upkeep at boot, and
174
213
  * `_update-check` IS the fetch).
175
214
  */
176
- const UPKEEP_SKIP = new Set(["version", "upgrade", "_update-check", "_brain-refresh", "mcp"]);
215
+ const UPKEEP_SKIP = new Set(["version", "upgrade", "_update-check", "_trail-past-read", "mcp"]);
177
216
  /**
178
217
  * Every other command: top up the cached registry answer in the background and,
179
218
  * if a newer graft is out, say so once on stderr. This is what makes the CLI the
@@ -182,6 +221,16 @@ const UPKEEP_SKIP = new Set(["version", "upgrade", "_update-check", "_brain-refr
182
221
  program.hook("preAction", (_parent, action) => {
183
222
  if (UPKEEP_SKIP.has(action.name()))
184
223
  return;
224
+ // Someone typing graft by hand learns its new name, once a day. Agents,
225
+ // hooks, CI and pipes never see it (see graftNotice).
226
+ const renamed = graftNotice({ ran: spelling(action), tty: Boolean(process.stderr.isTTY) });
227
+ if (renamed)
228
+ console.error(renamed);
229
+ // Someone typing trail in a repo whose graft-era hooks can't reach it learns
230
+ // the one command that moves it (init itself is that command).
231
+ const stranded = action.name() === "init" ? null : strandedRepoNotice({ cwd: process.cwd(), tty: Boolean(process.stderr.isTTY) });
232
+ if (stranded)
233
+ console.error(stranded);
185
234
  maybeRefreshInBackground();
186
235
  const nudge = formatUpdateNudge(currentVersion, readUpdateCache()?.latest);
187
236
  if (nudge)
@@ -202,26 +251,52 @@ program.hook("preAction", (_parent, action) => {
202
251
  * is the right failure mode for a metric.
203
252
  */
204
253
  program.hook("postAction", (_parent, action) => {
205
- const name = action.name();
254
+ // `trail build --check` is graft's `check`, and counts as it always has.
255
+ const name = queryNote.command ?? action.name();
206
256
  if (!isTrackedCommand(name))
207
257
  return;
208
- track("query", { command: name, surface: "cli", hit: queryNote.hit }, { repo: queryNote.repo });
258
+ track("query", { command: name, surface: "cli", hit: queryNote.hit, notes: queryNote.notes }, { repo: queryNote.repo });
259
+ });
260
+ /** `graft ask`, `graft build`: a command as graft spells it, whatever ran it. */
261
+ function spelling(action) {
262
+ const words = [];
263
+ for (let c = action; c && c.parent; c = c.parent)
264
+ words.unshift(c.name());
265
+ return ["graft", ...words].join(" ");
266
+ }
267
+ // Hidden: spawned detached by maybeSyncInBackground when a workspace repo's
268
+ // learnings changed, or uses are waiting to go. Running it by hand syncs now.
269
+ // A repo that moved from graft to trail at session start reads its past
270
+ // sessions here, detached, so the session doesn't wait on it.
271
+ program
272
+ .command("_trail-past-read", { hidden: true })
273
+ .argument("<dir>")
274
+ .action(async (dir) => {
275
+ const repo = resolve(dir);
276
+ const read = beginPastRead(repo);
277
+ if (read)
278
+ await finishPastRead(repo, read);
209
279
  });
210
- // Hidden from --help: only ever spawned detached by maybeRefreshInBackground.
211
280
  program
212
- .command("_brain-refresh", { hidden: true })
213
- .description("internal: re-pull the attached brain's rules and rewrite the agent files")
281
+ .command("_trail-sync", { hidden: true })
282
+ .description("internal: send this repo's learnings and the waiting uses to the Trail workspace")
214
283
  .argument("[dir]", "target repo directory", ".")
215
284
  .action(async (dir) => {
216
- // Spawned detached by upkeep, so nothing here is user-visible and nothing
217
- // may throw: a failure means the cached rules keep serving, which is the
218
- // correct outcome for a brain that is momentarily unreachable.
219
- try {
220
- await pullBrain(resolve(dir), { home: homedir() });
221
- }
222
- catch {
223
- /* the next session tries again */
224
- }
285
+ await runSyncChild(resolve(dir));
286
+ });
287
+ // Hidden: spawned detached by maybeRefreshBranches.
288
+ program
289
+ .command("_trail-branches", { hidden: true })
290
+ .description("internal: fetch, then note which files teammates' pushed branches change and the learnings they carry")
291
+ .argument("[dir]", "target repo directory", ".")
292
+ .action((dir) => {
293
+ refreshBranches(resolve(dir));
294
+ });
295
+ program
296
+ .command("_trail-claim", { hidden: true })
297
+ .description("internal: wait for a sign-in through the link trail handed the agent, then connect the workspace")
298
+ .action(async () => {
299
+ await waitForConnect();
225
300
  });
226
301
  program
227
302
  .command("_update-check", { hidden: true })
@@ -237,8 +312,7 @@ program
237
312
  .action(async () => {
238
313
  await runFlush();
239
314
  });
240
- program
241
- .command("telemetry")
315
+ grouped(program.command("telemetry"), GROUP.setup)
242
316
  .description("Show, inspect, or turn off the anonymous usage stats (see TELEMETRY.md)")
243
317
  .argument("[action]", "status (default) | enable | disable | debug", "status")
244
318
  .action((action) => {
@@ -248,7 +322,7 @@ program
248
322
  return;
249
323
  case "enable":
250
324
  patchState({ enabled: true });
251
- console.log("telemetry: on — anonymous, aggregate-only. `graft telemetry status` for details.");
325
+ console.log(`telemetry: on — anonymous, aggregate-only. \`${cmd("graft telemetry status")}\` for details.`);
252
326
  return;
253
327
  case "disable":
254
328
  // Also stamp the notice as shown: someone who has just opted out should
@@ -264,27 +338,49 @@ program
264
338
  process.exit(1);
265
339
  }
266
340
  });
341
+ // Hidden under trail, where -v says the same; listed under graft as it was.
267
342
  program
268
- .command("version")
343
+ .command("version", { hidden: TRAIL })
269
344
  .description("Print the installed version and the latest published on npm")
270
345
  .action(() => {
271
346
  const latest = getNpmViewVersion();
272
347
  console.log(formatVersionReport(currentVersion, latest));
273
348
  });
274
- program
275
- .command("upgrade")
276
- .description("Upgrade the globally installed graft to the latest version on npm")
349
+ grouped(program.command("upgrade"), GROUP.setup)
350
+ .description(`Upgrade the globally installed ${brand()} to the latest version on npm`)
277
351
  .action(() => {
278
352
  const result = runUpgrade(import.meta.url);
279
353
  console.log(formatUpgradeReport(result));
280
354
  if (result.ran && !result.ok)
281
355
  process.exit(1);
356
+ if (!TRAIL && result.ok && result.trailVersion)
357
+ moveHereToTrail(process.cwd());
282
358
  });
283
- program
284
- .command("build")
285
- .description("Build graft/ from your code — wiring graph + per-file cards ($0, no key). " +
286
- "Add --deep for the LLM concept map + per-symbol summaries/crux.")
359
+ /**
360
+ * After `graft upgrade` brought the trail command along, the repo it ran in
361
+ * moves to trail now, with the agents and flags its last init chose, and its
362
+ * past sessions are read for learnings. Every other repo wired as graft moves
363
+ * the next time a session opens in it (upkeep-run.ts).
364
+ */
365
+ function moveHereToTrail(dir) {
366
+ const root = checkoutRoot(dir);
367
+ if (!root || !shouldMoveToTrail(root))
368
+ return;
369
+ const stamp = readStamp(root);
370
+ const hosts = [...new Set([...(stamp?.hosts ?? []), ...wiredHostIds(root)])];
371
+ const o = wiringOpts(stamp);
372
+ const flags = [o.global ? null : "--no-global", o.mcp ? null : "--no-mcp", o.hooks ? null : "--no-hooks", o.statusline ? null : "--no-statusline"].filter((f) => f !== null);
373
+ console.log(`· moving this repo to trail: trail init ${hosts.length ? `--agents ${hosts.join(" ")} ` : ""}${flags.join(" ")}`.trimEnd());
374
+ spawnSync("trail", ["init", root, "--agents", ...hosts, ...flags], { stdio: "inherit" });
375
+ }
376
+ grouped(program.command("build"), GROUP.setup)
377
+ .description(TRAIL
378
+ ? "Rebuild the code map in graft/ ($0, no key). --check fails if it's stale; --deep adds LLM summaries."
379
+ : "Build graft/ from your code — wiring graph + per-file cards ($0, no key). " +
380
+ "Add --deep for the LLM concept map + per-symbol summaries/crux.")
287
381
  .argument("[dir]", "repository root", ".")
382
+ .option("--check", "build nothing: fail if graft/ is stale relative to the code (for CI)")
383
+ .option("--json", "with --check: output the drift as JSON")
288
384
  .option("--deep", "run the LLM pass: concept nodes (graft/*.md) + per-symbol summary/crux")
289
385
  .option("-e, --extensions <exts...>", 'code extensions to include (e.g. ".ts" ".py"); an extension with no parser is ignored with a warning that lists the supported set')
290
386
  .option("-j, --concurrency <n>", "files summarized in parallel during --deep (default 5)")
@@ -304,6 +400,13 @@ program
304
400
  .option("--no-gitignore", "skip writing graft/ into .gitignore (same as GRAFT_NO_GITIGNORE=1)")
305
401
  .option("--no-ignore", "skip writing .ignore for ripgrep re-admit (same as GRAFT_NO_IGNORE=1)")
306
402
  .action(async (dir, opts, command) => {
403
+ if (opts.check) {
404
+ // The freshness check, which was `graft check`. Counted as `check` so the
405
+ // metric carries on across the rename; the dir defaults like a query's.
406
+ queryNote.command = "check";
407
+ await runCheckCommand(command.args[0], { extensions: opts.extensions, json: opts.json }, cmd("graft check"));
408
+ return;
409
+ }
307
410
  const buildStartedAt = Date.now();
308
411
  if (opts.gitignore === false)
309
412
  process.env.GRAFT_NO_GITIGNORE = "1";
@@ -377,7 +480,7 @@ program
377
480
  deep = false;
378
481
  console.error("⚠ no API key set — falling back to the structural build (no LLM summaries).\n" +
379
482
  " Set GRAFT_API_KEY (and GRAFT_PROVIDER / GRAFT_BASE_URL / GRAFT_MODEL for your\n" +
380
- " provider) and re-run `graft build --deep` to add concept nodes and summaries.");
483
+ ` provider) and re-run \`${cmd("graft build --deep")}\` to add concept nodes and summaries.`);
381
484
  }
382
485
  if (deep && resolved.usedLegacyEnv) {
383
486
  console.error("⚠ using OPENROUTER_API_KEY (deprecated) — prefer GRAFT_API_KEY + GRAFT_BASE_URL.");
@@ -395,6 +498,7 @@ program
395
498
  includeDirs: opts.includeDir,
396
499
  followSubmodules: followSubmodulesWasExplicit ? opts.followSubmodules : undefined,
397
500
  followNestedRepos: followNestedReposWasExplicit ? opts.followNestedRepos : undefined,
501
+ packageName: readPackageName(import.meta.url),
398
502
  });
399
503
  return;
400
504
  }
@@ -452,12 +556,17 @@ program
452
556
  }, { repo: buildRoot });
453
557
  for (const e of g.errors)
454
558
  console.error(`✗ ${e}`);
559
+ // A language whose native grammar didn't load was parsed by its WASM stand-in:
560
+ // say which, and the install that fixes it. Nothing when everything loaded.
561
+ const fallbackNotice = nativeFallbackNotice(g.nativeFallback, readPackageName(import.meta.url));
562
+ if (fallbackNotice)
563
+ console.error(fallbackNotice);
455
564
  const rel = relative(process.cwd(), g.contextDir) || "graft";
456
565
  if (process.env.GRAFT_NO_GITIGNORE) {
457
566
  console.log(` ${rel}/ is a local cache — add it to your gitignore if you want it untracked.`);
458
567
  }
459
568
  else {
460
- console.log(` ${rel}/ is git-ignored (added automatically) — a local cache; teammates run \`graft build\` to get their own.`);
569
+ console.log(` ${rel}/ is git-ignored (added automatically) — a local cache; teammates run \`${cmd("graft build")}\` to get their own.`);
461
570
  }
462
571
  // #127: a --deep run whose LLM calls failed used to print the same success
463
572
  // footer and exit 0, so a quota-exhausted build looked identical to a clean
@@ -484,15 +593,58 @@ program
484
593
  if (conceptErrors.length > 0)
485
594
  console.error(` ${conceptErrors.length} concept-pass error(s).`);
486
595
  console.error(` meaning coverage: ${ready}/${total} symbols (${pct}%).`);
487
- console.error(" Nothing computed was lost: re-run `graft build --deep` to resume from what is cached.\n" +
596
+ console.error(` Nothing computed was lost: re-run \`${cmd("graft build --deep")}\` to resume from what is cached.\n` +
488
597
  " Pass --allow-partial to accept a degraded meaning tier and exit 0.");
489
598
  if (!opts.allowPartial)
490
599
  process.exitCode = 1;
491
600
  }
492
601
  }
493
602
  });
494
- program
495
- .command("ask")
603
+ /** The file part of a hit's `file:Lx-Ly` pointer. */
604
+ function pointerFile(pointer) {
605
+ return pointer.replace(/:L\d+(-L?\d+)?$/, "");
606
+ }
607
+ /**
608
+ * After `ask`, under trail: which shown learnings are a teammate's, which
609
+ * teammates' pushed branches change the files the answer points into, and the
610
+ * shown learnings recorded as a use, which is what the team page counts as
611
+ * reuse. Then the background upkeep that keeps branches and the workspace
612
+ * current. Spawns no git itself.
613
+ */
614
+ function afterAsk(r, repo) {
615
+ if (!r.notesChecked)
616
+ return;
617
+ rememberRepo(repo);
618
+ maybeRefreshBranches(repo);
619
+ const me = noteAuthor(repo);
620
+ const files = [...new Set(r.hits.map((h) => pointerFile(h.pointer)))];
621
+ const overlaps = overlapsWith(repo, files, me).slice(0, 2);
622
+ if (overlaps.length) {
623
+ r.overlaps = overlaps.map((o) => formatOverlap(o));
624
+ if (!process.stdout.isTTY)
625
+ r.overlaps.push(`${tag()} Start your reply with the overlap line${overlaps.length === 1 ? "" : "s"} above, word for word, so the user knows.`);
626
+ }
627
+ const shown = r.notes ?? [];
628
+ if (shown.length) {
629
+ for (const h of shown)
630
+ h.note.teammate = !!h.note.author && !!me && h.note.author !== me;
631
+ recordActivity({
632
+ repo: repoPlace(repo).key,
633
+ kind: "search",
634
+ asker: me,
635
+ session: sessionKey(repo) ?? `${me}-${new Date().toISOString().slice(0, 10)}`,
636
+ query: r.query,
637
+ files,
638
+ shown_paths: shown.map((h) => noteName(h.note)),
639
+ shown_authors: shown.map((h) => h.note.author),
640
+ at: new Date().toISOString(),
641
+ });
642
+ const saved = shown.reduce((sum, h) => sum + savedByNote(h.note), 0);
643
+ track("learning_reused", { teammate: String(shown.some((h) => h.note.teammate)), saved_tokens_bucket: savedTokensBucket(saved) }, { repo });
644
+ }
645
+ maybeSyncInBackground(repo);
646
+ }
647
+ grouped(program.command("ask"), GROUP.find)
496
648
  .description("Query the graft/ graph — returns ranked nodes + exact file:line, routed to prose or wiring ($0, no key)")
497
649
  .argument("<query>", "what you want to understand, in plain words")
498
650
  .argument(...DIR_ARG)
@@ -524,16 +676,22 @@ program
524
676
  return;
525
677
  }
526
678
  noteHit(r.hits.length > 0);
679
+ // Teammates' branches on the same files, and the learnings shown, counted as a use.
680
+ if (TRAIL)
681
+ afterAsk(r, dir);
682
+ // How many notes reached the agent: the demand side of `trail note`.
683
+ if (r.notesChecked)
684
+ queryNote.notes = String(Math.min(r.notes?.length ?? 0, 2));
527
685
  if (opts.json) {
528
686
  console.log(JSON.stringify(r, null, 2));
529
687
  }
530
688
  else {
531
689
  const { formatAsk } = await import("./ask/ask.js");
532
690
  process.stdout.write(formatAsk(r));
691
+ printLines(cloudLines(dir, { command: "ask" }));
533
692
  }
534
693
  });
535
- program
536
- .command("skeleton")
694
+ grouped(program.command("skeleton"), GROUP.find)
537
695
  .description("Signatures-only view of one file from the wiring graph — the cheapest way to see a file's API surface")
538
696
  .argument("<file>", "repo-relative path (or unique basename) of the file")
539
697
  .argument(...DIR_ARG)
@@ -550,13 +708,24 @@ program
550
708
  else
551
709
  process.stdout.write(formatSkeleton(r));
552
710
  });
553
- program
554
- .command("check")
555
- .description("Fail if graft/ is stale relative to the code (for CI)")
556
- .argument(...DIR_ARG)
557
- .option("-e, --extensions <exts...>", "code extensions to include")
558
- .option("--json", "output the drift as JSON")
559
- .action(async (dirArg, opts) => {
711
+ if (!TRAIL) {
712
+ program
713
+ .command("check")
714
+ .description("Fail if graft/ is stale relative to the code (for CI)")
715
+ .argument(...DIR_ARG)
716
+ .option("-e, --extensions <exts...>", "code extensions to include")
717
+ .option("--json", "output the drift as JSON")
718
+ .action(async (dirArg, opts) => {
719
+ await runCheckCommand(dirArg, opts, "graft check");
720
+ });
721
+ }
722
+ // Under trail, `check` is the team check (teamCheckCommand, with the cloud
723
+ // commands below); the freshness check is `trail build --check`.
724
+ /**
725
+ * The freshness check: `graft check`, and `trail build --check`. `label` is how
726
+ * the report names the command, so each name prints its own spelling.
727
+ */
728
+ async function runCheckCommand(dirArg, opts, label) {
560
729
  warnUnsupportedExtensions(opts.extensions);
561
730
  const dir = noteQuery(queryRoot(dirArg));
562
731
  const checkGlobalDir = program.opts().dir;
@@ -576,24 +745,25 @@ program
576
745
  console.log(JSON.stringify({ context: r, graph: g.missing ? null : g }, null, 2));
577
746
  }
578
747
  else if (bothMissing) {
579
- console.log("graft check: NO GRAPH\n\nNo graft/ graph found. Run `graft build` first.");
748
+ console.log(`${label}: NO GRAPH\n\nNo graft/ graph found. Run \`${cmd("graft build")}\` first.`);
580
749
  }
581
750
  else {
582
751
  if (r.missing) {
583
- console.log("deep layer: not built (run `graft build --deep` for concept nodes) — wiring graph is the source of truth");
752
+ console.log(`deep layer: not built (run \`${cmd("graft build --deep")}\` for concept nodes) — wiring graph is the source of truth`);
584
753
  }
585
754
  else {
586
- console.log(formatCheckReport(r));
755
+ console.log(formatCheckReport(r, label));
587
756
  }
588
757
  if (!g.missing)
589
758
  console.log("\n" + formatGraphCheckReport(g));
590
759
  }
591
760
  if (bothMissing || markdownFail || wiringFail)
592
761
  process.exit(1);
593
- });
762
+ }
763
+ // Under trail this is part of `status`; the old name still runs, unlisted.
594
764
  program
595
- .command("stats")
596
- .description("Show this agent session's graft-vs-source usage mix and tokens saved")
765
+ .command("stats", { hidden: TRAIL })
766
+ .description(`Show this agent session's ${brand()}-vs-source usage mix and tokens saved`)
597
767
  .argument(...DIR_ARG)
598
768
  .option("--json", "output the session stats as JSON")
599
769
  .action((dirArg, opts) => {
@@ -609,7 +779,7 @@ program
609
779
  console.log(formatSessionStats(s));
610
780
  });
611
781
  program
612
- .command("viz")
782
+ .command("viz", { hidden: TRAIL })
613
783
  .description("Serve an interactive visualization of the context graph (and graph.json when present)")
614
784
  .argument(...DIR_ARG)
615
785
  .option("-p, --port <port>", "port to serve on", "4400")
@@ -633,7 +803,7 @@ program
633
803
  const globalOpts = program.opts();
634
804
  const contextDir = contextDirFor(root, globalOpts.dir);
635
805
  if (!existsSync(contextDir)) {
636
- console.error(`✗ no context graph at ${contextDir} — run \`graft build --deep\` first`);
806
+ console.error(`✗ no context graph at ${contextDir} — run \`${cmd("graft build --deep")}\` first`);
637
807
  process.exit(1);
638
808
  }
639
809
  const viewerDir = fileURLToPath(new URL("./viewer/", import.meta.url)); // prebuilt
@@ -648,7 +818,7 @@ program
648
818
  tabs,
649
819
  });
650
820
  const kb = Math.round(out.bytes / 1024);
651
- console.log(`graft viz → ${out.file} (${kb} kB, ${out.contextNodes} concept nodes, ${out.codeNodes} code nodes)`);
821
+ console.log(`${cmd("graft viz")} → ${out.file} (${kb} kB, ${out.contextNodes} concept nodes, ${out.codeNodes} code nodes)`);
652
822
  return;
653
823
  }
654
824
  const srv = await startVizServer({
@@ -657,14 +827,14 @@ program
657
827
  port: Number(opts.port),
658
828
  repoName: basename(root),
659
829
  });
660
- console.log(`graft viz → ${srv.url} (ctrl-c to stop)`);
830
+ console.log(`${cmd("graft viz")} → ${srv.url} (ctrl-c to stop)`);
661
831
  if (opts.open) {
662
832
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
663
833
  spawn(opener, [srv.url], { stdio: "ignore", detached: true, shell: process.platform === "win32" }).unref();
664
834
  }
665
835
  });
666
836
  program
667
- .command("mcp")
837
+ .command("mcp", { hidden: TRAIL })
668
838
  .description("Serve the graph over MCP (stdio) — exposes graft_find_code, graft_trace_calls, graft_find_all, graft_file_api, graft_repo_map and graft_check_freshness as tools")
669
839
  .argument(...DIR_ARG)
670
840
  .action(async (dirArg) => {
@@ -673,8 +843,7 @@ program
673
843
  const globalOpts = program.opts();
674
844
  startMcpServer(dir, globalOpts.dir, currentVersion);
675
845
  });
676
- program
677
- .command("callers")
846
+ grouped(program.command("callers"), GROUP.find)
678
847
  .description("Who calls/references a symbol ($0, no LLM). --direction out gives callees (what it calls); --depth N (or all) walks transitively for full blast radius")
679
848
  .argument("<symbol>", "bare name, qualified (Class.method), or package-qualified (pkg.Fn)")
680
849
  .argument(...DIR_ARG)
@@ -706,8 +875,7 @@ program
706
875
  globalDir: globalOpts.dir,
707
876
  });
708
877
  });
709
- program
710
- .command("blast")
878
+ grouped(program.command("blast"), GROUP.ci)
711
879
  .description("Blast radius of a diff: what depends on the lines this change touched ($0, no LLM). " +
712
880
  "Built for CI — `--format markdown` is a PR comment with a Mermaid diagram.")
713
881
  .argument(...DIR_ARG)
@@ -736,8 +904,7 @@ program
736
904
  globalDir: program.opts().dir,
737
905
  });
738
906
  });
739
- program
740
- .command("grep")
907
+ grouped(program.command("grep"), GROUP.find)
741
908
  .description("Regex search over indexed files, hits grouped by enclosing symbol and ranked by coupling ($0, no LLM)")
742
909
  .argument("<pattern>", "regex pattern (or literal string with --fixed)")
743
910
  .argument(...DIR_ARG)
@@ -765,8 +932,7 @@ program
765
932
  globalDir: globalOpts.dir,
766
933
  });
767
934
  });
768
- program
769
- .command("map")
935
+ grouped(program.command("map"), GROUP.find)
770
936
  .description("Token-budgeted repo orientation — directory clusters, per-directory hubs, and global hotspots from the wiring graph ($0, no LLM)")
771
937
  .argument(...DIR_ARG)
772
938
  .option("--max-dirs <n>", "max directory entries shown, rest counted into dropped (default 16)")
@@ -795,7 +961,7 @@ program
795
961
  const contextDir = contextDirFor(root, globalOpts.dir);
796
962
  const graph = loadGraphCached(contextDir);
797
963
  if (!graph) {
798
- console.error("✗ no graph — run graft build first");
964
+ console.error(`✗ no graph — run ${cmd("graft build")} first`);
799
965
  process.exit(1);
800
966
  return;
801
967
  }
@@ -806,9 +972,175 @@ program
806
972
  }
807
973
  process.stdout.write(formatRepoMap(map));
808
974
  });
809
- program
810
- .command("init")
811
- .description("Wire Graft into the AI coding agents used with this repo (instruction files + MCP server; full hooks + statusline + MCP for Claude Code)")
975
+ // `trail note`: what a session worked out, saved for the next person. Listed
976
+ // under trail; under graft it still runs, unlisted, for a teammate on graft in
977
+ // a repo where someone else set up trail.
978
+ grouped(program.command("note", { hidden: !TRAIL }), GROUP.memory)
979
+ .description("Save what this session decided, tried and ruled out, as a learning in the repo's .trail/learnings/ (the text on stdin)")
980
+ .requiredOption("--title <text>", "what the session was about, in a few words")
981
+ .option("-m, --body <text>", "the learning itself, instead of stdin")
982
+ .option("--personal", "about your own setup or habits, not the code: keep it in ~/.trail, out of the repo")
983
+ .option("--touches <list>", "comma-separated files or file#Symbol it's about (default: the files this session changed, in every repo it changed)")
984
+ .option("--author <name>", "who wrote it (default: the first word of git config user.name)")
985
+ .option("--minutes <n>", "what it took to work out, when the agent's transcript can't say")
986
+ .option("--tokens <n>", "the same, in tokens")
987
+ .option("--json", "print the saved learning as JSON")
988
+ .argument("[dir]", "repository root", ".")
989
+ .action(async (dir, opts) => {
990
+ await runNoteCommand(resolve(dir), opts);
991
+ });
992
+ // `trail learn` and `trail skills`: corrections become takeaways on a skill,
993
+ // kept on this machine, and takeaways fold into its SKILL.md. Unlisted under
994
+ // graft, like `note`, but they run.
995
+ // Still runs; unlisted while learnings are the one thing trail keeps.
996
+ grouped(program.command("learn", { hidden: true }), GROUP.memory)
997
+ .description("Turn a correction into a takeaway on a skill, kept in ~/.trail (the takeaway on stdin)")
998
+ .argument("<skill>", "the skill it belongs to, e.g. pdf-coords; a new name makes a new skill")
999
+ .argument("[dir]", "repository root", ".")
1000
+ .option("-m, --text <text>", "the takeaway itself, instead of stdin")
1001
+ .option("--description <text>", "for a new skill: what it covers")
1002
+ .option("--section <heading>", "the SKILL.md heading it belongs under when folded")
1003
+ .option("--author <name>", "who taught it (default: the first word of git config user.name)")
1004
+ .action(async (skill, dir, opts) => {
1005
+ const repo = resolve(dir);
1006
+ const text = (opts.text ?? (await readStdin())).trim();
1007
+ if (!text) {
1008
+ console.error(`✗ a takeaway needs its text, on stdin or with -m:`);
1009
+ console.error(` ${cmd("graft learn")} ${skill} <<'EOF'\n Read rotation with page.Rotation(), never pdfcpu: it drops /Rotate on linearized files.\n EOF`);
1010
+ process.exitCode = 1;
1011
+ return;
1012
+ }
1013
+ const author = opts.author?.trim() || noteAuthor(repo);
1014
+ let r;
1015
+ try {
1016
+ r = learn(repo, { skill, text, author, date: new Date().toISOString().slice(0, 10), description: opts.description, section: opts.section });
1017
+ }
1018
+ catch (err) {
1019
+ console.error(`✗ ${err instanceof Error ? err.message : String(err)}`);
1020
+ process.exitCode = 1;
1021
+ return;
1022
+ }
1023
+ track("takeaway_saved", { new_skill: String(r.created) }, { repo });
1024
+ const kind = r.created ? "new skill" : r.skill.where === "repo" ? "the repo's skill" : "skill";
1025
+ console.log(`✓ takeaway saved to ${kind} ${r.skill.name}${author ? ` · taught by ${author}` : ""}`);
1026
+ console.log(`· your agent follows it from now on · kept on this machine, in ${shownPath(dirname(dirname(r.takeaway.path)))}`);
1027
+ console.log(`· ${cmd("graft skills")} fold ${r.skill.name} writes it into SKILL.md`);
1028
+ });
1029
+ const skillsCmd = grouped(program.command("skills", { hidden: true }), GROUP.memory)
1030
+ .description("List this repo's skills, and the takeaways waiting to fold into each")
1031
+ .argument("[dir]", "repository root", ".")
1032
+ .option("--json", "output as JSON")
1033
+ .action((dir, opts) => {
1034
+ const repo = resolve(dir);
1035
+ const all = listSkills(repo);
1036
+ if (opts.json) {
1037
+ console.log(JSON.stringify(all, null, 2));
1038
+ return;
1039
+ }
1040
+ if (all.length === 0) {
1041
+ console.log(`no skills yet · ${cmd("graft learn")} <skill> saves the first takeaway and makes the skill`);
1042
+ return;
1043
+ }
1044
+ const width = Math.max(...all.map((s) => s.name.length)) + 2;
1045
+ for (const s of all)
1046
+ console.log(`${s.name.padEnd(width)}${skillStatus(s)}`);
1047
+ });
1048
+ /** `✗ no skill called x` and the exit code, for a name that isn't one. */
1049
+ function noSuchSkill(name) {
1050
+ console.error(`✗ no skill called ${skillName(name)} · ${cmd("graft skills")} lists them`);
1051
+ process.exitCode = 1;
1052
+ }
1053
+ skillsCmd
1054
+ .command("show")
1055
+ .description("Print a skill with every takeaway not yet folded in: what an agent reads before working in its area")
1056
+ .argument("<skill>", "the skill")
1057
+ .option("-C, --dir <path>", "repository root", ".")
1058
+ .action((skill, opts) => {
1059
+ const s = getSkill(resolve(opts.dir), skill);
1060
+ if (!s)
1061
+ return noSuchSkill(skill);
1062
+ process.stdout.write(skillForAgent(s));
1063
+ });
1064
+ skillsCmd
1065
+ .command("fold")
1066
+ .description("Write a skill's waiting takeaways into its SKILL.md and bump its version")
1067
+ .argument("[skill]", "one skill; leave out to fold every skill with takeaways waiting")
1068
+ .option("--confirmed", "only the takeaways someone other than their teacher confirmed")
1069
+ .option("-C, --dir <path>", "repository root", ".")
1070
+ .action((skill, opts) => {
1071
+ const repo = resolve(opts.dir);
1072
+ if (skill && !getSkill(repo, skill))
1073
+ return noSuchSkill(skill);
1074
+ const names = skill ? [skill] : listSkills(repo).map((s) => s.name);
1075
+ let inRepo = false;
1076
+ let any = false;
1077
+ for (const n of names) {
1078
+ const r = fold(repo, n, { confirmed: opts.confirmed });
1079
+ if (!r)
1080
+ continue;
1081
+ if (r.folded.length === 0) {
1082
+ if (skill)
1083
+ console.log(`· ${r.skill} v${r.from} · nothing to fold${r.stillWaiting ? `, ${r.stillWaiting} waiting for a second person` : ""}`);
1084
+ continue;
1085
+ }
1086
+ any = true;
1087
+ inRepo ||= r.where === "repo";
1088
+ track("skills_folded", { takeaways_bucket: countBucket(r.folded.length) }, { repo });
1089
+ console.log(`✓ ${r.skill} v${r.from} → v${r.to} · folded ${r.folded.length} takeaway${r.folded.length === 1 ? "" : "s"}${r.stillWaiting ? `, ${r.stillWaiting} still waiting` : ""}`);
1090
+ console.log(` M ${r.where === "repo" ? shown(repo, r.file) : shownPath(r.file)} ${r.sections.map((h) => `+ ${h}`).join(" ")}`);
1091
+ }
1092
+ if (!any) {
1093
+ if (!skill)
1094
+ console.log("· nothing to fold");
1095
+ return;
1096
+ }
1097
+ if (inRepo)
1098
+ console.log("· review with git diff, then commit");
1099
+ else
1100
+ console.log(`· ${cmd("graft skills")} publish <skill> moves a skill into the repo's .claude/skills/, to commit for your team`);
1101
+ });
1102
+ skillsCmd
1103
+ .command("publish")
1104
+ .description("Move a skill kept on this machine into the repo's .claude/skills/, ready to commit")
1105
+ .argument("<skill>", "the skill")
1106
+ .option("-C, --dir <path>", "repository root", ".")
1107
+ .action((skill, opts) => {
1108
+ const repo = resolve(opts.dir);
1109
+ const r = publish(repo, skill);
1110
+ if (!r.ok) {
1111
+ if (r.reason === "unknown")
1112
+ return noSuchSkill(skill);
1113
+ console.log(`· ${skillName(skill)} is already in the repo, in .claude/skills/${skillName(skill)}/`);
1114
+ return;
1115
+ }
1116
+ console.log(`✓ ${skillName(skill)} → ${shown(repo, r.file)}`);
1117
+ console.log("· commit it to share it: Claude Code loads it for everyone who pulls. its takeaways stay on this machine");
1118
+ });
1119
+ skillsCmd
1120
+ .command("confirm")
1121
+ .description("Say a teammate's takeaway is right, so `fold --confirmed` takes it")
1122
+ .argument("<skill>", "the skill")
1123
+ .argument("[takeaway]", "one takeaway file (default: every one waiting that someone else taught)")
1124
+ .option("--author <name>", "who is confirming (default: the first word of git config user.name)")
1125
+ .option("-C, --dir <path>", "repository root", ".")
1126
+ .action((skill, takeaway, opts) => {
1127
+ const repo = resolve(opts.dir);
1128
+ if (!getSkill(repo, skill))
1129
+ return noSuchSkill(skill);
1130
+ const who = opts.author?.trim() || noteAuthor(repo);
1131
+ const done = confirm(repo, skill, who, takeaway);
1132
+ if (done.length === 0) {
1133
+ console.log(`· nothing to confirm on ${skillName(skill)} — a takeaway needs someone other than its teacher`);
1134
+ return;
1135
+ }
1136
+ for (const t of done)
1137
+ console.log(`✓ confirmed ${t.taughtBy || "a"}'s takeaway from ${shortDate(t.date)} · ${shownPath(t.path)}`);
1138
+ console.log(`· ${cmd("graft skills")} fold ${skillName(skill)} writes it into SKILL.md`);
1139
+ });
1140
+ grouped(program.command("init"), GROUP.setup)
1141
+ .description(TRAIL
1142
+ ? "Wire trail into your agents: instruction files and MCP for each, plus hooks and a statusline for Claude Code"
1143
+ : "Wire Graft into the AI coding agents used with this repo (instruction files + MCP server; full hooks + statusline + MCP for Claude Code)")
812
1144
  .argument("[dir]", "target repo directory", ".")
813
1145
  .option("--no-build", "skip building the graph (wire files only)")
814
1146
  .option("--agents <ids...>", `only these agents (${hostIds().join(", ")}, claude)`)
@@ -821,45 +1153,124 @@ program
821
1153
  .option("--dry-run", "print every file init would touch, then exit without writing")
822
1154
  .option("-y, --yes", "skip the picker and wire every detected agent (the pre-0.8 default)")
823
1155
  .option("--no-global", "skip writes outside this repo (the ~/.codex/ config + hooks)")
824
- .option("--trail <handoff>", "attach a Trail: <brainId>:<token> (or a bare id with GRAFT_BRAIN_TOKEN set)")
825
1156
  .option("--verbose", "print every file written, the graph build's own output and the closing banner")
826
1157
  .action(async (dir, opts) => {
827
1158
  await runInitCommand(dir, opts);
828
1159
  });
829
- /** `a, b and c`. */
830
- function joinAnd(items) {
831
- if (items.length <= 1)
832
- return items.join("");
833
- return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
1160
+ /** Everything on stdin, or "" when nothing is piped in. */
1161
+ async function readStdin() {
1162
+ if (process.stdin.isTTY)
1163
+ return "";
1164
+ const chunks = [];
1165
+ for await (const c of process.stdin)
1166
+ chunks.push(typeof c === "string" ? Buffer.from(c) : c);
1167
+ return Buffer.concat(chunks).toString("utf8");
1168
+ }
1169
+ async function runNoteCommand(repo, opts) {
1170
+ const body = (opts.body ?? (await readStdin())).trim();
1171
+ if (!opts.title.trim() || !body) {
1172
+ console.error(`✗ a learning needs a --title and a body, on stdin or with -m:`);
1173
+ console.error(` ${cmd("graft note")} --title "What it was about" <<'EOF'`);
1174
+ console.error(" ## Decided\n …\n ## Tried and ruled out\n …\n ## Watch out\n …\n EOF");
1175
+ process.exitCode = 1;
1176
+ return;
1177
+ }
1178
+ const wholeNumber = (raw, flag) => {
1179
+ if (raw === undefined)
1180
+ return undefined;
1181
+ const n = Number(raw);
1182
+ if (!Number.isFinite(n) || n < 0) {
1183
+ console.error(`✗ ${flag} takes a number, got "${raw}"`);
1184
+ process.exit(1);
1185
+ }
1186
+ return Math.round(n);
1187
+ };
1188
+ const minutes = wholeNumber(opts.minutes, "--minutes");
1189
+ const tokens = wholeNumber(opts.tokens, "--tokens");
1190
+ // What the session spent, from the agent's own transcript when there is one;
1191
+ // the flags win, since only the agent knows where its task began.
1192
+ const spent = currentSessionCost(repo);
1193
+ const cost = minutes !== undefined || tokens !== undefined || spent
1194
+ ? { minutes: minutes ?? spent?.minutes, tokens: tokens ?? spent?.tokens }
1195
+ : undefined;
1196
+ // Which repos it goes to: the one it was saved from, plus any other the
1197
+ // session edited. Explicit --touches means just this repo.
1198
+ const targets = opts.touches
1199
+ ? [{ dir: repo, touches: opts.touches.split(",").map((t) => t.trim()).filter(Boolean) }]
1200
+ : noteTargets(repo, spent?.edited ?? [], spent?.startedAt).map((t) => ({
1201
+ ...t,
1202
+ // And the files the note names, for a session that read code without editing it.
1203
+ touches: [...new Set([...t.touches, ...mentionedFiles(t.dir, body)])].slice(0, 10),
1204
+ }));
1205
+ const author = opts.author?.trim() || noteAuthor(repo);
1206
+ const date = new Date().toISOString().slice(0, 10);
1207
+ const scope = opts.personal ? "personal" : "repo";
1208
+ const earlier = [];
1209
+ const saved = [];
1210
+ for (const t of targets) {
1211
+ earlier.push(...listNotes(t.dir));
1212
+ saved.push(writeNote(t.dir, { title: opts.title, body, author, date, branch: currentBranch(t.dir), cost, touches: t.touches }, scope));
1213
+ }
1214
+ const touches = targets.flatMap((t) => t.touches);
1215
+ track("note_saved", { has_cost: String(cost?.tokens !== undefined || cost?.minutes !== undefined), touches_bucket: countBucket(touches.length), scope: saved[0]?.scope ?? scope }, { repo });
1216
+ if (opts.json) {
1217
+ console.log(JSON.stringify(saved.length === 1 ? saved[0] : saved, null, 2));
1218
+ return;
1219
+ }
1220
+ for (const n of saved)
1221
+ console.log(`✓ ${n.scope === "personal" ? "saved just for you" : "saved for your team"} · ${notePlace(n)}`);
1222
+ const note = saved[0];
1223
+ const took = costLabel(note.cost);
1224
+ if (took) {
1225
+ // Name a code file if the note touches one; docs are rarely what it's about.
1226
+ const about = touches.find((t) => !/\.(md|mdx|txt)$/i.test(t.split("#")[0])) ?? touches[0];
1227
+ const first = about ? ` that touches ${basename(about.split("#")[0])}` : "";
1228
+ const reading = tokensLabel(readingTokens(note)).replace(" tokens", "");
1229
+ console.log(`· this ${took.replace(" to work out", " to figure out")}. the next session${first} gets it for ${reading}`);
1230
+ }
1231
+ // Learnings about the same files, from someone else: this one builds on theirs.
1232
+ const files = new Set(touches.map((t) => t.split("#")[0]));
1233
+ const prior = earlier.find((n) => n.author !== note.author && n.touches.some((t) => files.has(t.split("#")[0])));
1234
+ if (prior)
1235
+ console.log(`· builds on ${prior.author ? `${prior.author}'s` : "a"} learning from ${shortDate(prior.date)}`);
1236
+ if (!SECTIONS.some((s) => sectionLead(body, s.heading) !== null))
1237
+ console.log("· tip: learnings read best under ## Decided, ## Tried and ruled out and ## Watch out");
1238
+ const repoSaved = saved.filter((n) => n.scope === "repo");
1239
+ if (repoSaved.length) {
1240
+ // Who else works on these files, for the case for committing it.
1241
+ const lines = commitLines(note, teammatesOn(repo, [...files].filter((f) => !!f)));
1242
+ console.log(`${lines.uncommitted} · git add ${repoSaved.map(notePlace).join(" ")}`);
1243
+ if (!process.stdout.isTTY && TRAIL)
1244
+ console.log(`[trail] Close your reply with one line, word for word. If this learning is committed by the end of your turn: ${lines.committed}\n` +
1245
+ ` If it isn't: ${lines.uncommitted}`);
1246
+ }
1247
+ else {
1248
+ console.log("· just for you: kept on this machine, out of the repo");
1249
+ if (!process.stdout.isTTY && TRAIL)
1250
+ console.log(`[trail] Close your reply with this line, word for word: ${PERSONAL_SAVED_LINE}`);
1251
+ }
1252
+ for (const t of targets) {
1253
+ rememberRepo(t.dir);
1254
+ maybeSyncInBackground(t.dir);
1255
+ }
1256
+ printLines(cloudLines(repo, { command: "note" }));
1257
+ }
1258
+ /** Lines that close a command's output, with a blank line before them. */
1259
+ function printLines(lines) {
1260
+ if (lines.length)
1261
+ console.log(`\n${lines.join("\n")}`);
834
1262
  }
835
1263
  /** Repo-relative with forward slashes, for printing. */
836
1264
  function shown(repo, path) {
837
1265
  return relative(repo, path).split("\\").join("/");
838
1266
  }
839
- /**
840
- * `graft init`. Also run by `graft trail push` on a repo graft has not been set
841
- * up in — the Trail pages start people at push, and a trail whose rules no
842
- * coding agent reads is only half set up. `epilogue: false` leaves out the
843
- * closing banner when push is going to keep talking.
844
- */
845
- async function runInitCommand(dir, opts, how = {}) {
1267
+ /** `graft init`. */
1268
+ async function runInitCommand(dir, opts) {
846
1269
  if (opts.listAgents) {
847
1270
  for (const id of [...hostIds(), "claude"])
848
1271
  console.log(id);
849
1272
  return null;
850
1273
  }
851
- // Parsed before anything is written: a mistyped handoff should cost the user
852
- // an error, not a half-wired repo they have to `graft uninstall` out of.
853
- let brainLink;
854
- if (opts.trail) {
855
- const parsed = parseBrainArg(opts.trail);
856
- if ("error" in parsed) {
857
- console.error(`✗ --trail: ${parsed.error}`);
858
- process.exitCode = 1;
859
- return null;
860
- }
861
- brainLink = parsed;
862
- }
863
1274
  const repo = resolve(dir);
864
1275
  const explicit = Array.isArray(opts.agents) ? opts.agents : undefined;
865
1276
  if (explicit) {
@@ -896,8 +1307,6 @@ async function runInitCommand(dir, opts, how = {}) {
896
1307
  const reason = offReason();
897
1308
  const picked = await runPicker(plan, repo, home, {
898
1309
  offerTelemetry: reason === null || reason === "disabled",
899
- // From push, the line above the picker already asks the question.
900
- ...(how.push ? { title: null } : {}),
901
1310
  });
902
1311
  if (picked === null) {
903
1312
  console.error("· cancelled — nothing written");
@@ -937,11 +1346,28 @@ async function runInitCommand(dir, opts, how = {}) {
937
1346
  }
938
1347
  const wantClaude = ids.includes("claude");
939
1348
  const cliPath = fileURLToPath(import.meta.url);
1349
+ // Under trail, with Claude Code picked: what the person kept telling Claude
1350
+ // in this repo's past sessions, read while the rest of init runs and saved
1351
+ // as learnings at the end. Once per repo, and never asked about first.
1352
+ const pastRead = TRAIL && wantClaude ? beginPastRead(repo) : null;
1353
+ if (pastRead)
1354
+ console.error(`· reading your ${pastRead.sessions} past Claude sessions here for what you keep telling Claude`);
940
1355
  if (children.length)
941
1356
  console.error(`· workspace: wiring ${repo} and ${children.length} child repo(s) — ${children.join(", ")}`);
942
1357
  // Today's output, file by file, behind --verbose. Everything else gets one
943
1358
  // line per step: the graph, then one per agent.
944
1359
  const verbose = opts.verbose === true;
1360
+ // Under trail, each repo gets its `.trail/learnings/`, committed with the
1361
+ // code, and its folder in ~/.trail for personal learnings and corrections.
1362
+ const notesHome = new Map();
1363
+ if (TRAIL)
1364
+ for (const target of targets) {
1365
+ const { place } = ensureRepoHome(target);
1366
+ const created = place.git ? ensureRepoTrail(place.checkout) : false;
1367
+ notesHome.set(target, { created, dir: place.git ? place.checkout : place.dir });
1368
+ if (place.git)
1369
+ maybeRefreshBranches(place.checkout);
1370
+ }
945
1371
  const reports = [];
946
1372
  for (const target of targets) {
947
1373
  if (verbose && target !== repo)
@@ -951,7 +1377,6 @@ async function runInitCommand(dir, opts, how = {}) {
951
1377
  // The graph, built once after the wiring: a workspace parent's build is
952
1378
  // the workspace build, so it covers every child.
953
1379
  let graphLine = null;
954
- let graphNodes = 0;
955
1380
  if (!verbose) {
956
1381
  const res = await buildGraphWithProgress(repo, { build: opts.build, cliPath });
957
1382
  for (const m of res.messages)
@@ -959,24 +1384,22 @@ async function runInitCommand(dir, opts, how = {}) {
959
1384
  const existing = res.built ? null : loadGraphCached(contextDirFor(repo, children.length ? undefined : program.opts().dir));
960
1385
  const fmt = (x) => x.toLocaleString("en-US");
961
1386
  if (res.graph) {
962
- graphNodes = res.graph.nodes;
963
1387
  graphLine = `✓ graph built · ${fmt(res.graph.nodes)} nodes, ${fmt(res.graph.edges)} edges${res.graph.files ? ` from ${fmt(res.graph.files)} file${res.graph.files === 1 ? "" : "s"}` : ""}`;
964
1388
  }
965
1389
  else if (res.built) {
966
1390
  graphLine = "✓ graph built";
967
1391
  }
968
1392
  else if (res.failed) {
969
- graphLine = "⚠ the graph build failed — run graft build to see why";
1393
+ graphLine = `⚠ the graph build failed — run ${cmd("graft build")} to see why`;
970
1394
  }
971
1395
  else if (existing) {
972
- graphNodes = existing.meta.nodeCount;
973
1396
  graphLine = `✓ graph ready · ${fmt(existing.meta.nodeCount)} nodes, ${fmt(existing.meta.edgeCount)} edges`;
974
1397
  }
975
1398
  else if (hasGraftIndex(repo)) {
976
1399
  graphLine = "✓ graph ready";
977
1400
  }
978
1401
  else {
979
- graphLine = "· skipped the graph build — run graft build";
1402
+ graphLine = `· skipped the graph build — run ${cmd("graft build")}`;
980
1403
  }
981
1404
  }
982
1405
  if (!verbose) {
@@ -987,50 +1410,46 @@ async function runInitCommand(dir, opts, how = {}) {
987
1410
  for (const w of reports.flatMap((r) => r.warnings))
988
1411
  console.error(w);
989
1412
  const writesFor = (id) => (plan.find((p) => p.id === id)?.writes ?? []).filter((w) => opts.global !== false || w.scope !== "global");
990
- if (how.push) {
991
- const built = graphLine?.startsWith("✓ graph built") ? `graph built, ${graphNodes.toLocaleString("en-US")} nodes` : graphLine?.startsWith("✓") ? "graph ready" : null;
992
- console.error(`✓ wired ${joinAnd(ids)}${built ? ` · ${built}` : ""}`);
993
- if (graphLine && !graphLine.startsWith("✓"))
994
- console.error(graphLine);
995
- }
996
- else {
997
- if (graphLine)
998
- console.error(graphLine);
999
- const width = Math.max(...ids.map((id) => id.length));
1000
- for (const id of ids) {
1001
- const summary = compactWrites(writesFor(id), repo, home);
1002
- console.error(`✓ ${id.padEnd(width)} ${summary}`.trimEnd());
1003
- }
1413
+ if (graphLine)
1414
+ console.error(graphLine);
1415
+ const width = Math.max(...ids.map((id) => id.length));
1416
+ for (const id of ids) {
1417
+ const summary = compactWrites(writesFor(id), repo, home);
1418
+ console.error(`✓ ${id.padEnd(width)} ${summary}`.trimEnd());
1004
1419
  }
1005
1420
  }
1006
- // The brain comes last, after the graph exists: its rules are anchored to
1007
- // symbols, and `graft trail status` can only report how many of them resolve
1008
- // once there is a graph to resolve them against.
1009
- if (brainLink) {
1010
- const res = await connectBrain(repo, brainLink, { home, ids });
1011
- if (verbose) {
1012
- if (res.warning)
1013
- console.error(`⚠ brain: ${res.warning}`);
1014
- else
1015
- console.error(`✓ brain: pulled ${res.ruleCount} rule(s) from ${brainLink.brainId}`);
1016
- for (const w of res.writes)
1017
- console.error(`✓ brain rules: ${w.path} (${w.action})`);
1018
- if (res.ruleCount > 0 && res.writes.length === 0)
1019
- console.error("· no instruction file to write rules into — graft ask still carries them");
1020
- }
1021
- else if (res.warning) {
1022
- console.error(`⚠ trail: ${res.warning}`);
1421
+ // Under trail: what init learned from past sessions, where this repo's
1422
+ // learnings are, and how many there already are.
1423
+ let learnedNow = false;
1424
+ if (TRAIL && pastRead) {
1425
+ if (!pastRead.done)
1426
+ console.error("· still reading your past sessions…");
1427
+ const { result, saved } = await finishPastRead(repo, pastRead);
1428
+ learnedNow = saved.length > 0;
1429
+ if (saved.length) {
1430
+ const n = saved.length;
1431
+ console.error(`✓ learned ${n} thing${n === 1 ? "" : "s"} you told Claude more than once, from your last ${result.read} sessions here`);
1432
+ for (const note of saved)
1433
+ console.error(` ${note.scope === "personal" ? "just for you " : "for your team"} · ${note.title}`);
1434
+ console.error("· Claude follows them from your next session");
1435
+ if (saved.some((s) => s.scope === "repo"))
1436
+ console.error(commitLines({}, teammatesOn(repo), "repo").uncommitted);
1023
1437
  }
1024
- else if (res.ruleCount === 0) {
1025
- console.error("✓ trail connected · no rules yet — graft trail push reads this repo into it");
1438
+ else if (result.reason === "failed") {
1439
+ console.error(`· couldn't read your past sessions this time; ${cmd("graft init")} again tries again`);
1026
1440
  }
1027
- else {
1028
- const where = res.writes.map((w) => shown(repo, w.path)).join(", ");
1029
- const n = `${res.ruleCount.toLocaleString("en-US")} rule${res.ruleCount === 1 ? "" : "s"}`;
1030
- console.error(`✓ trail connected · ${n}${where ? ` in ${where}` : ""}`);
1441
+ }
1442
+ if (TRAIL && !learnedNow) {
1443
+ const home = notesHome.get(repo);
1444
+ if (home) {
1445
+ const learnings = listNotes(repo, { branches: false }).filter((n) => n.scope === "repo");
1446
+ if (learnings.length === 0)
1447
+ console.error(`✓ learnings ${LEARNINGS_DIR}/ · sessions that take real digging leave one there, for your whole team`);
1448
+ else
1449
+ console.error(`✓ learnings ${learnings.length} in ${LEARNINGS_DIR}/, from ${new Set(learnings.map((n) => n.author || "someone")).size} ${new Set(learnings.map((n) => n.author)).size === 1 ? "person" : "people"}`);
1031
1450
  }
1032
1451
  }
1033
- if (!verbose && how.epilogue !== false) {
1452
+ if (!verbose) {
1034
1453
  // What there is to commit: the top-level entries the picked agents wrote.
1035
1454
  const tops = [];
1036
1455
  for (const w of selectedWrites(plan, ids)) {
@@ -1041,9 +1460,18 @@ async function runInitCommand(dir, opts, how = {}) {
1041
1460
  if (!tops.includes(top))
1042
1461
  tops.push(top);
1043
1462
  }
1044
- console.error("· restart your agents so a new session picks up graft");
1463
+ console.error(`· restart your agents so a new session picks up ${brand()}`);
1464
+ if (TRAIL && !tops.includes(".trail/"))
1465
+ tops.push(".trail/");
1466
+ // Only what git sees as new or changed: a teammate's init over committed files has nothing to commit.
1467
+ const changedTops = tops.filter((t) => {
1468
+ const r = spawnSync("git", ["status", "--porcelain", "--", t], { cwd: repo, encoding: "utf8" });
1469
+ return r.status !== 0 || r.stdout.trim().length > 0;
1470
+ });
1471
+ tops.splice(0, tops.length, ...changedTops);
1045
1472
  if (tops.length)
1046
- console.error(`· commit ${tops.slice(0, 4).join(" ")}${tops.length > 4 ? ` +${tops.length - 4} more` : ""} to share it — graft/ stays local and git-ignored`);
1473
+ console.error(`· commit ${tops.slice(0, 5).join(" ")}${tops.length > 5 ? ` +${tops.length - 5} more` : ""} to share it — ` +
1474
+ (TRAIL ? "teammates' Claude reads the learnings even without trail installed; the code map in graft/ stays on this machine" : "graft/ stays local and git-ignored"));
1047
1475
  }
1048
1476
  // One epilogue for the whole run. A workspace parent holds no nodes of its
1049
1477
  // own, so the totals come from the children — the graph the user actually got.
@@ -1051,7 +1479,7 @@ async function runInitCommand(dir, opts, how = {}) {
1051
1479
  const graphs = (children.length ? children.map((c) => join(repo, c)) : [repo])
1052
1480
  .map((d) => loadGraphCached(contextDirFor(d, children.length ? undefined : globalDir)))
1053
1481
  .filter((g) => g !== null);
1054
- if (verbose && how.epilogue !== false)
1482
+ if (verbose)
1055
1483
  console.error("\n" +
1056
1484
  formatInitEpilogue({
1057
1485
  graphBuilt: graphs.length > 0,
@@ -1090,7 +1518,7 @@ function wireTarget(repo, ids, ctx) {
1090
1518
  // `global`/`home` are threaded through alongside `statusline`: the claude layer
1091
1519
  // writes under `~/.claude` now (hosts/claude-global.ts), so --no-global has to
1092
1520
  // reach it or the flag would silently mean "no out-of-repo writes, except three".
1093
- const res = runInit(repo, { build: opts.build, cliPath, statusline: wantStatusline, global: opts.global, home });
1521
+ const res = runInit(repo, { build: opts.build, cliPath, statusline: wantStatusline, global: opts.global, home, brand: brand() });
1094
1522
  say(`✓ wrote ${res.settingsPath}`);
1095
1523
  for (const s of res.shims)
1096
1524
  say(`✓ wrote ${s}`);
@@ -1101,7 +1529,7 @@ function wireTarget(repo, ids, ctx) {
1101
1529
  say(`· mcp claude: ${res.mcp.path} (already registered)`);
1102
1530
  else
1103
1531
  say(`✓ mcp claude: ${res.mcp.path} (${res.mcp.action}) — restart Claude Code to load the graft MCP server`);
1104
- say(res.built ? "✓ built the graph (graft build)" : "· skipped graph build");
1532
+ say(res.built ? `✓ built the graph (${cmd("graft build")})` : "· skipped graph build");
1105
1533
  if (!wantStatusline)
1106
1534
  say("· skipped Claude Code statusLine (--no-statusline)");
1107
1535
  for (const w of res.warnings)
@@ -1117,6 +1545,7 @@ function wireTarget(repo, ids, ctx) {
1117
1545
  mcp: opts.mcp,
1118
1546
  hooks: opts.hooks,
1119
1547
  global: opts.global,
1548
+ brand: brand(),
1120
1549
  });
1121
1550
  for (const w of r.written)
1122
1551
  say(`✓ ${w.id}: ${w.path} (${w.action})`);
@@ -1144,7 +1573,7 @@ function wireTarget(repo, ids, ctx) {
1144
1573
  if (!wantClaude) {
1145
1574
  if (!quiet)
1146
1575
  say(buildGraphIfMissing(repo, { build: opts.build, cliPath })
1147
- ? "✓ built the graph (graft build)"
1576
+ ? `✓ built the graph (${cmd("graft build")})`
1148
1577
  : "· skipped graph build");
1149
1578
  }
1150
1579
  return report;
@@ -1181,9 +1610,8 @@ function formatRetractions(rs, apply) {
1181
1610
  }
1182
1611
  return lines.join("\n").replace(/^\n/, "");
1183
1612
  }
1184
- program
1185
- .command("uninstall")
1186
- .description("Remove every file and config entry graft has written to this repo (the inverse of init)")
1613
+ grouped(program.command("uninstall"), GROUP.setup)
1614
+ .description(`Remove every file and config entry ${TRAIL ? "trail or graft" : "graft"} has written to this repo (the inverse of init)`)
1187
1615
  .argument("[dir]", "target repo directory", ".")
1188
1616
  .option("-y, --yes", "actually remove (without this, prints what it would remove and exits)")
1189
1617
  .option("--keep-cache", "keep graft/ and the .gitignore entries — wiring only")
@@ -1204,433 +1632,485 @@ program
1204
1632
  const bad = changed(done).filter((r) => r.action === "skipped-unparseable");
1205
1633
  console.error(bad.length
1206
1634
  ? `\n⚠ ${bad.length} file(s) could not be parsed and were left as-is — see above.`
1207
- : "\n✓ graft fully removed. `graft init` re-wires from scratch.");
1635
+ : `\n✓ ${brand()} fully removed. \`${cmd("graft init")}\` re-wires from scratch.`);
1208
1636
  });
1209
- // `graft trail …` still works: see legacy-args.ts.
1210
- const brain = program
1211
- .command("trail")
1212
- .description("The Trail attached to this repo: the rules mined from its own history");
1637
+ /* -------------------------------------------------------------------------- */
1638
+ /* trail login · logout · workspace */
1639
+ /* -------------------------------------------------------------------------- */
1213
1640
  /**
1214
- * Get this repo a trail from the terminal, by sending the user through signup
1215
- * in their browser and catching the handoff on loopback.
1216
- *
1217
- * Returns the link, already saved, or null when the user should be left alone —
1218
- * every failure prints its own reason first, because the caller only needs to
1219
- * know whether to carry on. Nothing about the repository has been read or sent
1220
- * by then, and every message says so.
1641
+ * `trail login`: connect this machine to a Trail workspace by hand. Normally
1642
+ * nobody runs it: Claude offers the same link once there's a learning to
1643
+ * share. Prints the link, opens it, and waits for the person to sign in and
1644
+ * pick repos, then uploads those repos' learnings.
1221
1645
  */
1222
- async function signUpForBrain(repo, slug) {
1223
- // No terminal means an agent is running this, and a loopback listener cannot
1224
- // outlive the command it is waiting in: see signUpWithoutTerminal.
1225
- if (!process.stderr.isTTY)
1226
- return signUpWithoutTerminal(repo, slug);
1227
- const handoff = await startHandoff();
1228
- const url = signupUrl({ repo: slug, port: handoff.port, state: handoff.state });
1229
- const startedAt = Date.now();
1230
- // Queued before the link is printed rather than after the outcome, because
1231
- // the outcome is the one thing a terminal handoff can lose: a user who reads
1232
- // the URL and walks away kills the process, and only an event already on disk
1233
- // survives that. This is the denominator; `brain_signup_settled` is not.
1234
- track("brain_signup_opened", { mode: "terminal" }, { repo });
1235
- // Printed before the browser opens, and printed whether or not it opens: on a
1236
- // remote shell nothing can open, and on a desktop the window sometimes lands
1237
- // behind the terminal. The URL is the thing that always works.
1238
- console.error(`· ${slug} has no trail yet — opening your browser to make one:`);
1239
- console.error(` ${url}`);
1240
- openBrowser(url);
1241
- const spinner = startSpinner("waiting for you to finish signing up · Ctrl-C to stop");
1242
- // Ctrl-C while waiting is a person changing their mind, not a crash: say that
1243
- // nothing was sent, and leave with the usual interrupted exit code.
1244
- let onSigint;
1245
- const stopped = new Promise((resolve) => {
1246
- onSigint = () => resolve("stopped");
1247
- process.once("SIGINT", onSigint);
1646
+ function loginCommand(name = "login") {
1647
+ return new Command(name)
1648
+ .description("Connect this machine to a Trail workspace: sign in, pick the repos it gets, invite your team")
1649
+ .argument("[dir]", "the repo you're in, ticked first in the picker", ".")
1650
+ .option("--server <url>", "a self-hosted Trail, e.g. https://trail.acme.internal (default: Trail cloud)")
1651
+ .action(async (dir, opts) => {
1652
+ const repo = resolve(dir);
1653
+ const server = opts.server?.trim().replace(/\/+$/, "");
1654
+ if (server) {
1655
+ if (!/^https?:\/\//.test(server)) {
1656
+ console.error(`✗ --server takes a URL, like https://trail.acme.internal`);
1657
+ process.exitCode = 1;
1658
+ return;
1659
+ }
1660
+ process.env.TRAIL_URL = server;
1661
+ }
1662
+ const cloud = readCloud();
1663
+ if (cloud) {
1664
+ console.error(`✓ already connected to workspace ${cloud.workspace.name} · ${cloud.workspace.url}`);
1665
+ return;
1666
+ }
1667
+ rememberRepo(repo);
1668
+ const url = loginLink(repo);
1669
+ console.error(`· sign in and pick the repos your workspace gets:\n ${url}`);
1670
+ if (process.stdout.isTTY)
1671
+ openBrowser(url);
1672
+ console.error("· waiting for you to finish in the browser (ctrl-c stops; Claude picks it up later too)");
1673
+ const got = await waitForConnect({ waitMs: 10 * 60 * 1000 });
1674
+ if (got && "cloud" in got)
1675
+ printLines(cloudLines(repo, { command: "note", agent: false }));
1676
+ else if (got && "error" in got) {
1677
+ console.error(`✗ ${got.error} · run ${cmd("graft login")} again for a new link`);
1678
+ process.exitCode = 1;
1679
+ }
1680
+ else
1681
+ console.error(`· not connected yet · the link stays good for a day`);
1248
1682
  });
1249
- const got = await Promise.race([handoff.wait(), stopped]);
1250
- if (onSigint)
1251
- process.off("SIGINT", onSigint);
1252
- spinner.stop();
1253
- if (got === "stopped") {
1254
- handoff.close();
1255
- console.error("· stopped — nothing was sent; run graft trail push again when you're ready");
1256
- track("brain_signup_settled", { outcome: "stopped", mode: "terminal", duration_bucket: durationBucket(Date.now() - startedAt) }, { repo });
1257
- process.exitCode = 130;
1258
- return null;
1259
- }
1260
- if ("error" in got) {
1261
- console.error(`✗ ${got.error}`);
1262
- // The category, never the sentence: `got.error` names the repo and the link.
1263
- track("brain_signup_settled", { outcome: got.reason, mode: "terminal", duration_bucket: durationBucket(Date.now() - startedAt) }, { repo });
1264
- return null;
1265
- }
1266
- writeLink(repo, got.link);
1267
- console.error(`✓ trail connected · ${slug} — your browser shows it building`);
1268
- track("brain_signup_settled", { outcome: "linked", mode: "terminal", duration_bucket: durationBucket(Date.now() - startedAt) }, { repo });
1269
- return got.link;
1270
1683
  }
1271
- /**
1272
- * The same sign-up when an agent runs the push, in two runs of one command.
1273
- *
1274
- * An agent shows the person nothing until a command exits, so the first run
1275
- * opens the sign-up page, saves the state in its link and exits at once, with
1276
- * a line telling the agent what to do next. The run after that finds the saved
1277
- * state and asks Trail for the trail it made, for up to AGENT_WAIT_MS, then
1278
- * carries on with the push. Still not signed up by then, it says so and the
1279
- * agent runs it again. Nothing about the repository is read or sent until the
1280
- * trail is linked.
1281
- */
1282
- async function signUpWithoutTerminal(repo, slug) {
1283
- const startedAt = Date.now();
1284
- const settled = (outcome) => track("brain_signup_settled", { outcome, mode: "agent", duration_bucket: durationBucket(Date.now() - startedAt) }, { repo });
1285
- const pending = readPendingSignup(repo, slug, PENDING_SIGNUP_TTL_MS);
1286
- if (!pending) {
1287
- const state = newSignupState();
1288
- writePendingSignup(repo, { state, repo: slug, createdAt: Date.now() });
1289
- const url = signupUrl({ repo: slug, state });
1290
- track("brain_signup_opened", { mode: "agent" }, { repo });
1291
- openBrowser(url);
1292
- console.error(`· ${slug} has no trail yet — opened Trail's sign-up page in the browser:`);
1293
- console.error(` ${url}`);
1294
- console.error("· nothing has been read or sent yet.");
1295
- console.error("· next: ask the user to sign up in the tab that just opened (or at the link above), then run `graft trail push` again straight away — it waits for the sign-up to finish and carries on with the push. Don't wait for them to reply first.");
1296
- settled("agent_link_opened");
1297
- process.exitCode = 1;
1298
- return null;
1299
- }
1300
- const url = signupUrl({ repo: slug, state: pending.state });
1301
- console.error(`· waiting up to ${Math.round(AGENT_WAIT_MS / 1000)} s for the sign-up for ${slug} to finish:`);
1302
- console.error(` ${url}`);
1303
- const got = await waitForSignup(pending.state, apiBaseUrl());
1304
- if ("link" in got) {
1305
- clearPendingSignup(repo);
1306
- writeLink(repo, got.link);
1307
- console.error(`✓ trail connected · ${slug}`);
1308
- settled("linked");
1309
- return got.link;
1310
- }
1311
- if ("pending" in got) {
1312
- console.error("· not signed up yet — nothing has been read or sent.");
1313
- console.error("· next: run `graft trail push` again to keep waiting. The link above stays valid for about 15 minutes.");
1314
- settled("still_waiting");
1315
- process.exitCode = 1;
1316
- return null;
1317
- }
1318
- clearPendingSignup(repo);
1319
- console.error(`✗ ${got.error}`);
1320
- settled(got.reason);
1321
- process.exitCode = 1;
1322
- return null;
1684
+ function logoutCommand(name = "logout") {
1685
+ return new Command(name)
1686
+ .description("Disconnect this machine from its Trail workspace (learnings stay in your repos)")
1687
+ .action(() => {
1688
+ const cloud = readCloud();
1689
+ clearPending();
1690
+ if (!clearCloud()) {
1691
+ console.error("· not connected to a workspace");
1692
+ return;
1693
+ }
1694
+ console.error(`✓ disconnected from workspace ${cloud?.workspace.name ?? ""} · learnings stay in your repos' .trail/`.replace(" ", " "));
1695
+ });
1323
1696
  }
1324
- brain
1325
- .command("connect")
1326
- .description("Attach a brain to this repo and pull its rules")
1327
- .argument("<handoff>", "<brainId>:<token>, or a bare brain id with GRAFT_BRAIN_TOKEN set")
1328
- .argument("[dir]", "target repo directory", ".")
1329
- .action(async (handoff, dir) => {
1330
- const parsed = parseBrainArg(handoff);
1331
- if ("error" in parsed) {
1332
- console.error(`✗ ${parsed.error}`);
1333
- process.exitCode = 1;
1334
- return;
1335
- }
1336
- const repo = resolve(dir);
1337
- const res = await connectBrain(repo, parsed, { home: homedir() });
1338
- if (res.warning) {
1339
- console.error(`⚠ ${res.warning}`);
1340
- return;
1341
- }
1342
- if (res.ruleCount === 0) {
1343
- // The normal case on the local route: the brain exists but nothing has
1344
- // read the repo yet. Saying "0 rules" without saying why reads as a
1345
- // failure, and the next step is the whole point.
1346
- console.error("✓ attached this repo to the brain — it has no rules yet");
1347
- console.error("· run `graft trail push` to read this repository into it");
1348
- return;
1349
- }
1350
- console.error(`✓ pulled ${res.ruleCount} rule(s) from ${parsed.brainId}`);
1351
- for (const w of res.writes)
1352
- console.error(`✓ ${w.path} (${w.action})`);
1353
- });
1354
- brain
1355
- .command("pull")
1356
- .description("Refresh the trail's rules and write every change you accepted in Trail into this repo's context files")
1357
- .argument("[dir]", "target repo directory", ".")
1358
- .option("--dry-run", "show what would change without writing anything or telling Trail")
1359
- .action(async (dir, opts) => {
1360
- await runTrailPullCommand(dir, opts);
1361
- });
1362
- /** `graft trail pull`, and `graft claude-md pull` which is now the same thing. */
1363
- async function runTrailPullCommand(dir, opts) {
1364
- const repo = resolve(dir);
1365
- const link = readLink(repo);
1366
- if (!link) {
1367
- console.error("✗ this repo has no trail yet — run graft trail push first");
1368
- process.exitCode = 1;
1369
- return;
1370
- }
1371
- const stamp = readStamp(repo);
1372
- const wired = [...new Set([...(stamp?.hosts ?? []), ...wiredHostIds(repo)])];
1373
- const code = await runTrailPull(repo, link, { home: homedir(), wired, dryRun: opts.dryRun });
1374
- if (code !== 0)
1375
- process.exitCode = code;
1697
+ /** The repo at `repo`, as the workspace names it. */
1698
+ function workspaceRepo(repo) {
1699
+ const place = repoPlace(repo);
1700
+ return { key: place.key, name: repoName(place.key, place.checkout), checkout: place.checkout, git: place.git };
1376
1701
  }
1377
- brain
1378
- .command("push")
1379
- .description("Read THIS repo on your machine and build its trail — no GitHub App, works on private repos")
1380
- .argument("[dir]", "target repo directory", ".")
1381
- .option("--no-approve", "leave the mined rules as drafts for review")
1382
- .option("--no-watch", "return as soon as the push is sent, without following the build")
1383
- .option("--verbose", "print each step's counts and every build stage as it finishes")
1384
- .action(async (dir, opts) => {
1385
- const repo = resolve(dir);
1386
- const tty = Boolean(process.stderr.isTTY);
1387
- const verbose = opts.verbose === true;
1388
- // Resolved before the link, because an unlinked repo now signs up for a
1389
- // trail and Trail creates that trail FOR a named repository. Without a slug
1390
- // there is nothing to name it after — and the digest builder would fail on
1391
- // the same missing remote a moment later regardless.
1392
- const here = repoSlugFromGit(repo);
1393
- let link = readLink(repo);
1394
- // Set when this run opened the browser, which then sits on Trail's build
1395
- // page: the closing lines point at that tab instead of printing a new link.
1396
- let browserOpen = false;
1397
- if (!link) {
1398
- if (!here) {
1399
- console.error("✗ this directory has no GitHub origin remote — graft can only push a GitHub repository today");
1702
+ function workspaceCommand(name = "workspace") {
1703
+ const ws = new Command(name)
1704
+ .description("Your Trail workspace: its repos, the team page and the invite link")
1705
+ .argument("[dir]", "repository root", ".")
1706
+ .option("--json", "output as JSON")
1707
+ .action(async (dir, opts) => {
1708
+ const repo = resolve(dir);
1709
+ const cloud = readCloud();
1710
+ if (!cloud) {
1711
+ console.log("· not connected to a workspace");
1712
+ console.log(`· Claude offers a link once this repo has learnings, or run ${cmd("graft login")}`);
1713
+ return;
1714
+ }
1715
+ const me = await fetchMe(cloud);
1716
+ if (isError(me)) {
1717
+ console.error(`✗ ${me.error}`);
1400
1718
  process.exitCode = 1;
1401
1719
  return;
1402
1720
  }
1403
- const signedUp = await signUpForBrain(repo, `${here.owner}/${here.name}`);
1404
- if (!signedUp) {
1405
- if (!process.exitCode)
1406
- process.exitCode = 1;
1721
+ // What the workspace has now, for every command that reads cloud.json.
1722
+ writeCloud({ ...cloud, workspace: me.workspace, repos: me.repos.map((r) => r.key) });
1723
+ if (opts.json) {
1724
+ console.log(JSON.stringify(me, null, 2));
1407
1725
  return;
1408
1726
  }
1409
- link = signedUp;
1410
- browserOpen = true;
1411
- }
1412
- // What the website said this trail is for. Checked BEFORE any reading, so
1413
- // standing in the wrong checkout costs a message rather than a trail full
1414
- // of another repository's rules — a mistake that is silent afterwards,
1415
- // because the rules look perfectly plausible, just not about your code.
1416
- const expected = await fetchExpectedRepo(link);
1417
- if (expected && here && !sameRepo(expected.slug, `${here.owner}/${here.name}`)) {
1418
- console.error(`✗ this trail is for ${expected.slug}, but you are in ${here.owner}/${here.name}`);
1419
- console.error(` cd into ${expected.slug} and run this again, or attach a different trail here.`);
1420
- process.exitCode = 1;
1421
- return;
1422
- }
1423
- const ctx = pushContext(repo);
1424
- if (!ctx) {
1425
- console.error("✗ this directory has no GitHub origin remote — graft can only push a GitHub repository today");
1426
- process.exitCode = 1;
1427
- return;
1727
+ console.log(`${me.workspace.name} · ${me.workspace.url}`);
1728
+ console.log(`invite ${me.workspace.invite_url}`);
1729
+ console.log("");
1730
+ const here = workspaceRepo(repo);
1731
+ const width = Math.max(...me.repos.map((r) => r.name.length), here.name.length) + 3;
1732
+ for (const r of me.repos) {
1733
+ const by = r.added_by ? ` · added by ${r.added_by}` : "";
1734
+ console.log(` ✓ ${r.name.padEnd(width)}${r.learnings} learning${r.learnings === 1 ? "" : "s"}${by}`);
1735
+ }
1736
+ if (here.git && !me.repos.some((r) => r.key === here.key)) {
1737
+ console.log(` · ${here.name.padEnd(width)}not in the workspace · ${cmd("graft workspace add")} shares its learnings`);
1738
+ }
1739
+ });
1740
+ ws.command("add")
1741
+ .description("Add this repo to your workspace and share its learnings")
1742
+ .argument("[dir]", "repository root", ".")
1743
+ .action(async (dir) => {
1744
+ const repo = resolve(dir);
1745
+ const cloud = readCloud();
1746
+ if (!cloud) {
1747
+ console.error(`✗ not connected to a workspace · run ${cmd("graft login")}`);
1748
+ process.exitCode = 1;
1749
+ return;
1750
+ }
1751
+ const here = workspaceRepo(repo);
1752
+ if (!here.git) {
1753
+ console.error("✗ not a git repository");
1754
+ process.exitCode = 1;
1755
+ return;
1756
+ }
1757
+ const added = await addRepo(cloud, { key: here.key, name: here.name });
1758
+ if (isError(added)) {
1759
+ console.error(`✗ ${added.error}`);
1760
+ process.exitCode = 1;
1761
+ return;
1762
+ }
1763
+ const next = { ...cloud, repos: added.repos.map((r) => r.key) };
1764
+ writeCloud(next);
1765
+ rememberRepo(repo);
1766
+ const res = await syncRepo(here.checkout, next);
1767
+ if (isError(res)) {
1768
+ console.error(`✓ added ${here.name} to ${cloud.workspace.name} · its learnings go up in the background (${res.error})`);
1769
+ return;
1770
+ }
1771
+ console.error(`✓ added ${here.name} to ${cloud.workspace.name} · shared ${res.learnings} learning${res.learnings === 1 ? "" : "s"}`);
1772
+ console.error(`· ${cloud.workspace.url}`);
1773
+ });
1774
+ return ws;
1775
+ }
1776
+ /* -------------------------------------------------------------------------- */
1777
+ /* trail team · trail check */
1778
+ /* -------------------------------------------------------------------------- */
1779
+ /** Fold a long line at `width`, indenting what wraps. */
1780
+ function wrap(text, width, indent) {
1781
+ const out = [];
1782
+ let line = "";
1783
+ for (const word of text.split(/\s+/)) {
1784
+ if (line && line.length + 1 + word.length > width) {
1785
+ out.push(line);
1786
+ line = indent + word;
1787
+ }
1788
+ else
1789
+ line = line ? `${line} ${word}` : word;
1428
1790
  }
1429
- const firstPush = !expected || expected.ruleCount === 0;
1430
- // The instruction files and the newest pull requests go first, when the
1431
- // trail takes them: its suggestions start from those while the rest of the
1432
- // history is still being read here. In the background, from here on — the
1433
- // picker, the graph build and the full read all happen while it goes, and
1434
- // the full read reuses every GitHub request it made.
1435
- let early = Promise.resolve();
1436
- if (expected?.earlyUpload) {
1437
- early = (async () => {
1438
- const first = await buildEarlyDigest(repo, { context: ctx });
1439
- if (!first)
1440
- return;
1441
- const ok = await pushEarlyDigest(link, first.digest, fetch, { gzip: expected.gzipUpload });
1442
- if (ok && verbose)
1443
- console.error("· sent the instruction files and the newest pull requests first, so suggestions start now");
1444
- })().catch(() => undefined);
1445
- }
1446
- // A repo graft was never set up in gets `graft init` first. Same picker as
1447
- // init: it is how someone agrees to what gets written, so a
1448
- // non-interactive push only says what to run.
1449
- if (wiredHostIds(repo).length === 0) {
1450
- if (process.stdin.isTTY && tty) {
1451
- console.error("· graft isn't set up here yet — pick the agents your team uses:");
1452
- await runInitCommand(repo, { build: true, mcp: true, hooks: true, statusline: true, global: true, verbose }, { epilogue: false, push: !verbose });
1791
+ if (line)
1792
+ out.push(line);
1793
+ return out;
1794
+ }
1795
+ function teamCommand(name = "team") {
1796
+ return new Command(name)
1797
+ .description("What the team learned this week, who's working on what, and where work overlaps")
1798
+ .argument("[dir]", "repository root", ".")
1799
+ .option("--days <n>", "how far back to look", "7")
1800
+ .option("--json", "output as JSON")
1801
+ .action(async (dir, opts) => {
1802
+ const repo = resolve(dir);
1803
+ const days = Math.max(1, Math.min(90, Number(opts.days) || 7));
1804
+ const cloud = readCloud();
1805
+ if (!cloud) {
1806
+ localTeam(repo, days, opts.json === true);
1807
+ return;
1453
1808
  }
1454
- else {
1455
- console.error("· graft isn't set up here — run graft init so your agents read these rules");
1809
+ maybeSyncInBackground(repo);
1810
+ const here = workspaceRepo(repo);
1811
+ const t = await fetchTeam(cloud, { repo: cloud.repos.includes(here.key) ? here.key : undefined, days });
1812
+ if (isError(t)) {
1813
+ console.error(`✗ ${t.error}`);
1814
+ process.exitCode = 1;
1815
+ return;
1816
+ }
1817
+ if (opts.json) {
1818
+ console.log(JSON.stringify(t, null, 2));
1819
+ return;
1820
+ }
1821
+ const period = days === 7 ? "this week" : `the last ${days} days`;
1822
+ console.log(`${period} in ${t.repo || t.workspace}`);
1823
+ console.log(`${t.people} ${t.people === 1 ? "person" : "people"} · ${t.sessions} session${t.sessions === 1 ? "" : "s"} · ` +
1824
+ `${t.new_learnings} new learning${t.new_learnings === 1 ? "" : "s"} · reused ${t.reused}×`);
1825
+ if (t.members.length) {
1826
+ console.log("");
1827
+ const nameW = Math.max(...t.members.map((m) => m.name.length)) + 3;
1828
+ const topicW = Math.min(36, Math.max(...t.members.map((m) => m.topics.join(", ").length)) + 3);
1829
+ for (const m of t.members) {
1830
+ const learnings = m.learnings ? `${m.learnings} learning${m.learnings === 1 ? "" : "s"}` : "";
1831
+ const reused = m.reused ? ` reused ${m.reused}×` : "";
1832
+ console.log(` ${m.name.padEnd(nameW)}${m.topics.join(", ").padEnd(topicW)}${learnings.padEnd(12)}${reused}`.trimEnd());
1833
+ }
1834
+ }
1835
+ const overlaps = [...t.overlaps.map((o) => o.text), ...branchOverlapTexts(repo)];
1836
+ if (overlaps.length) {
1837
+ console.log("\noverlap");
1838
+ for (const o of overlaps)
1839
+ for (const [i, l] of wrap(o, 66, " ").entries())
1840
+ console.log(i === 0 ? ` ● ${l}` : l);
1841
+ }
1842
+ if (t.url)
1843
+ console.log(`\n team page: ${t.url}`);
1844
+ });
1845
+ }
1846
+ /** Pairs of teammates' pushed branches that change the same files, from this clone's branch cache. */
1847
+ function branchOverlapTexts(repo, now = Date.now()) {
1848
+ const branches = readBranchCache(repo)?.branches ?? [];
1849
+ const out = [];
1850
+ for (let i = 0; i < branches.length; i++) {
1851
+ for (let j = i + 1; j < branches.length; j++) {
1852
+ const a = branches[i];
1853
+ const b = branches[j];
1854
+ if (!a.author || !b.author || a.author === b.author)
1855
+ continue;
1856
+ const shared = a.files.filter((f) => !f.startsWith(".trail/") && b.files.includes(f));
1857
+ if (!shared.length)
1858
+ continue;
1859
+ const files = shared.length <= 2 ? shared.join(" and ") : `${shared.slice(0, 2).join(", ")} and ${shared.length - 2} more`;
1860
+ out.push(`${a.author}'s ${a.ref.replace(/^origin\//, "")} (pushed ${pushedWhen(a.at, now)}) and ${b.author}'s ${b.ref.replace(/^origin\//, "")} (pushed ${pushedWhen(b.at, now)}) both change ${files}`);
1861
+ if (out.length >= 5)
1862
+ return out;
1456
1863
  }
1457
1864
  }
1458
- // The graph is what symbol anchors are resolved against, so a rule mined
1459
- // here can later go stale on its own. Without one the ingest still works;
1460
- // its rules simply govern the repo rather than a symbol in it.
1461
- const graph = loadGraphCached(contextDirFor(repo, program.opts().dir));
1462
- if (!graph)
1463
- console.error("· no graph yet — run graft build so rules can be anchored to symbols");
1464
- const reading = startSpinner(`reading ${ctx.owner}/${ctx.name} · commits, pull-request discussion and the docs in the tree`);
1465
- const built = await buildLocalDigest(repo, graph, { autoApprove: opts.approve !== false, context: ctx });
1466
- if ("error" in built) {
1467
- reading.stop();
1468
- console.error(`✗ ${built.error}`);
1469
- process.exitCode = 1;
1865
+ return out;
1866
+ }
1867
+ /**
1868
+ * `trail team` before this machine is connected: what this clone can see on
1869
+ * its own. Who wrote which learnings lately, and where teammates' pushed
1870
+ * branches overlap. Reuse and the team page need the workspace.
1871
+ */
1872
+ function localTeam(repo, days, json) {
1873
+ maybeRefreshBranches(repo);
1874
+ const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
1875
+ const recent = listNotes(repo).filter((n) => n.scope === "repo" && n.date >= since);
1876
+ const byAuthor = new Map();
1877
+ for (const n of recent)
1878
+ byAuthor.set(n.author || "someone", [...(byAuthor.get(n.author || "someone") ?? []), n]);
1879
+ const overlaps = branchOverlapTexts(repo);
1880
+ if (json) {
1881
+ console.log(JSON.stringify({ days, learnings: recent.length, members: [...byAuthor].map(([name, ns]) => ({ name, learnings: ns.length, titles: ns.map((n) => n.title) })), overlaps }, null, 2));
1470
1882
  return;
1471
1883
  }
1472
- const d = built.digest;
1473
- reading.update("sending it to Trail");
1474
- await early;
1475
- const sent = await pushDigest(link, d, fetch, uploadCaps(expected));
1476
- reading.stop();
1477
- if (built.warning)
1478
- console.error(`⚠ ${built.warning}`);
1479
- if ("error" in sent) {
1480
- console.error(`✗ ${sent.error}`);
1481
- process.exitCode = 1;
1482
- return;
1884
+ const here = workspaceRepo(repo);
1885
+ console.log(`${days === 7 ? "this week" : `the last ${days} days`} in ${here.name} · from this clone`);
1886
+ console.log(`${byAuthor.size} ${byAuthor.size === 1 ? "person" : "people"} · ${recent.length} new learning${recent.length === 1 ? "" : "s"}`);
1887
+ if (byAuthor.size) {
1888
+ console.log("");
1889
+ const nameW = Math.max(...[...byAuthor.keys()].map((k) => k.length)) + 3;
1890
+ for (const [name, ns] of byAuthor) {
1891
+ const latest = ns[0].title.length > 40 ? `${ns[0].title.slice(0, 39)}…` : ns[0].title;
1892
+ console.log(` ${name.padEnd(nameW)}${latest.padEnd(42)}${ns.length} learning${ns.length === 1 ? "" : "s"}`);
1893
+ }
1483
1894
  }
1484
- const fmt = (x) => x.toLocaleString("en-US");
1485
- const trailLabel = expected?.brainName ? `“${expected.brainName}”` : `${d.owner}/${d.name}`;
1486
- console.error(`✓ sent ${fmt(d.commits.length)} commits and ${fmt(d.threads.length)} discussions to ${trailLabel} · no file contents left this machine`);
1487
- if (verbose)
1488
- console.error(` ${fmt(d.symbols.length)} symbols, ${fmt(d.sources.length)} stated sources`);
1489
- const buildPage = brainUrl(link.brainId);
1490
- // Held rather than handed back. Everything above this line succeeded even
1491
- // in the runs that end badly: the push lands, and then the miner fails —
1492
- // which is where roughly a third of production's repo trails die. Returning
1493
- // the prompt here is what made that invisible from this side, on CI and
1494
- // over SSH permanently so. `--no-watch` is for a caller that genuinely
1495
- // wants fire-and-forget.
1496
- if (opts.watch === false) {
1497
- console.error("· building now — the rules reach this repo on their own; watch it at");
1498
- console.error(` ${buildPage}`);
1499
- return;
1895
+ if (overlaps.length) {
1896
+ console.log("\noverlap");
1897
+ for (const o of overlaps)
1898
+ for (const [i, l] of wrap(o, 66, " ").entries())
1899
+ console.log(i === 0 ? ` ● ${l}` : l);
1500
1900
  }
1501
- const live = tty && !verbose;
1502
- if (!live)
1503
- console.error("· building the trail");
1504
- const spinner = live ? startSpinner("building the trail · reaching the repository · Ctrl-C detaches") : null;
1505
- let last = null;
1506
- // Ctrl-C detaches: the work is server-side, and killing a watcher must
1507
- // never look like killing the build.
1508
- const onSigint = () => {
1509
- spinner?.stop();
1510
- console.error("· detached — the build keeps going; watch it at");
1511
- console.error(` ${buildPage}`);
1512
- process.exit(130);
1513
- };
1514
- process.once("SIGINT", onSigint);
1515
- const outcome = await watchBuild(link, {
1516
- events: expected?.events === true,
1517
- stageLines: !live,
1518
- write: (line) => (spinner ? spinner.print(line) : console.error(line)),
1519
- onState: (view, repoState) => {
1520
- last = repoState;
1521
- spinner?.update(`building the trail · ${DOING_LABEL[currentStage(view)]} · Ctrl-C detaches`);
1522
- },
1901
+ console.log("\n· reuse and the team page, across your repos, are in a Trail workspace");
1902
+ printLines(cloudLines(repo, { command: "check" }));
1903
+ }
1904
+ function teamCheckCommand(name = "check") {
1905
+ return new Command(name)
1906
+ .description("Check your branch's diff against everything the team has learned: the repo's learnings, yours and your skills")
1907
+ .argument("[dir]", "repository root", ".")
1908
+ .option("--base <ref>", "compare against where the branch left this ref (default: origin's default branch)")
1909
+ .option("--json", "output as JSON")
1910
+ .action(async (dir, opts) => {
1911
+ await runTeamCheck(resolve(dir), opts);
1523
1912
  });
1524
- process.off("SIGINT", onSigint);
1525
- spinner?.stop();
1526
- const ruleCount = rulesSoFar(last);
1527
- const rules = `${fmt(ruleCount)} rule${ruleCount === 1 ? "" : "s"}`;
1528
- const wired = [...new Set([...(readStamp(repo)?.hosts ?? []), ...wiredHostIds(repo)])];
1529
- const closing = () => {
1530
- const line = suggestionsLine(last?.suggestions, wired, !firstPush);
1531
- if (!line)
1532
- return;
1533
- if (browserOpen) {
1534
- console.error(`${line} — they are in the Trail tab in your browser`);
1535
- console.error(` ${reviewUrl(link.brainId)}`);
1536
- }
1537
- else {
1538
- console.error(line);
1539
- console.error(` review: ${reviewUrl(link.brainId)}`);
1540
- }
1541
- };
1542
- if (outcome === "completed") {
1543
- console.error(`✓ trail built · ${rules}, reaching this repo on their own`);
1544
- closing();
1545
- return;
1546
- }
1547
- // The ordinary ending for anything but a small repository. The history is
1548
- // mined in slices, and the prompt comes back on the first of them: mining is
1549
- // the part that fails and it has now succeeded, and the rest is filing,
1550
- // which is minutes of reliable work nobody gains by watching.
1551
- if (outcome === "building") {
1552
- console.error(firstPush
1553
- ? `✓ trail has its first ${rules} · they reach this repo on their own`
1554
- : `✓ trail updated · ${rules}, reaching this repo on their own`);
1555
- closing();
1913
+ }
1914
+ /** A unified diff without the sections for `.trail/` files: a learning is never the change being checked. */
1915
+ function withoutTrailFiles(diff) {
1916
+ return diff
1917
+ .split(/(?=^diff --git )/m)
1918
+ .filter((part) => !/^diff --git a\/\.trail\//.test(part))
1919
+ .join("");
1920
+ }
1921
+ /**
1922
+ * `trail check`, on this machine: the branch's diff against the learnings in
1923
+ * the repo's `.trail/` (teammates' pushed branches included), this person's
1924
+ * own, and the repo's skills. No sign-in, no server. Exits 1 on a conflict.
1925
+ */
1926
+ async function runTeamCheck(repo, opts) {
1927
+ const d = branchDiff(repo, opts.base);
1928
+ if (!d) {
1929
+ console.error("✗ not a git repository");
1930
+ process.exitCode = 1;
1556
1931
  return;
1557
1932
  }
1558
- if (outcome === "failed") {
1559
- // A non-zero exit, unlike every other ending here: this is the one case
1560
- // where the work did not produce a trail, and a CI step that ran the push
1561
- // should hear about it the way it hears about any other failure. The
1562
- // watcher has already printed where it stopped and why.
1563
- process.exitCode = 1;
1933
+ const files = d.files.filter((f) => !f.startsWith(".trail/"));
1934
+ if (files.length === 0) {
1935
+ console.log(`· nothing to check: no changes against ${d.base}`);
1564
1936
  return;
1565
1937
  }
1566
- if (outcome === "unreachable") {
1567
- console.error("· lost touch with Trail — the build keeps going; watch it at the link below");
1938
+ maybeRefreshBranches(repo);
1939
+ if (d.truncated)
1940
+ console.error("⚠ the diff is over 400 KB; only the first 400 KB is checked");
1941
+ const me = noteAuthor(repo);
1942
+ const r = checkLocally({ diff: withoutTrailFiles(d.diff), files, notes: listNotes(repo), skills: listSkills(repo), asker: me });
1943
+ const sources = r.findings.map((f) => f.source).filter((s) => s.path);
1944
+ recordActivity({
1945
+ repo: repoPlace(repo).key,
1946
+ kind: "check",
1947
+ asker: me,
1948
+ session: sessionKey(repo) ?? `${me}-${new Date().toISOString().slice(0, 10)}`,
1949
+ query: "",
1950
+ files,
1951
+ shown_paths: [...new Set(sources.map((s) => s.path.slice(s.path.lastIndexOf("/") + 1)))],
1952
+ shown_authors: [...new Set(sources.map((s) => s.author ?? ""))],
1953
+ at: new Date().toISOString(),
1954
+ });
1955
+ const overlaps = overlapsWith(repo, files, me).slice(0, 3);
1956
+ if (opts.json) {
1957
+ console.log(JSON.stringify({ ...r, overlaps }, null, 2));
1568
1958
  }
1569
1959
  else {
1570
- console.error("· still building after 15 minutes — not failed, just long; watch it at the link below");
1960
+ printCheck(r);
1961
+ if (overlaps.length)
1962
+ console.log(`\n${overlaps.map((o) => formatOverlap(o)).join("\n")}`);
1963
+ printLines(cloudLines(repo, { command: "check" }));
1571
1964
  }
1572
- console.error(` ${buildPage}`);
1573
- });
1574
- // `graft claude-md pull` is `graft trail pull` now: the CLAUDE.md changes are
1575
- // the first half of what that writes. Kept so the old name keeps working.
1576
- const claudeMd = program
1577
- .command("claude-md")
1578
- .description("The CLAUDE.md changes this repo's trail suggested (now part of graft trail pull)");
1579
- claudeMd
1580
- .command("pull")
1581
- .description("Same as graft trail pull: write every change you accepted in Trail into this repo")
1582
- .argument("[dir]", "target repo directory", ".")
1583
- .option("--dry-run", "show what would change without writing anything or telling Trail")
1584
- .action(async (dir, opts) => {
1585
- console.error("· graft claude-md pull is now part of graft trail pull — running that");
1586
- await runTrailPullCommand(dir, opts);
1587
- });
1588
- brain
1589
- .command("status")
1590
- .description("Show the attached brain, how many rules are cached, and how many still match the code")
1591
- .argument("[dir]", "target repo directory", ".")
1592
- .option("--json", "machine-readable output")
1593
- .action((dir, opts) => {
1594
- const repo = resolve(dir);
1595
- const { link, rules, fetchedAt } = brainStatus(repo);
1596
- // Resolve every cached rule against the CURRENT graph, so the count of stale
1597
- // rules is the real one rather than what was true at pull time. This is the
1598
- // number worth surfacing: it says how far the codebase has drifted from what
1599
- // the team decided.
1600
- const graph = loadGraphCached(contextDirFor(repo, program.opts().dir));
1601
- const applied = rulesForPointers((graph?.nodes ?? []).map((n) => `${n.path}:${n.span}`), rules, graph);
1602
- if (opts.json) {
1603
- console.log(JSON.stringify({ brainId: link?.brainId ?? null, cached: rules.length, fetchedAt, anchored: applied.length, stale: applied.filter((a) => a.stale).length }, null, 2));
1604
- return;
1605
- }
1606
- if (!link) {
1607
- console.error("· no brain attached — run `graft trail connect <brainId>:<token>`");
1608
- return;
1965
+ maybeSyncInBackground(repo);
1966
+ if (r.findings.some((f) => f.verdict === "conflict"))
1967
+ process.exitCode = 1;
1968
+ }
1969
+ function printCheck(r) {
1970
+ console.log(`checking ${r.files} changed file${r.files === 1 ? "" : "s"} against ${r.notes} learning${r.notes === 1 ? "" : "s"} and ${r.skills} skill${r.skills === 1 ? "" : "s"}\n`);
1971
+ const conflicts = r.findings.filter((f) => f.verdict === "conflict");
1972
+ for (const f of [...conflicts, ...r.findings.filter((x) => x.verdict !== "conflict")]) {
1973
+ const mark = f.verdict === "conflict" ? "✗" : "✓";
1974
+ console.log(`${mark} ${f.file}${f.line ? `:${f.line}` : ""}`);
1975
+ console.log(` ${f.summary}`);
1976
+ const s = f.source;
1977
+ if (f.verdict === "conflict" && s.quote) {
1978
+ const who = [s.author, s.date ? shortDate(s.date) : ""].filter(Boolean).join(" · ");
1979
+ for (const l of wrap(`${who ? `${who}: ` : ""}${s.quote}`, 70, " "))
1980
+ console.log(` ${l.trimStart()}`);
1981
+ }
1609
1982
  }
1610
- console.error(`brain ${link.brainId}`);
1611
- console.error(` ${rules.length} rule(s) cached${fetchedAt ? `, pulled ${new Date(fetchedAt).toISOString()}` : ""}`);
1612
- if (!graph) {
1613
- console.error(" no graph yet — run `graft build` to see which rules still match the code");
1614
- return;
1983
+ if (r.findings.length === 0)
1984
+ console.log("✓ nothing the team has learned bears on this change");
1985
+ console.log("");
1986
+ console.log(conflicts.length
1987
+ ? `· ${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"} · fix ${conflicts.length === 1 ? "it" : "them"}, or say why in the commit message or PR description`
1988
+ : "· no conflicts");
1989
+ }
1990
+ /** Tokens saved by this repo's agent sessions touched in the last 7 days. */
1991
+ function savedThisWeek(repo, now = Date.now()) {
1992
+ const since = now - 7 * 24 * 60 * 60 * 1000;
1993
+ let total = 0;
1994
+ for (const id of listSessionIds(repo)) {
1995
+ try {
1996
+ if (statSync(join(sessionDir(repo), `${id}.json`)).mtimeMs < since)
1997
+ continue;
1998
+ }
1999
+ catch {
2000
+ continue;
2001
+ }
2002
+ total += readSession(repo, id).savedTokens ?? 0;
1615
2003
  }
1616
- const stale = applied.filter((a) => a.stale);
1617
- console.error(` ${applied.length} anchored to symbols in this repo`);
1618
- console.error(stale.length
1619
- ? ` ${stale.length} describe code that has changed since:`
1620
- : " none describe code that has changed since");
1621
- for (const a of stale.slice(0, 10))
1622
- console.error(` - ${a.rule}\n ${a.pointer}`);
1623
- });
1624
- brain
1625
- .command("disconnect")
1626
- .description("Forget the attached brain (its rules stay in the instruction files until the next init)")
1627
- .argument("[dir]", "target repo directory", ".")
1628
- .action((dir) => {
1629
- const repo = resolve(dir);
1630
- clearLink(resolve(dir));
1631
- console.error(`✓ detached the brain from ${repo}`);
1632
- });
1633
- program.parseAsync(withLegacyNames(process.argv)).catch((err) => {
2004
+ return total;
2005
+ }
2006
+ /** 212345 → `212k`, 1_250_000 → `1.3M`. */
2007
+ function shortCount(n) {
2008
+ if (n >= 1_000_000)
2009
+ return `${(n / 1_000_000).toFixed(1)}M`;
2010
+ if (n >= 1_000)
2011
+ return `${Math.round(n / 1_000)}k`;
2012
+ return String(n);
2013
+ }
2014
+ /**
2015
+ * `trail status`: what was `graft stats`, on one
2016
+ * screen. The code map and whether it matches the code, the learnings for
2017
+ * this repo, the workspace, and what the agents saved this week.
2018
+ */
2019
+ function statusCommand(name = "status") {
2020
+ return new Command(name)
2021
+ .description("Code map, learnings and tokens saved, on one screen")
2022
+ .argument(...DIR_ARG)
2023
+ .option("--json", "machine-readable output")
2024
+ .action(async (dirArg, opts) => {
2025
+ const repo = queryRoot(dirArg);
2026
+ const graph = loadGraphCached(contextDirFor(repo, program.opts().dir));
2027
+ const g = graph ? await engineFrom().checkGraph(repo) : null;
2028
+ const fresh = g && !g.missing ? g.ok : null;
2029
+ const kept = keepsNotes(repo);
2030
+ const all = kept ? listNotes(repo) : [];
2031
+ const inRepo = all.filter((n) => n.scope === "repo" && !n.ref).length;
2032
+ const onBranches = all.filter((n) => n.ref).length;
2033
+ const personal = all.filter((n) => n.scope === "personal").length;
2034
+ const cloud = readCloud();
2035
+ const shared = cloud ? inWorkspace(repo, cloud) : false;
2036
+ const saved = savedThisWeek(repo);
2037
+ if (opts.json) {
2038
+ console.log(JSON.stringify({
2039
+ codeMap: graph ? { nodes: graph.meta.nodeCount, inSync: fresh } : null,
2040
+ learnings: kept ? { repo: inRepo, branches: onBranches, personal } : null,
2041
+ workspace: cloud ? { name: cloud.workspace.name, url: cloud.workspace.url, repos: cloud.repos.length, thisRepo: shared } : null,
2042
+ week: { savedTokens: saved },
2043
+ }, null, 2));
2044
+ return;
2045
+ }
2046
+ const fmt = (x) => x.toLocaleString("en-US");
2047
+ const row = (k, v) => console.log(`${k.padEnd(11)} ${v}`);
2048
+ if (!graph)
2049
+ row("code map", `✗ not built · run ${cmd("graft build")}`);
2050
+ else if (fresh === false)
2051
+ row("code map", `⚠ ${fmt(graph.meta.nodeCount)} nodes · behind the code, refreshed on the next query`);
2052
+ else
2053
+ row("code map", `✓ ${fmt(graph.meta.nodeCount)} nodes · in sync with the code`);
2054
+ if (kept) {
2055
+ const parts = [`${inRepo} for the team in ${LEARNINGS_DIR}/`];
2056
+ if (onBranches)
2057
+ parts.push(`${onBranches} on teammates' branches`);
2058
+ if (personal)
2059
+ parts.push(`${personal} just for you, on this machine`);
2060
+ row("learnings", parts.join(" · "));
2061
+ }
2062
+ if (!cloud) {
2063
+ if (cloudEnabled())
2064
+ row("workspace", "not connected · Claude offers a link once there are learnings to share");
2065
+ }
2066
+ else
2067
+ row("workspace", `${cloud.workspace.name} · ${cloud.repos.length} repo${cloud.repos.length === 1 ? "" : "s"}${shared ? ", this one included" : `, not this one (${cmd("graft workspace add")})`} · ${cloud.workspace.url}`);
2068
+ row("this week", saved > 0 ? `~${shortCount(saved)} tokens saved` : "nothing saved yet");
2069
+ });
2070
+ }
2071
+ if (TRAIL) {
2072
+ for (const c of [teamCheckCommand(), teamCommand()])
2073
+ program.addCommand(c.helpGroup(GROUP.memory));
2074
+ // Signing in waits until it gives a team more than committing .trail/ does.
2075
+ for (const c of [workspaceCommand(), loginCommand(), logoutCommand()])
2076
+ program.addCommand(c.helpGroup(GROUP.cloud), { hidden: !cloudEnabled() });
2077
+ program.addCommand(statusCommand().helpGroup(GROUP.setup));
2078
+ // Help lists the groups in this order, and the commands within each.
2079
+ const order = ["ask", "grep", "skeleton", "callers", "map", "note", "check", "team", "learn", "skills", "init", "build", "status", "upgrade", "uninstall", "telemetry", "workspace", "login", "logout", "blast"];
2080
+ const rank = (c) => {
2081
+ const i = order.indexOf(c.name());
2082
+ return i === -1 ? order.length : i;
2083
+ };
2084
+ program.commands.sort((a, b) => rank(a) - rank(b));
2085
+ // One line each in the list; `trail <command> --help` keeps the long text.
2086
+ const summaries = {
2087
+ ask: "ranked answer, with the code inlined at each file:line",
2088
+ grep: "every occurrence, grouped by enclosing symbol",
2089
+ skeleton: "a file's whole API in ~200 tokens",
2090
+ callers: "who calls it, or what it calls with --direction out",
2091
+ map: "directory clusters, hubs and hotspots",
2092
+ note: "save what this session worked out, as a learning in .trail/",
2093
+ learn: "turn a correction into a takeaway on a skill",
2094
+ skills: "list skills, show one, fold takeaways in, publish to the repo",
2095
+ init: "wire trail into your agents",
2096
+ build: "rebuild the code map · --check fails if it's stale",
2097
+ status: "code map, learnings and tokens saved",
2098
+ upgrade: "install the latest trail",
2099
+ uninstall: "remove everything trail or graft wrote to this repo",
2100
+ telemetry: "show or turn off the anonymous usage stats",
2101
+ check: "check your diff against everything the team has learned",
2102
+ team: "what the team learned this week, and where work overlaps",
2103
+ workspace: "your workspace's repos, team page and invite link",
2104
+ login: "connect a Trail workspace (Claude offers you the link)",
2105
+ logout: "disconnect this machine from its workspace",
2106
+ blast: "what depends on the lines this diff touched",
2107
+ };
2108
+ for (const c of program.commands)
2109
+ if (summaries[c.name()])
2110
+ c.summary(summaries[c.name()]);
2111
+ program.addHelpText("after", "\ntrail <command> --help for flags · every graft command still works");
2112
+ }
2113
+ program.parseAsync(process.argv).catch((err) => {
1634
2114
  console.error(err instanceof Error ? err.message : err);
1635
2115
  process.exit(1);
1636
2116
  });