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