@nanonets/graft 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/app/history.d.ts +36 -1
  2. package/dist/app/history.d.ts.map +1 -1
  3. package/dist/app/history.js +72 -25
  4. package/dist/app/history.js.map +1 -1
  5. package/dist/app/sources.d.ts +13 -0
  6. package/dist/app/sources.d.ts.map +1 -1
  7. package/dist/app/sources.js +129 -1
  8. package/dist/app/sources.js.map +1 -1
  9. package/dist/brain/claude-md.d.ts +4 -2
  10. package/dist/brain/claude-md.d.ts.map +1 -1
  11. package/dist/brain/claude-md.js +4 -3
  12. package/dist/brain/claude-md.js.map +1 -1
  13. package/dist/brain/context-files.d.ts +79 -0
  14. package/dist/brain/context-files.d.ts.map +1 -0
  15. package/dist/brain/context-files.js +245 -0
  16. package/dist/brain/context-files.js.map +1 -0
  17. package/dist/brain/link.d.ts +17 -0
  18. package/dist/brain/link.d.ts.map +1 -1
  19. package/dist/brain/link.js +21 -0
  20. package/dist/brain/link.js.map +1 -1
  21. package/dist/brain/pull.d.ts +17 -0
  22. package/dist/brain/pull.d.ts.map +1 -0
  23. package/dist/brain/pull.js +172 -0
  24. package/dist/brain/pull.js.map +1 -0
  25. package/dist/brain/push.d.ts +62 -7
  26. package/dist/brain/push.d.ts.map +1 -1
  27. package/dist/brain/push.js +97 -62
  28. package/dist/brain/push.js.map +1 -1
  29. package/dist/brain/signup.d.ts +46 -2
  30. package/dist/brain/signup.d.ts.map +1 -1
  31. package/dist/brain/signup.js +101 -11
  32. package/dist/brain/signup.js.map +1 -1
  33. package/dist/brain/upload.d.ts +61 -0
  34. package/dist/brain/upload.d.ts.map +1 -0
  35. package/dist/brain/upload.js +210 -0
  36. package/dist/brain/upload.js.map +1 -0
  37. package/dist/brain/watch.d.ts +76 -3
  38. package/dist/brain/watch.d.ts.map +1 -1
  39. package/dist/brain/watch.js +263 -39
  40. package/dist/brain/watch.js.map +1 -1
  41. package/dist/claude/build-progress.d.ts +29 -0
  42. package/dist/claude/build-progress.d.ts.map +1 -0
  43. package/dist/claude/build-progress.js +76 -0
  44. package/dist/claude/build-progress.js.map +1 -0
  45. package/dist/cli-picker.d.ts +17 -2
  46. package/dist/cli-picker.d.ts.map +1 -1
  47. package/dist/cli-picker.js +103 -22
  48. package/dist/cli-picker.js.map +1 -1
  49. package/dist/cli.js +423 -187
  50. package/dist/cli.js.map +1 -1
  51. package/dist/telemetry/contract.d.ts +16 -3
  52. package/dist/telemetry/contract.d.ts.map +1 -1
  53. package/dist/telemetry/contract.js +23 -5
  54. package/dist/telemetry/contract.js.map +1 -1
  55. package/dist/util/spinner.d.ts +15 -0
  56. package/dist/util/spinner.d.ts.map +1 -0
  57. package/dist/util/spinner.js +52 -0
  58. package/dist/util/spinner.js.map +1 -0
  59. package/dist/util/state.d.ts +8 -0
  60. package/dist/util/state.d.ts.map +1 -1
  61. package/dist/util/state.js.map +1 -1
  62. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -19,31 +19,34 @@ import { hostIds } from "./hosts/registry.js";
19
19
  import { parseBrainArg, connectBrain, pullBrain, brainStatus } from "./brain/connect.js";
20
20
  import { rulesForPointers } from "./brain/attach.js";
21
21
  import { clearLink } from "./brain/link.js";
22
- import { buildEarlyDigest, buildLocalDigest, fetchExpectedRepo, pushDigest, pushEarlyDigest, repoSlugFromGit, sameRepo, } from "./brain/push.js";
23
- import { readLink, writeLink } from "./brain/link.js";
22
+ import { buildEarlyDigest, buildLocalDigest, fetchExpectedRepo, pushContext, pushDigest, pushEarlyDigest, repoSlugFromGit, sameRepo, uploadCaps, } from "./brain/push.js";
23
+ import { apiBaseUrl, clearPendingSignup, readLink, readPendingSignup, writeLink, writePendingSignup } from "./brain/link.js";
24
24
  import { withLegacyNames } from "./legacy-args.js";
25
- import { applyChanges, fetchAcceptedChanges, markApplied } from "./brain/claude-md.js";
26
- import { watchBuild } from "./brain/watch.js";
27
- import { openBrowser, signupUrl, startHandoff } from "./brain/signup.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 } from "./brain/pull.js";
28
+ import { readByWired, readersOf } from "./brain/context-files.js";
29
+ import { startSpinner } from "./util/spinner.js";
28
30
  import { contextDirFor } from "./context/node-file.js";
29
31
  import { loadGraphCached } from "./graph/load.js";
30
32
  import { ensureFreshChildren, ensureFreshGraph, refreshNote } from "./graph/refresh.js";
31
33
  import { isWorkspaceBuildRoot, readWorkspace } from "./graph/workspace.js";
32
- import { nearestGraftRoot } from "./graph/root.js";
34
+ import { hasGraftIndex, nearestGraftRoot } from "./graph/root.js";
33
35
  import { unsupportedExtensions, supportedExtensions } from "./graph/source-files.js";
34
36
  import { discoverWorkspaceChildren } from "./graph/scopes.js";
35
37
  import { runWorkspaceAsk, runWorkspaceBuild, runWorkspaceCallers, runWorkspaceCheck, runWorkspaceGrep, runWorkspaceMap, } from "./graph/workspace-cli.js";
36
38
  import { formatInitEpilogue } from "./cli-epilogue.js";
37
39
  import { planInit, selectedWrites } from "./hosts/plan.js";
38
40
  import { planRetract, runRetract, changed } from "./hosts/retract.js";
39
- import { formatNonInteractiveHelp, formatPlan, runPicker } from "./cli-picker.js";
41
+ import { compactWrites, formatNonInteractiveHelp, formatPlan, runPicker } from "./cli-picker.js";
42
+ import { buildGraphWithProgress } from "./claude/build-progress.js";
40
43
  import { homedir } from "node:os";
41
44
  import { formatUpgradeReport, formatVersionReport, getNpmViewVersion, readCurrentVersion, runUpgrade } from "./cli-meta.js";
42
45
  import { patchBuildConfig } from "./util/state.js";
43
46
  import { normalizePathPrefix } from "./util/paths.js";
44
47
  import { latestSession, formatSessionStats, sessionInputRate } from "./claude/session-metrics.js";
45
48
  import { setInputRate } from "./context/savings.js";
46
- import { formatUpdateNudge, maybeRefreshInBackground, readUpdateCache, refreshUpdateCache, wiredHostIds, writeStamp } from "./upkeep.js";
49
+ import { formatUpdateNudge, maybeRefreshInBackground, readStamp, readUpdateCache, refreshUpdateCache, wiredHostIds, writeStamp } from "./upkeep.js";
47
50
  import { errorCode, filesBucket, durationBucket, formatDebug, formatStatus, firstRunNotice, isTrackedCommand, langsValue, offReason, maybeFlushInBackground, patchState, runFlush, track, trackFirstRunIfNew, } from "./telemetry/index.js";
48
51
  const program = new Command();
49
52
  const currentVersion = readCurrentVersion(import.meta.url);
@@ -820,7 +823,20 @@ program
820
823
  .option("-y, --yes", "skip the picker and wire every detected agent (the pre-0.8 default)")
821
824
  .option("--no-global", "skip writes outside this repo (the ~/.codex/ config + hooks)")
822
825
  .option("--trail <handoff>", "attach a Trail: <brainId>:<token> (or a bare id with GRAFT_BRAIN_TOKEN set)")
823
- .action((dir, opts) => runInitCommand(dir, opts));
826
+ .option("--verbose", "print every file written, the graph build's own output and the closing banner")
827
+ .action(async (dir, opts) => {
828
+ await runInitCommand(dir, opts);
829
+ });
830
+ /** `a, b and c`. */
831
+ function joinAnd(items) {
832
+ if (items.length <= 1)
833
+ return items.join("");
834
+ return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
835
+ }
836
+ /** Repo-relative with forward slashes, for printing. */
837
+ function shown(repo, path) {
838
+ return relative(repo, path).split("\\").join("/");
839
+ }
824
840
  /**
825
841
  * `graft init`. Also run by `graft trail push` on a repo graft has not been set
826
842
  * up in — the Trail pages start people at push, and a trail whose rules no
@@ -831,7 +847,7 @@ async function runInitCommand(dir, opts, how = {}) {
831
847
  if (opts.listAgents) {
832
848
  for (const id of [...hostIds(), "claude"])
833
849
  console.log(id);
834
- return;
850
+ return null;
835
851
  }
836
852
  // Parsed before anything is written: a mistyped handoff should cost the user
837
853
  // an error, not a half-wired repo they have to `graft uninstall` out of.
@@ -841,7 +857,7 @@ async function runInitCommand(dir, opts, how = {}) {
841
857
  if ("error" in parsed) {
842
858
  console.error(`✗ --trail: ${parsed.error}`);
843
859
  process.exitCode = 1;
844
- return;
860
+ return null;
845
861
  }
846
862
  brainLink = parsed;
847
863
  }
@@ -881,17 +897,19 @@ async function runInitCommand(dir, opts, how = {}) {
881
897
  const reason = offReason();
882
898
  const picked = await runPicker(plan, repo, home, {
883
899
  offerTelemetry: reason === null || reason === "disabled",
900
+ // From push, the line above the picker already asks the question.
901
+ ...(how.push ? { title: null } : {}),
884
902
  });
885
903
  if (picked === null) {
886
904
  console.error("· cancelled — nothing written");
887
- return;
905
+ return null;
888
906
  }
889
907
  ids = picked.hosts;
890
908
  consent = picked.telemetry;
891
909
  }
892
910
  else {
893
911
  console.error(formatNonInteractiveHelp(detectedIds));
894
- return;
912
+ return null;
895
913
  }
896
914
  // The picker's answer, recorded before anything is wired: a user who
897
915
  // unchecked the row must not have this run's init_completed sent.
@@ -912,34 +930,121 @@ async function runInitCommand(dir, opts, how = {}) {
912
930
  console.error(formatPlan(plan, ids, repo, home));
913
931
  for (const child of children)
914
932
  console.error(`\n— ${child}/ (workspace child)\n` + formatPlan(planInit(join(repo, child), { home }), ids, join(repo, child), home));
915
- return;
933
+ return null;
916
934
  }
917
935
  if (ids.length === 0) {
918
936
  console.error("· no agents selected — nothing written");
919
- return;
937
+ return null;
920
938
  }
921
939
  const wantClaude = ids.includes("claude");
922
940
  const cliPath = fileURLToPath(import.meta.url);
923
941
  if (children.length)
924
942
  console.error(`· workspace: wiring ${repo} and ${children.length} child repo(s) — ${children.join(", ")}`);
943
+ // Today's output, file by file, behind --verbose. Everything else gets one
944
+ // line per step: the graph, then one per agent.
945
+ const verbose = opts.verbose === true;
946
+ const reports = [];
925
947
  for (const target of targets) {
926
- if (target !== repo)
948
+ if (verbose && target !== repo)
927
949
  console.error(`\n— ${relative(repo, target)}/`);
928
- wireTarget(target, ids, { home, cliPath, plan, opts, wantClaude });
950
+ reports.push(wireTarget(target, ids, { home, cliPath, plan, opts, wantClaude, quiet: !verbose }));
951
+ }
952
+ // The graph, built once after the wiring: a workspace parent's build is
953
+ // the workspace build, so it covers every child.
954
+ let graphLine = null;
955
+ let graphNodes = 0;
956
+ if (!verbose) {
957
+ const res = await buildGraphWithProgress(repo, { build: opts.build, cliPath });
958
+ for (const m of res.messages)
959
+ console.error(m);
960
+ const existing = res.built ? null : loadGraphCached(contextDirFor(repo, children.length ? undefined : program.opts().dir));
961
+ const fmt = (x) => x.toLocaleString("en-US");
962
+ if (res.graph) {
963
+ graphNodes = res.graph.nodes;
964
+ 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"}` : ""}`;
965
+ }
966
+ else if (res.built) {
967
+ graphLine = "✓ graph built";
968
+ }
969
+ else if (res.failed) {
970
+ graphLine = "⚠ the graph build failed — run graft build to see why";
971
+ }
972
+ else if (existing) {
973
+ graphNodes = existing.meta.nodeCount;
974
+ graphLine = `✓ graph ready · ${fmt(existing.meta.nodeCount)} nodes, ${fmt(existing.meta.edgeCount)} edges`;
975
+ }
976
+ else if (hasGraftIndex(repo)) {
977
+ graphLine = "✓ graph ready";
978
+ }
979
+ else {
980
+ graphLine = "· skipped the graph build — run graft build";
981
+ }
982
+ }
983
+ if (!verbose) {
984
+ // Warnings and removals in full, whatever else is folded.
985
+ const removed = [...new Set(reports.flatMap((r) => r.retracted))];
986
+ for (const id of removed)
987
+ console.error(`- removed ${id} — agent not selected`);
988
+ for (const w of reports.flatMap((r) => r.warnings))
989
+ console.error(w);
990
+ const writesFor = (id) => (plan.find((p) => p.id === id)?.writes ?? []).filter((w) => opts.global !== false || w.scope !== "global");
991
+ if (how.push) {
992
+ const built = graphLine?.startsWith("✓ graph built") ? `graph built, ${graphNodes.toLocaleString("en-US")} nodes` : graphLine?.startsWith("✓") ? "graph ready" : null;
993
+ console.error(`✓ wired ${joinAnd(ids)}${built ? ` · ${built}` : ""}`);
994
+ if (graphLine && !graphLine.startsWith("✓"))
995
+ console.error(graphLine);
996
+ }
997
+ else {
998
+ if (graphLine)
999
+ console.error(graphLine);
1000
+ const width = Math.max(...ids.map((id) => id.length));
1001
+ for (const id of ids) {
1002
+ const summary = compactWrites(writesFor(id), repo, home);
1003
+ console.error(`✓ ${id.padEnd(width)} ${summary}`.trimEnd());
1004
+ }
1005
+ }
929
1006
  }
930
1007
  // The brain comes last, after the graph exists: its rules are anchored to
931
1008
  // symbols, and `graft trail status` can only report how many of them resolve
932
1009
  // once there is a graph to resolve them against.
933
1010
  if (brainLink) {
934
1011
  const res = await connectBrain(repo, brainLink, { home, ids });
935
- if (res.warning)
936
- console.error(`⚠ brain: ${res.warning}`);
937
- else
938
- console.error(`✓ brain: pulled ${res.ruleCount} rule(s) from ${brainLink.brainId}`);
939
- for (const w of res.writes)
940
- console.error(`✓ brain rules: ${w.path} (${w.action})`);
941
- if (res.ruleCount > 0 && res.writes.length === 0)
942
- console.error("· no instruction file to write rules into — graft ask still carries them");
1012
+ if (verbose) {
1013
+ if (res.warning)
1014
+ console.error(`⚠ brain: ${res.warning}`);
1015
+ else
1016
+ console.error(`✓ brain: pulled ${res.ruleCount} rule(s) from ${brainLink.brainId}`);
1017
+ for (const w of res.writes)
1018
+ console.error(`✓ brain rules: ${w.path} (${w.action})`);
1019
+ if (res.ruleCount > 0 && res.writes.length === 0)
1020
+ console.error("· no instruction file to write rules into — graft ask still carries them");
1021
+ }
1022
+ else if (res.warning) {
1023
+ console.error(`⚠ trail: ${res.warning}`);
1024
+ }
1025
+ else if (res.ruleCount === 0) {
1026
+ console.error("✓ trail connected · no rules yet — graft trail push reads this repo into it");
1027
+ }
1028
+ else {
1029
+ const where = res.writes.map((w) => shown(repo, w.path)).join(", ");
1030
+ const n = `${res.ruleCount.toLocaleString("en-US")} rule${res.ruleCount === 1 ? "" : "s"}`;
1031
+ console.error(`✓ trail connected · ${n}${where ? ` in ${where}` : ""}`);
1032
+ }
1033
+ }
1034
+ if (!verbose && how.epilogue !== false) {
1035
+ // What there is to commit: the top-level entries the picked agents wrote.
1036
+ const tops = [];
1037
+ for (const w of selectedWrites(plan, ids)) {
1038
+ if (w.scope !== "repo")
1039
+ continue;
1040
+ const parts = shown(repo, w.path).split("/");
1041
+ const top = parts.length > 1 ? `${parts[0]}/` : parts[0];
1042
+ if (!tops.includes(top))
1043
+ tops.push(top);
1044
+ }
1045
+ console.error("· restart your agents so a new session picks up graft");
1046
+ if (tops.length)
1047
+ console.error(`· commit ${tops.slice(0, 4).join(" ")}${tops.length > 4 ? ` +${tops.length - 4} more` : ""} to share it — graft/ stays local and git-ignored`);
943
1048
  }
944
1049
  // One epilogue for the whole run. A workspace parent holds no nodes of its
945
1050
  // own, so the totals come from the children — the graph the user actually got.
@@ -947,7 +1052,7 @@ async function runInitCommand(dir, opts, how = {}) {
947
1052
  const graphs = (children.length ? children.map((c) => join(repo, c)) : [repo])
948
1053
  .map((d) => loadGraphCached(contextDirFor(d, children.length ? undefined : globalDir)))
949
1054
  .filter((g) => g !== null);
950
- if (how.epilogue !== false)
1055
+ if (verbose && how.epilogue !== false)
951
1056
  console.error("\n" +
952
1057
  formatInitEpilogue({
953
1058
  graphBuilt: graphs.length > 0,
@@ -956,10 +1061,21 @@ async function runInitCommand(dir, opts, how = {}) {
956
1061
  }));
957
1062
  // Sorted so `claude,cursor` and `cursor,claude` aggregate as one value.
958
1063
  track("init_completed", { agents: [...ids].sort().join(","), consent: consent === undefined ? "unasked" : String(consent) }, { repo });
1064
+ return { ids };
959
1065
  }
960
1066
  /** One repo's worth of `init` writes — the parent, then each workspace child. */
961
1067
  function wireTarget(repo, ids, ctx) {
962
- const { home, cliPath, plan, wantClaude, opts } = ctx;
1068
+ const { home, cliPath, plan, wantClaude } = ctx;
1069
+ const quiet = ctx.quiet === true;
1070
+ const opts = quiet ? { ...ctx.opts, build: false } : ctx.opts;
1071
+ const report = { retracted: [], warnings: [] };
1072
+ // Every per-file line goes through here; in quiet mode only ⚠ lines survive.
1073
+ const say = (line) => {
1074
+ if (!quiet)
1075
+ console.error(line);
1076
+ else if (line.startsWith("⚠"))
1077
+ report.warnings.push(line);
1078
+ };
963
1079
  const wantStatusline = statuslineWanted({ statusline: opts.statusline });
964
1080
  // Converge, don't just add. init writes the selected hosts; without this it
965
1081
  // never touches the rest, so a repo wired by an older version (or by the same
@@ -969,27 +1085,28 @@ function wireTarget(repo, ids, ctx) {
969
1085
  // be rewritten, and the graph cache is kept (init is one step from using it).
970
1086
  const retracted = changed(runRetract(repo, { home, apply: true, global: opts.global, cache: false, exclude: ids })).filter((r) => r.action !== "skipped-unparseable");
971
1087
  for (const r of retracted)
972
- console.error(`- removed ${r.path} (${r.what}) — agent not selected`);
1088
+ say(`- removed ${r.path} (${r.what}) — agent not selected`);
1089
+ report.retracted = [...new Set(retracted.map((r) => r.hostId))];
973
1090
  if (wantClaude) {
974
1091
  // `global`/`home` are threaded through alongside `statusline`: the claude layer
975
1092
  // writes under `~/.claude` now (hosts/claude-global.ts), so --no-global has to
976
1093
  // reach it or the flag would silently mean "no out-of-repo writes, except three".
977
1094
  const res = runInit(repo, { build: opts.build, cliPath, statusline: wantStatusline, global: opts.global, home });
978
- console.error(`✓ wrote ${res.settingsPath}`);
1095
+ say(`✓ wrote ${res.settingsPath}`);
979
1096
  for (const s of res.shims)
980
- console.error(`✓ wrote ${s}`);
981
- console.error(`✓ wrote ${res.skill}`);
1097
+ say(`✓ wrote ${s}`);
1098
+ say(`✓ wrote ${res.skill}`);
982
1099
  if (res.mcp.action === "skipped-unparseable")
983
- console.error(`⚠ .mcp.json: ${res.mcp.path} left unchanged (not valid JSON) — add the graft server manually`);
1100
+ say(`⚠ .mcp.json: ${res.mcp.path} left unchanged (not valid JSON) — add the graft server manually`);
984
1101
  else if (res.mcp.action === "unchanged")
985
- console.error(`· mcp claude: ${res.mcp.path} (already registered)`);
1102
+ say(`· mcp claude: ${res.mcp.path} (already registered)`);
986
1103
  else
987
- console.error(`✓ mcp claude: ${res.mcp.path} (${res.mcp.action}) — restart Claude Code to load the graft MCP server`);
988
- console.error(res.built ? "✓ built the graph (graft build)" : "· skipped graph build");
1104
+ say(`✓ mcp claude: ${res.mcp.path} (${res.mcp.action}) — restart Claude Code to load the graft MCP server`);
1105
+ say(res.built ? "✓ built the graph (graft build)" : "· skipped graph build");
989
1106
  if (!wantStatusline)
990
- console.error("· skipped Claude Code statusLine (--no-statusline)");
1107
+ say("· skipped Claude Code statusLine (--no-statusline)");
991
1108
  for (const w of res.warnings)
992
- console.error(`⚠ ${w}`);
1109
+ say(`⚠ ${w}`);
993
1110
  }
994
1111
  // `ids` is already resolved, so hosts init is always driven by an explicit
995
1112
  // list — never by its own detection fallback.
@@ -1003,14 +1120,14 @@ function wireTarget(repo, ids, ctx) {
1003
1120
  global: opts.global,
1004
1121
  });
1005
1122
  for (const w of r.written)
1006
- console.error(`✓ ${w.id}: ${w.path} (${w.action})`);
1123
+ say(`✓ ${w.id}: ${w.path} (${w.action})`);
1007
1124
  for (const m of r.mcp)
1008
- console.error(`✓ mcp ${m.id}: ${m.path} (${m.action})`);
1125
+ say(`✓ mcp ${m.id}: ${m.path} (${m.action})`);
1009
1126
  for (const h of r.hooks)
1010
- console.error(`✓ hook ${h.id}: ${h.path} (${h.action})`);
1127
+ say(`✓ hook ${h.id}: ${h.path} (${h.action})`);
1011
1128
  // Only worth saying when there was actually something out-of-repo to skip.
1012
1129
  if (opts.global === false && selectedWrites(plan, ids).some((w) => w.scope === "global"))
1013
- console.error("· skipped out-of-repo writes (--no-global)");
1130
+ say("· skipped out-of-repo writes (--no-global)");
1014
1131
  }
1015
1132
  // Record WHICH graft wrote this repo's agent files, and under which flags.
1016
1133
  // Every entry point compares this against the running binary and re-writes
@@ -1026,10 +1143,12 @@ function wireTarget(repo, ids, ctx) {
1026
1143
  // Every host's wiring points at graft/, so the graph is built whatever was
1027
1144
  // selected — not only when Claude Code is in the list (runInit does its own).
1028
1145
  if (!wantClaude) {
1029
- console.error(buildGraphIfMissing(repo, { build: opts.build, cliPath })
1030
- ? "✓ built the graph (graft build)"
1031
- : "· skipped graph build");
1146
+ if (!quiet)
1147
+ say(buildGraphIfMissing(repo, { build: opts.build, cliPath })
1148
+ ? "✓ built the graph (graft build)"
1149
+ : "· skipped graph build");
1032
1150
  }
1151
+ return report;
1033
1152
  }
1034
1153
  /** Group a retraction report by host, so the output reads as "what leaves each agent". */
1035
1154
  function formatRetractions(rs, apply) {
@@ -1093,14 +1212,19 @@ const brain = program
1093
1212
  .command("trail")
1094
1213
  .description("The Trail attached to this repo: the rules mined from its own history");
1095
1214
  /**
1096
- * Get this repo a brain from the terminal, by sending the user through signup
1215
+ * Get this repo a trail from the terminal, by sending the user through signup
1097
1216
  * in their browser and catching the handoff on loopback.
1098
1217
  *
1099
1218
  * Returns the link, already saved, or null when the user should be left alone —
1100
1219
  * every failure prints its own reason first, because the caller only needs to
1101
- * know whether to carry on.
1220
+ * know whether to carry on. Nothing about the repository has been read or sent
1221
+ * by then, and every message says so.
1102
1222
  */
1103
1223
  async function signUpForBrain(repo, slug) {
1224
+ // No terminal means an agent is running this, and a loopback listener cannot
1225
+ // outlive the command it is waiting in: see signUpWithoutTerminal.
1226
+ if (!process.stderr.isTTY)
1227
+ return signUpWithoutTerminal(repo, slug);
1104
1228
  const handoff = await startHandoff();
1105
1229
  const url = signupUrl({ repo: slug, port: handoff.port, state: handoff.state });
1106
1230
  const startedAt = Date.now();
@@ -1108,37 +1232,96 @@ async function signUpForBrain(repo, slug) {
1108
1232
  // the outcome is the one thing a terminal handoff can lose: a user who reads
1109
1233
  // the URL and walks away kills the process, and only an event already on disk
1110
1234
  // survives that. This is the denominator; `brain_signup_settled` is not.
1111
- track("brain_signup_opened", {}, { repo });
1235
+ track("brain_signup_opened", { mode: "terminal" }, { repo });
1112
1236
  // Printed before the browser opens, and printed whether or not it opens: on a
1113
1237
  // remote shell nothing can open, and on a desktop the window sometimes lands
1114
1238
  // behind the terminal. The URL is the thing that always works.
1115
- console.error(`· ${slug} has no brain yet. Opening your browser to make one:`);
1239
+ console.error(`· ${slug} has no trail yet — opening your browser to make one:`);
1116
1240
  console.error(` ${url}`);
1117
- // A non-interactive shell has nobody to click anything, so waiting five
1118
- // minutes for a browser that will never come is worse than saying so now.
1119
- if (!process.stderr.isTTY) {
1120
- console.error("· not a terminal — open that link, then run `graft trail connect <brainId>:<token>` here");
1121
- // Its own outcome, not a timeout: nothing here could have opened a browser,
1122
- // so folding the two together would read as people abandoning signup when
1123
- // it is only a remote shell doing what it has to.
1124
- track("brain_signup_settled", { outcome: "no_tty", duration_bucket: durationBucket(Date.now() - startedAt) }, { repo });
1241
+ openBrowser(url);
1242
+ const spinner = startSpinner("waiting for you to finish signing up · Ctrl-C to stop");
1243
+ // Ctrl-C while waiting is a person changing their mind, not a crash: say that
1244
+ // nothing was sent, and leave with the usual interrupted exit code.
1245
+ let onSigint;
1246
+ const stopped = new Promise((resolve) => {
1247
+ onSigint = () => resolve("stopped");
1248
+ process.once("SIGINT", onSigint);
1249
+ });
1250
+ const got = await Promise.race([handoff.wait(), stopped]);
1251
+ if (onSigint)
1252
+ process.off("SIGINT", onSigint);
1253
+ spinner.stop();
1254
+ if (got === "stopped") {
1125
1255
  handoff.close();
1256
+ console.error("· stopped — nothing was sent; run graft trail push again when you're ready");
1257
+ track("brain_signup_settled", { outcome: "stopped", mode: "terminal", duration_bucket: durationBucket(Date.now() - startedAt) }, { repo });
1258
+ process.exitCode = 130;
1126
1259
  return null;
1127
1260
  }
1128
- openBrowser(url);
1129
- console.error("· waiting for you to finish signing up…");
1130
- const got = await handoff.wait();
1131
1261
  if ("error" in got) {
1132
1262
  console.error(`✗ ${got.error}`);
1133
1263
  // The category, never the sentence: `got.error` names the repo and the link.
1134
- track("brain_signup_settled", { outcome: got.reason, duration_bucket: durationBucket(Date.now() - startedAt) }, { repo });
1264
+ track("brain_signup_settled", { outcome: got.reason, mode: "terminal", duration_bucket: durationBucket(Date.now() - startedAt) }, { repo });
1135
1265
  return null;
1136
1266
  }
1137
1267
  writeLink(repo, got.link);
1138
- console.error(`✓ brain connected to ${slug}`);
1139
- track("brain_signup_settled", { outcome: "linked", duration_bucket: durationBucket(Date.now() - startedAt) }, { repo });
1268
+ console.error(`✓ trail connected · ${slug} — your browser shows it building`);
1269
+ track("brain_signup_settled", { outcome: "linked", mode: "terminal", duration_bucket: durationBucket(Date.now() - startedAt) }, { repo });
1140
1270
  return got.link;
1141
1271
  }
1272
+ /**
1273
+ * The same sign-up when an agent runs the push, in two runs of one command.
1274
+ *
1275
+ * An agent shows the person nothing until a command exits, so the first run
1276
+ * opens the sign-up page, saves the state in its link and exits at once, with
1277
+ * a line telling the agent what to do next. The run after that finds the saved
1278
+ * state and asks Trail for the trail it made, for up to AGENT_WAIT_MS, then
1279
+ * carries on with the push. Still not signed up by then, it says so and the
1280
+ * agent runs it again. Nothing about the repository is read or sent until the
1281
+ * trail is linked.
1282
+ */
1283
+ async function signUpWithoutTerminal(repo, slug) {
1284
+ const startedAt = Date.now();
1285
+ const settled = (outcome) => track("brain_signup_settled", { outcome, mode: "agent", duration_bucket: durationBucket(Date.now() - startedAt) }, { repo });
1286
+ const pending = readPendingSignup(repo, slug, PENDING_SIGNUP_TTL_MS);
1287
+ if (!pending) {
1288
+ const state = newSignupState();
1289
+ writePendingSignup(repo, { state, repo: slug, createdAt: Date.now() });
1290
+ const url = signupUrl({ repo: slug, state });
1291
+ track("brain_signup_opened", { mode: "agent" }, { repo });
1292
+ openBrowser(url);
1293
+ console.error(`· ${slug} has no trail yet — opened Trail's sign-up page in the browser:`);
1294
+ console.error(` ${url}`);
1295
+ console.error("· nothing has been read or sent yet.");
1296
+ 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.");
1297
+ settled("agent_link_opened");
1298
+ process.exitCode = 1;
1299
+ return null;
1300
+ }
1301
+ const url = signupUrl({ repo: slug, state: pending.state });
1302
+ console.error(`· waiting up to ${Math.round(AGENT_WAIT_MS / 1000)} s for the sign-up for ${slug} to finish:`);
1303
+ console.error(` ${url}`);
1304
+ const got = await waitForSignup(pending.state, apiBaseUrl());
1305
+ if ("link" in got) {
1306
+ clearPendingSignup(repo);
1307
+ writeLink(repo, got.link);
1308
+ console.error(`✓ trail connected · ${slug}`);
1309
+ settled("linked");
1310
+ return got.link;
1311
+ }
1312
+ if ("pending" in got) {
1313
+ console.error("· not signed up yet — nothing has been read or sent.");
1314
+ console.error("· next: run `graft trail push` again to keep waiting. The link above stays valid for about 15 minutes.");
1315
+ settled("still_waiting");
1316
+ process.exitCode = 1;
1317
+ return null;
1318
+ }
1319
+ clearPendingSignup(repo);
1320
+ console.error(`✗ ${got.error}`);
1321
+ settled(got.reason);
1322
+ process.exitCode = 1;
1323
+ return null;
1324
+ }
1142
1325
  brain
1143
1326
  .command("connect")
1144
1327
  .description("Attach a brain to this repo and pull its rules")
@@ -1171,94 +1354,143 @@ brain
1171
1354
  });
1172
1355
  brain
1173
1356
  .command("pull")
1174
- .description("Re-pull the attached brain's rules and rewrite the agent instruction files")
1357
+ .description("Refresh the trail's rules and write every change you accepted in Trail into this repo's context files")
1175
1358
  .argument("[dir]", "target repo directory", ".")
1176
- .action(async (dir) => {
1359
+ .option("--dry-run", "show what would change without writing anything or telling Trail")
1360
+ .action(async (dir, opts) => {
1361
+ await runTrailPullCommand(dir, opts);
1362
+ });
1363
+ /** `graft trail pull`, and `graft claude-md pull` which is now the same thing. */
1364
+ async function runTrailPullCommand(dir, opts) {
1177
1365
  const repo = resolve(dir);
1178
- const res = await pullBrain(repo, { home: homedir() });
1179
- if (!res) {
1180
- console.error("· no brain attached — run `graft trail connect <brainId>:<token>`");
1181
- return;
1182
- }
1183
- if (res.warning) {
1184
- console.error(`⚠ ${res.warning}`);
1366
+ const link = readLink(repo);
1367
+ if (!link) {
1368
+ console.error("✗ this repo has no trail yet — run graft trail push first");
1185
1369
  process.exitCode = 1;
1186
1370
  return;
1187
1371
  }
1188
- console.error(`✓ pulled ${res.ruleCount} rule(s)`);
1189
- for (const w of res.writes)
1190
- console.error(`✓ ${w.path} (${w.action})`);
1191
- });
1372
+ const stamp = readStamp(repo);
1373
+ const wired = [...new Set([...(stamp?.hosts ?? []), ...wiredHostIds(repo)])];
1374
+ const code = await runTrailPull(repo, link, { home: homedir(), wired, dryRun: opts.dryRun });
1375
+ if (code !== 0)
1376
+ process.exitCode = code;
1377
+ }
1378
+ /**
1379
+ * The suggestions line after a build, cut to the files the wired agents read:
1380
+ * `● 18 suggestions for the files claude, agents and cursor read`. Null when
1381
+ * there are none to mention.
1382
+ */
1383
+ function suggestionsLine(s, wired, again) {
1384
+ if (!s)
1385
+ return null;
1386
+ let count = 0;
1387
+ const kinds = new Set();
1388
+ if (s.claudeMd > 0 && readByWired("claude_md", wired)) {
1389
+ count += s.claudeMd;
1390
+ kinds.add("claude_md");
1391
+ }
1392
+ if (s.files) {
1393
+ for (const f of s.files) {
1394
+ if (f.changes <= 0 || !readByWired(f.kind, wired))
1395
+ continue;
1396
+ count += f.changes;
1397
+ kinds.add(f.kind);
1398
+ }
1399
+ }
1400
+ else if (s.contextFiles > 0) {
1401
+ // No per-file list from this Trail, so no way to cut it to the wired agents'
1402
+ // files: the count is Trail's total.
1403
+ count += s.contextFiles;
1404
+ for (const k of ["agents_md", "folder_claude_md", "cursor_rule", "skill"])
1405
+ kinds.add(k);
1406
+ }
1407
+ if (count === 0)
1408
+ return null;
1409
+ const readers = wired.length
1410
+ ? wired.filter((w) => [...kinds].some((k) => readersOf(k, [w]).length > 0))
1411
+ : [];
1412
+ const whose = readers.length ? `the files ${joinAnd(readers)} read` : "this repo's context files";
1413
+ return `● ${count.toLocaleString("en-US")} ${again ? "new " : ""}suggestion${count === 1 ? "" : "s"} for ${whose}`;
1414
+ }
1192
1415
  brain
1193
1416
  .command("push")
1194
- .description("Read THIS repo on your machine and build its brain — no GitHub App, works on private repos")
1417
+ .description("Read THIS repo on your machine and build its trail — no GitHub App, works on private repos")
1195
1418
  .argument("[dir]", "target repo directory", ".")
1196
1419
  .option("--no-approve", "leave the mined rules as drafts for review")
1197
1420
  .option("--no-watch", "return as soon as the push is sent, without following the build")
1421
+ .option("--verbose", "print each step's counts and every build stage as it finishes")
1198
1422
  .action(async (dir, opts) => {
1199
1423
  const repo = resolve(dir);
1424
+ const tty = Boolean(process.stderr.isTTY);
1425
+ const verbose = opts.verbose === true;
1200
1426
  // Resolved before the link, because an unlinked repo now signs up for a
1201
- // brain and Trail creates that brain FOR a named repository. Without a slug
1427
+ // trail and Trail creates that trail FOR a named repository. Without a slug
1202
1428
  // there is nothing to name it after — and the digest builder would fail on
1203
1429
  // the same missing remote a moment later regardless.
1204
1430
  const here = repoSlugFromGit(repo);
1205
1431
  let link = readLink(repo);
1432
+ // Set when this run opened the browser, which then sits on Trail's build
1433
+ // page: the closing lines point at that tab instead of printing a new link.
1434
+ let browserOpen = false;
1206
1435
  if (!link) {
1207
1436
  if (!here) {
1208
- console.error("✗ this directory has no GitHub `origin` remote — graft can only push a GitHub repository today");
1437
+ console.error("✗ this directory has no GitHub origin remote — graft can only push a GitHub repository today");
1209
1438
  process.exitCode = 1;
1210
1439
  return;
1211
1440
  }
1212
1441
  const signedUp = await signUpForBrain(repo, `${here.owner}/${here.name}`);
1213
1442
  if (!signedUp) {
1214
- process.exitCode = 1;
1443
+ if (!process.exitCode)
1444
+ process.exitCode = 1;
1215
1445
  return;
1216
1446
  }
1217
1447
  link = signedUp;
1448
+ browserOpen = true;
1218
1449
  }
1219
- // What the website said this brain is for. Checked BEFORE any reading, so
1220
- // standing in the wrong checkout costs a message rather than a brain full
1450
+ // What the website said this trail is for. Checked BEFORE any reading, so
1451
+ // standing in the wrong checkout costs a message rather than a trail full
1221
1452
  // of another repository's rules — a mistake that is silent afterwards,
1222
1453
  // because the rules look perfectly plausible, just not about your code.
1223
1454
  const expected = await fetchExpectedRepo(link);
1224
1455
  if (expected && here && !sameRepo(expected.slug, `${here.owner}/${here.name}`)) {
1225
- console.error(`✗ this brain is for ${expected.slug}, but you are in ${here.owner}/${here.name}`);
1226
- console.error(` cd into ${expected.slug} and run this again, or attach a different brain here.`);
1456
+ console.error(`✗ this trail is for ${expected.slug}, but you are in ${here.owner}/${here.name}`);
1457
+ console.error(` cd into ${expected.slug} and run this again, or attach a different trail here.`);
1227
1458
  process.exitCode = 1;
1228
1459
  return;
1229
1460
  }
1230
- const label = expected?.slug ?? (here ? `${here.owner}/${here.name}` : "this repository");
1231
- console.error(`· reading ${label} — commit messages, pull-request discussion and the docs in the tree.`);
1232
- console.error(" No file contents leave this machine.");
1233
- // The instruction file and the newest pull requests go first, when the brain
1234
- // takes them: its CLAUDE.md suggestions start from those while the rest of
1235
- // the history is still being read here. Not awaited — the full read starts
1236
- // at once, reusing the threads the early one already fetched.
1461
+ const ctx = pushContext(repo);
1462
+ if (!ctx) {
1463
+ console.error("✗ this directory has no GitHub origin remote — graft can only push a GitHub repository today");
1464
+ process.exitCode = 1;
1465
+ return;
1466
+ }
1467
+ const firstPush = !expected || expected.ruleCount === 0;
1468
+ // The instruction files and the newest pull requests go first, when the
1469
+ // trail takes them: its suggestions start from those while the rest of the
1470
+ // history is still being read here. In the background, from here on — the
1471
+ // picker, the graph build and the full read all happen while it goes, and
1472
+ // the full read reuses every GitHub request it made.
1237
1473
  let early = Promise.resolve();
1238
- let knownThreads = [];
1239
1474
  if (expected?.earlyUpload) {
1240
- const first = await buildEarlyDigest(repo);
1241
- if (first) {
1242
- knownThreads = first.threads;
1243
- early = pushEarlyDigest(link, first.digest).then((ok) => {
1244
- if (ok)
1245
- console.error("· CLAUDE.md suggestions are on their way in your browser, from the newest pull requests");
1246
- });
1247
- }
1248
- }
1249
- // A repo graft was never set up in gets `graft init` first — after the early
1250
- // upload has gone, so the CLAUDE.md suggestions are not held up by the graph
1251
- // build. Same picker as init: it is how someone agrees to what gets written,
1252
- // so a non-interactive push only says what to run.
1475
+ early = (async () => {
1476
+ const first = await buildEarlyDigest(repo, { context: ctx });
1477
+ if (!first)
1478
+ return;
1479
+ const ok = await pushEarlyDigest(link, first.digest, fetch, { gzip: expected.gzipUpload });
1480
+ if (ok && verbose)
1481
+ console.error("· sent the instruction files and the newest pull requests first, so suggestions start now");
1482
+ })().catch(() => undefined);
1483
+ }
1484
+ // A repo graft was never set up in gets `graft init` first. Same picker as
1485
+ // init: it is how someone agrees to what gets written, so a
1486
+ // non-interactive push only says what to run.
1253
1487
  if (wiredHostIds(repo).length === 0) {
1254
- if (process.stdin.isTTY && process.stderr.isTTY) {
1255
- // Its one line of output lands before the picker, not across it.
1256
- await early;
1257
- console.error("· graft is not set up in this repo yet — setting it up first, the same as `graft init`");
1258
- await runInitCommand(repo, { build: true, mcp: true, hooks: true, statusline: true, global: true }, { epilogue: false });
1488
+ if (process.stdin.isTTY && tty) {
1489
+ console.error("· graft isn't set up here yet — pick the agents your team uses:");
1490
+ await runInitCommand(repo, { build: true, mcp: true, hooks: true, statusline: true, global: true, verbose }, { epilogue: false, push: !verbose });
1259
1491
  }
1260
1492
  else {
1261
- console.error("· graft is not set up in this repo — run `graft init` so your coding agent reads these rules");
1493
+ console.error("· graft isn't set up here — run graft init so your agents read these rules");
1262
1494
  }
1263
1495
  }
1264
1496
  // The graph is what symbol anchors are resolved against, so a rule mined
@@ -1266,126 +1498,130 @@ brain
1266
1498
  // its rules simply govern the repo rather than a symbol in it.
1267
1499
  const graph = loadGraphCached(contextDirFor(repo, program.opts().dir));
1268
1500
  if (!graph)
1269
- console.error("· no graph yet — run `graft build` first so rules can be anchored to symbols");
1270
- const built = await buildLocalDigest(repo, graph, { autoApprove: opts.approve !== false, knownThreads });
1271
- await early;
1501
+ console.error("· no graph yet — run graft build so rules can be anchored to symbols");
1502
+ const reading = startSpinner(`reading ${ctx.owner}/${ctx.name} · commits, pull-request discussion and the docs in the tree`);
1503
+ const built = await buildLocalDigest(repo, graph, { autoApprove: opts.approve !== false, context: ctx });
1272
1504
  if ("error" in built) {
1505
+ reading.stop();
1273
1506
  console.error(`✗ ${built.error}`);
1274
1507
  process.exitCode = 1;
1275
1508
  return;
1276
1509
  }
1277
1510
  const d = built.digest;
1511
+ reading.update("sending it to Trail");
1512
+ await early;
1513
+ const sent = await pushDigest(link, d, fetch, uploadCaps(expected));
1514
+ reading.stop();
1278
1515
  if (built.warning)
1279
1516
  console.error(`⚠ ${built.warning}`);
1280
- console.error(`· ${d.commits.length} commits, ${d.threads.length} discussions, ${d.symbols.length} symbols, ${d.sources.length} stated sources`);
1281
- const sent = await pushDigest(link, d);
1282
1517
  if ("error" in sent) {
1283
1518
  console.error(`✗ ${sent.error}`);
1284
1519
  process.exitCode = 1;
1285
1520
  return;
1286
1521
  }
1287
- const brainLabel = expected?.brainName ? `“${expected.brainName}”` : link.brainId;
1288
- console.error(`✓ sent ${d.owner}/${d.name} to ${brainLabel}`);
1522
+ const fmt = (x) => x.toLocaleString("en-US");
1523
+ const trailLabel = expected?.brainName ? `“${expected.brainName}”` : `${d.owner}/${d.name}`;
1524
+ console.error(`✓ sent ${fmt(d.commits.length)} commits and ${fmt(d.threads.length)} discussions to ${trailLabel} · no file contents left this machine`);
1525
+ if (verbose)
1526
+ console.error(` ${fmt(d.symbols.length)} symbols, ${fmt(d.sources.length)} stated sources`);
1527
+ const buildPage = brainUrl(link.brainId);
1289
1528
  // Held rather than handed back. Everything above this line succeeded even
1290
1529
  // in the runs that end badly: the push lands, and then the miner fails —
1291
- // which is where roughly a third of production's repo brains die. Returning
1530
+ // which is where roughly a third of production's repo trails die. Returning
1292
1531
  // the prompt here is what made that invisible from this side, on CI and
1293
1532
  // over SSH permanently so. `--no-watch` is for a caller that genuinely
1294
- // wants fire-and-forget, and it prints the old two lines instead.
1533
+ // wants fire-and-forget.
1295
1534
  if (opts.watch === false) {
1296
- console.error("· it is being mined into rules now — a few minutes. Watch it finish in your browser.");
1297
- console.error(" The rules reach this repo on their own; nothing else to run.");
1535
+ console.error("· building now — the rules reach this repo on their own; watch it at");
1536
+ console.error(` ${buildPage}`);
1298
1537
  return;
1299
1538
  }
1300
- console.error("· building the brain — Ctrl-C detaches, it keeps going without you");
1301
- const outcome = await watchBuild(link);
1539
+ const live = tty && !verbose;
1540
+ if (!live)
1541
+ console.error("· building the trail");
1542
+ const spinner = live ? startSpinner("building the trail · reaching the repository · Ctrl-C detaches") : null;
1543
+ let last = null;
1544
+ // Ctrl-C detaches: the work is server-side, and killing a watcher must
1545
+ // never look like killing the build.
1546
+ const onSigint = () => {
1547
+ spinner?.stop();
1548
+ console.error("· detached — the build keeps going; watch it at");
1549
+ console.error(` ${buildPage}`);
1550
+ process.exit(130);
1551
+ };
1552
+ process.once("SIGINT", onSigint);
1553
+ const outcome = await watchBuild(link, {
1554
+ events: expected?.events === true,
1555
+ stageLines: !live,
1556
+ write: (line) => (spinner ? spinner.print(line) : console.error(line)),
1557
+ onState: (view, repoState) => {
1558
+ last = repoState;
1559
+ spinner?.update(`building the trail · ${DOING_LABEL[currentStage(view)]} · Ctrl-C detaches`);
1560
+ },
1561
+ });
1562
+ process.off("SIGINT", onSigint);
1563
+ spinner?.stop();
1564
+ const ruleCount = rulesSoFar(last);
1565
+ const rules = `${fmt(ruleCount)} rule${ruleCount === 1 ? "" : "s"}`;
1566
+ const wired = [...new Set([...(readStamp(repo)?.hosts ?? []), ...wiredHostIds(repo)])];
1567
+ const closing = () => {
1568
+ const line = suggestionsLine(last?.suggestions, wired, !firstPush);
1569
+ if (!line)
1570
+ return;
1571
+ if (browserOpen) {
1572
+ console.error(`${line} — they are in the Trail tab in your browser`);
1573
+ console.error(` ${reviewUrl(link.brainId)}`);
1574
+ }
1575
+ else {
1576
+ console.error(line);
1577
+ console.error(` review: ${reviewUrl(link.brainId)}`);
1578
+ }
1579
+ };
1302
1580
  if (outcome === "completed") {
1303
- console.error("✓ the brain is built — its rules reach this repo on their own; nothing else to run");
1581
+ console.error(`✓ trail built · ${rules}, reaching this repo on their own`);
1582
+ closing();
1304
1583
  return;
1305
1584
  }
1306
1585
  // The ordinary ending for anything but a small repository. The history is
1307
1586
  // mined in slices, and the prompt comes back on the first of them: mining is
1308
1587
  // the part that fails and it has now succeeded, and the rest is filing,
1309
- // which is minutes of reliable work nobody gains by watching. The rules
1310
- // already in the brain are already being served.
1588
+ // which is minutes of reliable work nobody gains by watching.
1311
1589
  if (outcome === "building") {
1312
- console.error("✓ the brain has its first rules — they reach this repo on their own; nothing else to run");
1313
- console.error(" The rest of the history is still being read. Watch it fill in your browser.");
1590
+ console.error(firstPush
1591
+ ? `✓ trail has its first ${rules} · they reach this repo on their own`
1592
+ : `✓ trail updated · ${rules}, reaching this repo on their own`);
1593
+ closing();
1314
1594
  return;
1315
1595
  }
1316
1596
  if (outcome === "failed") {
1317
1597
  // A non-zero exit, unlike every other ending here: this is the one case
1318
- // where the work did not produce a brain, and a CI step that ran the push
1319
- // should hear about it the way it hears about any other failure.
1320
- console.error(" Nothing was lost — the brain is still there. Run `graft trail push` again to retry the read.");
1598
+ // where the work did not produce a trail, and a CI step that ran the push
1599
+ // should hear about it the way it hears about any other failure. The
1600
+ // watcher has already printed where it stopped and why.
1321
1601
  process.exitCode = 1;
1322
1602
  return;
1323
1603
  }
1324
1604
  if (outcome === "unreachable") {
1325
- console.error("· could not reach Trail to follow the build — it is still running. Watch it finish in your browser.");
1326
- return;
1605
+ console.error("· lost touch with Trail — the build keeps going; watch it at the link below");
1606
+ }
1607
+ else {
1608
+ console.error("· still building after 15 minutes — not failed, just long; watch it at the link below");
1327
1609
  }
1328
- console.error("· still building after 15 minutes — it has not failed, it is just long. Watch it finish in your browser.");
1610
+ console.error(` ${buildPage}`);
1329
1611
  });
1612
+ // `graft claude-md pull` is `graft trail pull` now: the CLAUDE.md changes are
1613
+ // the first half of what that writes. Kept so the old name keeps working.
1330
1614
  const claudeMd = program
1331
1615
  .command("claude-md")
1332
- .description("The CLAUDE.md changes this repo's brain suggested and you accepted in Trail");
1616
+ .description("The CLAUDE.md changes this repo's trail suggested (now part of graft trail pull)");
1333
1617
  claudeMd
1334
1618
  .command("pull")
1335
- .description("Write the CLAUDE.md changes you accepted in Trail into this repo's instruction file")
1619
+ .description("Same as graft trail pull: write every change you accepted in Trail into this repo")
1336
1620
  .argument("[dir]", "target repo directory", ".")
1337
- .option("--dry-run", "show what would change without writing the file or telling Trail")
1621
+ .option("--dry-run", "show what would change without writing anything or telling Trail")
1338
1622
  .action(async (dir, opts) => {
1339
- const repo = resolve(dir);
1340
- const link = readLink(repo);
1341
- if (!link) {
1342
- console.error("✗ this repo has no brain attached — run `graft trail push` first");
1343
- process.exitCode = 1;
1344
- return;
1345
- }
1346
- const pulled = await fetchAcceptedChanges(link);
1347
- if ("error" in pulled) {
1348
- console.error(`✗ ${pulled.error}`);
1349
- process.exitCode = 1;
1350
- return;
1351
- }
1352
- if (pulled.changes.length === 0) {
1353
- console.error("· nothing accepted yet — accept suggestions on the brain's CLAUDE.md page, then pull again");
1354
- return;
1355
- }
1356
- // The file Trail read, or a new CLAUDE.md when the repo had none.
1357
- const rel = pulled.path || "CLAUDE.md";
1358
- const file = join(repo, rel);
1359
- const { existsSync, readFileSync, writeFileSync } = await import("node:fs");
1360
- const before = existsSync(file) ? readFileSync(file, "utf8") : "";
1361
- const result = applyChanges(before, pulled.changes);
1362
- for (const c of result.written)
1363
- console.error(` + ${c.kind === "add" ? "new section" : "edit"} · ${c.heading}`);
1364
- for (const c of result.present)
1365
- console.error(` = already in the file · ${c.heading}`);
1366
- for (const s of result.skipped)
1367
- console.error(` ! skipped · ${s.change.heading} — ${s.why}`);
1368
- if (opts.dryRun) {
1369
- console.error(`· dry run: ${result.written.length} change(s) would be written to ${rel}; nothing was written`);
1370
- return;
1371
- }
1372
- if (result.written.length > 0)
1373
- writeFileSync(file, result.text);
1374
- // Written now or already there: either way the page should stop listing it.
1375
- const done = [...result.written, ...result.present].map((c) => c.id);
1376
- const told = await markApplied(link, done);
1377
- if (result.written.length > 0) {
1378
- console.error(`✓ wrote ${result.written.length} change(s) to ${rel} — review and commit it like any other edit`);
1379
- }
1380
- else if (result.present.length > 0) {
1381
- console.error(`✓ ${rel} already has every accepted change`);
1382
- }
1383
- if (!told)
1384
- console.error("⚠ could not tell Trail which changes were written; the page may still list them");
1385
- if (result.skipped.length > 0) {
1386
- console.error(`· ${result.skipped.length} change(s) no longer match the local file — edit them on the CLAUDE.md page, or apply them by hand`);
1387
- process.exitCode = 1;
1388
- }
1623
+ console.error("· graft claude-md pull is now part of graft trail pull — running that");
1624
+ await runTrailPullCommand(dir, opts);
1389
1625
  });
1390
1626
  brain
1391
1627
  .command("status")