pi-plans 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +90 -26
  2. package/agents/ref-analyst.md +18 -0
  3. package/index.ts +121 -9
  4. package/package.json +16 -1
  5. package/references/pi-planning-workflow.md +21 -6
  6. package/references/state-and-config.md +52 -5
  7. package/scripts/validate.ts +5 -0
  8. package/skills/plan-with-refs/SKILL.md +3 -3
  9. package/src/code-graph/commands.ts +483 -0
  10. package/src/code-graph/discovery.ts +118 -0
  11. package/src/code-graph/git.ts +108 -0
  12. package/src/code-graph/identity.ts +59 -0
  13. package/src/code-graph/indexer.ts +281 -0
  14. package/src/code-graph/materialize.ts +166 -0
  15. package/src/code-graph/mode.ts +28 -0
  16. package/src/code-graph/mutations.ts +160 -0
  17. package/src/code-graph/parser.ts +51 -0
  18. package/src/code-graph/parsers/javascript.ts +35 -0
  19. package/src/code-graph/parsers/python.ts +160 -0
  20. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  21. package/src/code-graph/paths.ts +85 -0
  22. package/src/code-graph/prompts.ts +18 -0
  23. package/src/code-graph/resolver.ts +69 -0
  24. package/src/code-graph/runtime.ts +158 -0
  25. package/src/code-graph/schema.ts +135 -0
  26. package/src/code-graph/screening.ts +82 -0
  27. package/src/code-graph/store.ts +278 -0
  28. package/src/code-graph/summary.ts +435 -0
  29. package/src/code-graph/types.ts +163 -0
  30. package/src/compaction.ts +1125 -371
  31. package/src/config-command.ts +361 -0
  32. package/src/exec.ts +508 -693
  33. package/src/guard.ts +14 -1
  34. package/src/refine-prompts.ts +109 -0
  35. package/src/refine-ui-helpers.ts +71 -18
  36. package/src/refine-ui-state.ts +88 -22
  37. package/src/refine-ui.ts +210 -102
  38. package/src/state.ts +36 -7
  39. package/src/subagent.ts +164 -61
  40. package/src/termination-prompt.ts +22 -0
  41. package/tests/analyze-refs.test.ts +265 -0
  42. package/tests/ask-choice.test.ts +264 -0
  43. package/tests/autocomplete.test.ts +6 -1
  44. package/tests/code-graph-apply-action.test.ts +173 -0
  45. package/tests/code-graph-apply.test.ts +185 -0
  46. package/tests/code-graph-commands.test.ts +211 -0
  47. package/tests/code-graph-db.test.ts +166 -0
  48. package/tests/code-graph-discovery.test.ts +38 -0
  49. package/tests/code-graph-git.test.ts +94 -0
  50. package/tests/code-graph-index.test.ts +175 -0
  51. package/tests/code-graph-loop.e2e.test.ts +159 -0
  52. package/tests/code-graph-mutations.test.ts +117 -0
  53. package/tests/code-graph-parser.test.ts +85 -0
  54. package/tests/code-graph-rollback.test.ts +100 -0
  55. package/tests/code-graph-summary-batching.test.ts +518 -0
  56. package/tests/code-graph-summary.test.ts +148 -0
  57. package/tests/compaction.test.ts +371 -57
  58. package/tests/config-command.test.ts +263 -0
  59. package/tests/exec.test.ts +808 -241
  60. package/tests/fixtures/code-graph/sample.js +36 -0
  61. package/tests/fixtures/code-graph/sample.py +20 -0
  62. package/tests/fixtures/code-graph/sample.ts +15 -0
  63. package/tests/graph-aware-file-tools.test.ts +411 -0
  64. package/tests/guard.test.ts +27 -1
  65. package/tests/plans.test.ts +10 -0
  66. package/tests/refine-prompts.test.ts +101 -2
  67. package/tests/refine-ui.test.ts +371 -72
  68. package/tests/state.test.ts +32 -0
  69. package/tests/subagent.test.ts +48 -20
  70. package/tools/analyze-refs.ts +263 -0
  71. package/tools/ask-choice.ts +159 -11
  72. package/tools/code-graph.ts +277 -0
  73. package/tools/graph-aware-file-tools.ts +392 -0
  74. package/tools/plans.ts +97 -2
  75. package/tools/refine.ts +61 -15
@@ -25,8 +25,9 @@ Bare repositories are refused with a clear error. A missing `git` executable is
25
25
  ```text
26
26
  <git-common-dir>/pi_plans/
27
27
  config.json
28
+ pi-vcc-config.json
28
29
  active.json
29
- runs/
30
+ runs/ # note: the refs root is a sibling — .git/pi-plans/refs (hyphenated), not under pi_plans/
30
31
  <run-id>/
31
32
  run.json
32
33
  decisions.jsonl
@@ -36,7 +37,7 @@ Bare repositories are refused with a clear error. A missing `git` executable is
36
37
  cache/
37
38
  ```
38
39
 
39
- `config.json` is stable workspace preference state. `active.json` and `runs/` are run state. Large external references stay outside the repository by default under `~/.cache/pi-plans/refs/`, with metadata recorded in the run state and public artifacts.
40
+ `config.json` is stable workspace preference state. `pi-vcc-config.json` is the repo-private compaction config used only by pi-plans' VCC-style compact hook. `active.json` and `runs/` are run state. Reference downloads go to the configured `refs_root` (asked once per workspace when unset; the recommended `.git/pi-plans/refs/` sits inside the git dir so git never tracks it), with metadata recorded in the run state and public artifacts.
40
41
 
41
42
  ## Config Schema
42
43
 
@@ -60,7 +61,10 @@ The default config is:
60
61
  },
61
62
  "artifact_root": "./docs/pi-plans",
62
63
  "artifact_root_source": "unset",
63
- "artifact_root_updated_at": null
64
+ "artifact_root_updated_at": null,
65
+ "refs_root": null,
66
+ "refs_root_source": "unset",
67
+ "refs_root_updated_at": null
64
68
  }
65
69
  ```
66
70
 
@@ -74,8 +78,33 @@ Rules:
74
78
  - `artifact_root` is relative to the target workspace unless absolute.
75
79
  - `artifact_root_source` is `user`, `auto`, or `unset`.
76
80
  - `artifact_root_updated_at` is the selection timestamp or `null` before confirmation.
81
+ - `refs_root` is where plan-with-refs downloads references, relative to the target workspace unless absolute, or `null` before selection; `refs_root_source` is `user`, `auto`, or `unset`; `refs_root_updated_at` is the selection timestamp or `null`.
77
82
  - There is intentionally no `effort` field: subagents inherit the dispatching session's model and thinking level unless an exact selector is stored. The real lever is the main session's thinking level at refine time.
78
83
 
84
+ ## VCC Compact Config
85
+
86
+ `pi-vcc-config.json` is scaffolded under the resolved `<git-common-dir>/pi_plans/` state root when an active planning or execution compaction hook first needs it. It is independent from `config.json` so planning preferences, run state, and compact policy can evolve separately.
87
+
88
+ Default values:
89
+
90
+ ```json
91
+ {
92
+ "overrideDefaultCompaction": true,
93
+ "smartKeepTail": true,
94
+ "continueAfterThresholdCompact": true,
95
+ "debug": false
96
+ }
97
+ ```
98
+
99
+ Rules:
100
+
101
+ - Only the repo-private file is read. Upstream global pi-vcc config such as `~/.pi/agent/pi-vcc-config.json` and `PI_VCC_CONFIG_PATH` are ignored.
102
+ - Missing files are created with defaults; valid files keep user values and receive missing default keys; invalid JSON is never clobbered and the runtime falls back to defaults for that read.
103
+ - `overrideDefaultCompaction:false` returns ordinary Pi manual/threshold/overflow compactions to Pi core. Explicit pi-plans internal compact hints can still use the VCC path.
104
+ - `smartKeepTail:true` starts from the requested/default keep count and may retain more recent user turns when the retained tail remains within the safe token budget. Explicit `keep:N` is honored.
105
+ - `continueAfterThresholdCompact:true` permits one hidden continuation after successful threshold/overflow compaction only on Pi versions that still need extension-driven resume behavior. Plain manual `/compact` never auto-continues, and `/compact <text>` sends the text once as the follow-up prompt.
106
+ - `debug:false` writes no diagnostics; `debug:true` writes a best-effort `/tmp/pi-vcc-debug.json` snapshot for local troubleshooting.
107
+
79
108
  ## Language Setting
80
109
 
81
110
  Before the first product planning question, check the persisted config (`plans` action `show`). If `language.tag` is missing or invalid, ask exactly one `ask_choice` question:
@@ -88,7 +117,14 @@ Before the first product planning question, check the persisted config (`plans`
88
117
 
89
118
  Persist with `plans` (`set-language`, `languageSource: "user"`). Use the selected language for visible questions, choices, review summaries, criticizer questions, and Markdown artifacts. Keep IDs, file paths, command names, JSON keys, and protocol labels stable in English.
90
119
 
91
- ## Planning Docs Location
120
+ ## Code Graph Enabled
121
+
122
+ `graph_enabled` (`boolean | null`) records whether the workspace wants graph-aware read/write/edit wrappers and `code_graph` mutations for indexed source files. `null` means the question was never asked: the first `plans` `init`/`show` in a workspace returns a `hint` instructing the agent to ask the user once via `ask_choice` (recommended: yes) and persist with the `plans` tool (`set-graph-enabled`, `enabled: true|false`). This question does not count against the planning-question limit. `/enable-graph` and `/disable-graph` toggle it later; disable refuses while graph drift is dirty. When enabled, DB-first staged edits are materialized agent-side via the `code_graph` tool's `apply` action (same planning/accepted gate as `/apply-graph`; refused for read-only refiner subagents via the `PI_PLANS_REFINER` env marker; the result carries per-file counts and a post-apply drift summary and never changes run status).
123
+
124
+ ## `/config-pi-plans`
125
+
126
+ `/config-pi-plans` is an interactive workspace configuration wizard. It re-asks the workspace language, planning docs root, refs root, code graph toggle, reviewer mode/model, and criticizer mode/model, then writes the chosen defaults back to `.git/pi_plans/config.json`. When code graph is enabled, the extension also overrides built-in `read`/`write`/`edit` for indexed source files so graph-backed source reads and DB-first edits happen automatically. Model pickers can reuse the current session model, any available selector surfaced by `ctx.scopedModels` or the model registry, or a manually entered exact `provider/model` string. If a run is already active, only the workspace defaults change; the active run's `artifact_dir` and `language_tag` stay unchanged.
127
+
92
128
 
93
129
  Before the first product planning question, check the persisted config again. If `artifact_root_source` is missing or `unset`, ask exactly one `ask_choice` question:
94
130
 
@@ -99,6 +135,15 @@ Before the first product planning question, check the persisted config again. If
99
135
 
100
136
  Persist with `plans` (`set-artifact-root`, `artifactRoot: <selected path>`, `artifactRootSource: "user"` or `"auto"`). Use the selected path for the run's artifact directory root. This question does not count against the planning-question limit.
101
137
 
138
+ Before downloading any reference in a plan-with-refs flow, check the persisted config. If `refs_root_source` is missing or `unset`, ask exactly one `ask_choice` question:
139
+
140
+ 1. `.git/pi-plans/refs` — recommended; inside the git dir so git never tracks the downloads.
141
+ 2. `./refs/` — inside the worktree; the planning write guard allows writes under the configured refs root.
142
+ 3. `~/.cache/pi-plans/refs/` — outside the repository; matches the historical default.
143
+ 4. `Other` / `Auto-complete` — select the recommended path.
144
+
145
+ Persist with `plans` (`set-refs-root`, `refsRoot: <selected path>`, `refsRootSource: "user"` or `"auto"`). Download references under this root. This question does not count against the planning-question limit.
146
+
102
147
 
103
148
  Before running a `refine` round, read the role setting from the persisted config.
104
149
 
@@ -128,10 +173,12 @@ When `mode` is `delegated-subagent`, the `refine` tool spawns a read-only `pi` s
128
173
 
129
174
  The main agent consolidates the results, records dispositions, revises the plan, and asks the next merged accept/execute question — all in the same turn.
130
175
 
176
+ The `analyze_refs` tool (plan-with-refs) uses the same spawning machinery with the **reviewer** role's gates (`mode` must be `delegated-subagent`; a confirmed `current-session` reviewer is refused with guidance to switch, since analysis is spawn-only) and the reviewer's model selector. Each downloaded reference gets one independent read-only subagent whose system prompt comes from `agents/ref-analyst.md` and whose working directory is that reference's own directory; lanes never get `code_graph`. Lanes run in sequential batches of at most 3 under a standalone overlay titled `Refs`; each batch's controller opens and closes exactly like a single refine round. Successful spawns are recorded best-effort in `subagents.jsonl` with role `ref-analyst` (skipped when no active run exists, e.g. adhoc calls). The structured per-reference sections come back as the tool result; the main agent owns `REF_ANALYSIS.md` and fills `coverage`/`gaps` in `refs.jsonl` via `plans` (`record-ref`).
177
+
131
178
  ## Run State
132
179
 
133
180
  One run directory per planning request: `<git-common-dir>/pi_plans/runs/<YYYYMMDDTHHMMSSZ-topic>/` (second-precision; `-2`, `-3` suffixes on collision).
134
181
 
135
182
  `run.json` includes: run ID; skill name; original request; target workspace; artifact directory; language tag; status (`planning` → `accepted` → `executing` → `done`, with `stopped`/`abandoned` as exits); timestamps.
136
183
 
137
- `decisions.jsonl` is appended automatically by `ask_choice` (question, options, answer, answer source). `subagents.jsonl` records reviewer/criticizer spawns. `refs.jsonl` records reference metadata via `plans` (`record-ref`).
184
+ `decisions.jsonl` is appended automatically by `ask_choice` (question, options, answer, answer source). `subagents.jsonl` records reviewer/criticizer/ref-analyst spawns. `refs.jsonl` records reference metadata via `plans` (`record-ref`).
@@ -21,12 +21,17 @@ const REQUIRED_TOOL_FILES = [
21
21
  "tools/plans.ts",
22
22
  "tools/ask-choice.ts",
23
23
  "tools/refine.ts",
24
+ "tools/analyze-refs.ts",
24
25
  "tools/execute-plan.ts",
26
+ "tools/code-graph.ts",
25
27
  "src/state.ts",
26
28
  "src/guard.ts",
27
29
  "src/plan.ts",
28
30
  "src/subagent.ts",
29
31
  "src/exec.ts",
32
+ "src/code-graph/runtime.ts",
33
+ "src/code-graph/schema.ts",
34
+ "src/code-graph/store.ts",
30
35
  ];
31
36
 
32
37
  const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -16,15 +16,15 @@ Read `../../references/pi-planning-workflow.md` and `../../references/state-and-
16
16
  1. Inspect the target Git repo read-only before external research so search terms match the actual codebase and constraints.
17
17
  2. Create the `.git/pi_plans` run state and planning artifact directory once the topic is clear (`plans` action `start-run`).
18
18
  3. Search proactively for related projects, articles, papers, docs, and prior art. Prefer a websearch skill when installed; otherwise use bash tools such as `curl` or `gh` when already available.
19
- 4. Download or clone at least 3 credible references before writing `PLAN_v1.md`; store large downloads outside the target repo under `~/.cache/pi-plans/refs/<repo-slug>/<topic>/`. Landing pages, README-only snapshots, abstracts, package metadata, or curl-only fragments do not count when deeper source material is available.
19
+ 4. Before the first download, check `refs_root` in `.git/pi_plans/config.json` (`plans` action `show`). If it is unset, ask exactly one `ask_choice` question — recommended `.git/pi-plans/refs/` (inside the git dir, never tracked), second `./refs/`, third `~/.cache/pi-plans/refs/` — and persist with `plans` (`set-refs-root`); this question does not count against the planning-question limit. Then download or clone at least 3 credible references before writing `PLAN_v1.md`, under the configured refs root (create per-ref subdirectories). Landing pages, README-only snapshots, abstracts, package metadata, or curl-only fragments do not count when deeper source material is available.
20
20
  5. For every reference, record source metadata and local path in `REF_ANALYSIS.md` and in the run's `refs.jsonl` (via `plans` action `record-ref`): title, URL, kind, retrieval method, date accessed, local path, coverage, and evidence gaps.
21
- 6. For every reference, produce a structured analysis artifact (manual structured read recorded in `REF_ANALYSIS.md`) before asking adoption questions.
21
+ 6. For every reference, run the `analyze_refs` tool (required path — it replaces manual structured reads): one independent read-only subagent per reference deep-reads it and returns structured sections (Overview / Key Mechanisms And Design Tradeoffs / Adoptable Ideas For The Target Repo / Pitfalls And Anti-Patterns / Evidence Citations / Coverage / Evidence Gaps). Paste each analysis into `REF_ANALYSIS.md` and fill `coverage` and `gaps` in `refs.jsonl` via `plans` (`record-ref`) before asking adoption questions.
22
22
  7. For every reference after analysis, ask at least 3 ref-specific adoption questions via `ask_choice` before using its ideas in `PLAN_v1.md`; each based on downloaded content, recommended option first, `Other` second-last, `Auto-complete` last (the tool appends both).
23
23
  8. Block rather than pad if fewer than 3 credible references exist, unless the user explicitly narrows the topic or waives the minimum. `Auto-complete` cannot grant this waiver.
24
24
  9. Continue with big-plan depth: at least 10 planning questions, required web research during brainstorming and refinement (`refine` `reviewers: 3` reviewer round, then a criticizer round), no refinement limit, at most five high-priority comments or questions per refinement round. Then the merged accept/execute question (ask_choice with `autoComplete: false`: ✓ Accept & execute now / Accept, don't execute yet / another round) and the `execute_plan` tool.
25
25
 
26
26
  ## REF_ANALYSIS.md
27
27
 
28
- Include: original request and repo evidence that shaped the search; attempted queries and selection criteria; references selected and rejected; local download paths; structured analysis summaries; adoption questions and recorded answers; accepted ideas, rejected ideas, and reasons; evidence gaps and user-granted waivers; language, reviewer, and criticizer settings used.
28
+ Include: original request and repo evidence that shaped the search; attempted queries and selection criteria; references selected and rejected; configured refs root and local download paths; the per-reference `analyze_refs` structured analyses (pasted verbatim, one section per reference); adoption questions and recorded answers; accepted ideas, rejected ideas, and reasons; evidence gaps and user-granted waivers; language, reviewer, and criticizer settings used.
29
29
 
30
30
  Reference ideas are not eligible for `PLAN_v1.md` until their adoption question answers are recorded.
@@ -0,0 +1,483 @@
1
+ /**
2
+ * Slash-command handlers for /init-graph, /update-graph, and /apply-graph.
3
+ * They defer loading the runtime and SQLite until invoked so other pi-plans tools stay usable
4
+ * even when the graph feature is unavailable.
5
+ */
6
+
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ loadGraphRuntime,
10
+ describeRuntimeIssues,
11
+ type RuntimeStatus,
12
+ } from "./runtime.ts";
13
+ import { resolveCanonicalWorktree } from "./paths.ts";
14
+ import { Store } from "./store.ts";
15
+ import { runIndex } from "./indexer.ts";
16
+ import { makeBackend } from "./parsers/javascript.ts";
17
+ import { PythonBackend } from "./parsers/python.ts";
18
+ import type { ParserBackend } from "./parser.ts";
19
+ import type { Language } from "./types.ts";
20
+ import { materialize, type MaterializeReport } from "./materialize.ts";
21
+ import { generateSummaries, type CompletionHandle, type SummaryReport } from "./summary.ts";
22
+ import { readActive, getRun, setRunStatus } from "../state.ts";
23
+
24
+ interface CommandContext {
25
+ cwd: string;
26
+ hasUI: boolean;
27
+ ui: {
28
+ notify: (message: string, kind?: "info" | "warning" | "error") => void;
29
+ confirm: (title: string, body: string) => Promise<boolean>;
30
+ };
31
+ modelRegistry?: {
32
+ find?: (provider: string, id: string) => unknown;
33
+ complete?: (
34
+ model: unknown,
35
+ context: { messages: Array<{ role: "user"; content: string }> },
36
+ options: Record<string, unknown>,
37
+ ) => Promise<{ content: Array<{ type: "text"; text: string }>; stopReason?: string }>;
38
+ hasConfiguredAuth?: (model: unknown) => boolean;
39
+ };
40
+ model?: unknown;
41
+ thinkingLevel?: string;
42
+ }
43
+
44
+ async function buildParsers(runtime: Awaited<ReturnType<typeof loadGraphRuntime>>["runtime"]): Promise<Record<Language, ParserBackend>> {
45
+ const ParserCtor = runtime.parser.Parser as unknown as new () => {
46
+ parse(input: string | Buffer): unknown;
47
+ setLanguage(language: unknown): void;
48
+ };
49
+ return {
50
+ javascript: makeBackend("javascript", ParserCtor, runtime.parser.javascript),
51
+ typescript: makeBackend("typescript", ParserCtor, runtime.parser.typescript),
52
+ tsx: makeBackend("tsx", ParserCtor, runtime.parser.tsx),
53
+ python: new PythonBackend(ParserCtor, runtime.parser.python),
54
+ };
55
+ }
56
+
57
+ interface Bootstrap {
58
+ store: Store;
59
+ paths: ReturnType<typeof resolveCanonicalWorktree>;
60
+ parsers: Record<Language, ParserBackend>;
61
+ runtimeStatus: RuntimeStatus;
62
+ }
63
+
64
+ async function bootstrap(ctx: CommandContext, opts: { reindex?: boolean }): Promise<Bootstrap | null> {
65
+ const paths = resolveCanonicalWorktree(ctx.cwd);
66
+ const { runtime, status } = await loadGraphRuntime();
67
+ if (status.issues.length > 0 && !status.sqliteAvailable && !status.parserAvailable) {
68
+ ctx.ui.notify(`code-graph unavailable: ${describeRuntimeIssues(status).join("; ")}`, "error");
69
+ return null;
70
+ }
71
+ const store = new Store(
72
+ { dbPath: paths.codeGraphDb, worktreeRoot: paths.worktreeRoot, gitCommonDir: paths.gitCommonDir },
73
+ runtime.sqlite,
74
+ );
75
+ try {
76
+ store.checkWorktree(paths.worktreeRoot, paths.gitCommonDir);
77
+ } catch (error) {
78
+ store.close();
79
+ ctx.ui.notify(`code-graph: ${(error as Error).message}`, "error");
80
+ return null;
81
+ }
82
+ const parsers = await buildParsers(runtime);
83
+ void opts;
84
+ return { store, paths, parsers, runtimeStatus: status };
85
+ }
86
+
87
+ function activePlanningRun(workdir: string): ActiveInfo | null {
88
+ const active = readActive(workdir);
89
+ if (!active) return null;
90
+ const run = getRun(workdir, active.run_id);
91
+ if (!run) return null;
92
+ return run.status === "planning" || run.status === "accepted" ? active : null;
93
+ }
94
+
95
+ function denyActivePlanning(ctx: CommandContext): boolean {
96
+ return activePlanningRun(ctx.cwd) !== null;
97
+ }
98
+
99
+ export async function initGraphCommand(args: string, ctx: CommandContext): Promise<void> {
100
+ const flags = parseCommandArgs(args).flags;
101
+ const preflightPaths = resolveCanonicalWorktree(ctx.cwd);
102
+ const preferRebuild = flags.has("reindex") || !ctx.hasUI;
103
+ if (fs.existsSync(preflightPaths.codeGraphDb) && !preferRebuild) {
104
+ const rebuild = await ctx.ui.confirm(
105
+ "code-graph DB already exists",
106
+ `${preflightPaths.codeGraphDb}\n\nRebuild the graph with the current /init-graph flow, or sync changed paths via /update-graph?\n\nYes = rebuild the full graph\nNo = sync changed paths only`,
107
+ );
108
+ if (!rebuild) {
109
+ const bootstrapResult = await bootstrap(ctx, {});
110
+ if (!bootstrapResult) return;
111
+ const { store, paths, parsers } = bootstrapResult;
112
+ try {
113
+ await runChangedPathSync(args, ctx, store, paths, parsers);
114
+ } catch (error) {
115
+ ctx.ui.notify(`code-graph update failed: ${(error as Error).message}`, "error");
116
+ } finally {
117
+ store.close();
118
+ }
119
+ return;
120
+ }
121
+ }
122
+ const bootstrapResult = await bootstrap(ctx, { reindex: flags.has("reindex") });
123
+ if (!bootstrapResult) return;
124
+ const { store, paths, parsers, runtimeStatus } = bootstrapResult;
125
+ try {
126
+ // Pre-index chore commit: snapshot any uncommitted work so the DB indexes
127
+ // a recoverable state (--no-commit skips this).
128
+ let choreCommit = "";
129
+ if (!flags.has("no-commit")) {
130
+ choreCommit = gitAddAllAndCommit(paths.worktreeRoot, "chore(code-graph): pre-init snapshot");
131
+ if (choreCommit) ctx.ui.notify(`code-graph pre-init commit: ${choreCommit.slice(0, 12)}`, "info");
132
+ }
133
+ const report = await runIndex({
134
+ store,
135
+ worktreeRoot: paths.worktreeRoot,
136
+ parsers,
137
+ reindex: flags.has("reindex"),
138
+ });
139
+ ctx.ui.notify(
140
+ `code-graph indexed ${report.functionsIndexed} function(s) in ${report.filesScanned} file(s) — ${report.edgesResolved} resolved, ${report.edgesUnresolved} unresolved`,
141
+ "info",
142
+ );
143
+ if (!flags.has("no-summary") && ctx.hasUI && ctx.modelRegistry?.complete && ctx.model) {
144
+ const handle: CompletionHandle = {
145
+ complete: async (request) =>
146
+ await ctx.modelRegistry!.complete!(ctx.model, { messages: request.messages }, {}),
147
+ model: () => ctx.model as { id?: string; provider?: string; api?: string; reasoning?: boolean } | undefined,
148
+ thinkingLevel: () => ctx.thinkingLevel,
149
+ hasUI: ctx.hasUI,
150
+ confirm: ctx.ui.confirm,
151
+ notify: ctx.ui.notify,
152
+ };
153
+ try {
154
+ const summary: SummaryReport = await generateSummaries({ store, ctx: handle, skipConsent: flags.has("consent") });
155
+ ctx.ui.notify(
156
+ `code-graph summaries: ${summary.ok} ok, ${summary.failed} failed, ${summary.declined} declined`,
157
+ summary.failed > 0 ? "warning" : "info",
158
+ );
159
+ } catch (error) {
160
+ ctx.ui.notify(`code-graph summary failed: ${(error as Error).message}`, "warning");
161
+ }
162
+ }
163
+ ctx.ui.notify(
164
+ `code-graph db: ${paths.codeGraphDb} (Node ${runtimeStatus.nodeVersion}${runtimeStatus.hasExperimentalSqliteFlag ? " +sqlite-flag" : ""})`,
165
+ "info",
166
+ );
167
+ // Post-index snapshot: anchor drift checks to this commit + status.
168
+ const head = gitHead(paths.worktreeRoot) || choreCommit;
169
+ const uncommitted = gitStatusPorcelain(paths.worktreeRoot).map((entry) => entry.path);
170
+ store.upsertSnapshot(head, uncommitted);
171
+ ctx.ui.notify(`code-graph snapshot: ${head.slice(0, 12) || "(no commits)"} · ${uncommitted.length} uncommitted path(s)`, "info");
172
+ } catch (error) {
173
+ ctx.ui.notify(`code-graph index failed: ${(error as Error).message}`, "error");
174
+ } finally {
175
+ store.close();
176
+ }
177
+ }
178
+
179
+ export interface ApplyGraphCoreResult {
180
+ refused?: string;
181
+ failed?: string;
182
+ report?: MaterializeReport;
183
+ drift?: { pending: number; ok: boolean } | null;
184
+ }
185
+
186
+ /**
187
+ * Shared core for /apply-graph and the code_graph "apply" tool action:
188
+ * gate (planning/accepted refusal) → bootstrap → materialize → drift summary.
189
+ * No notifications and no run-status side effects; callers own presentation
190
+ * and lifecycle transitions.
191
+ */
192
+ export async function applyGraphCore(workdir: string, opts: { force?: boolean } = {}): Promise<ApplyGraphCoreResult> {
193
+ const planned = activePlanningRun(workdir);
194
+ if (planned) {
195
+ return { refused: `code-graph apply refused: a planning run is currently planning or accepted (run ${planned.run_id}).` };
196
+ }
197
+ let bootstrapFailure = "";
198
+ const bootstrapResult = await bootstrap(
199
+ { cwd: workdir, hasUI: false, ui: { notify: (message: string) => { bootstrapFailure ||= message; }, confirm: async () => false } } as never,
200
+ {},
201
+ );
202
+ if (!bootstrapResult) return { failed: bootstrapFailure || "code-graph runtime unavailable" };
203
+ const { store, paths } = bootstrapResult;
204
+ try {
205
+ const report = materialize({ store, worktreeRoot: paths.worktreeRoot, force: opts.force ?? false });
206
+ let drift: ApplyGraphCoreResult["drift"] = null;
207
+ try {
208
+ const d = computeDrift(store, paths.worktreeRoot);
209
+ drift = { pending: d.pending.length, ok: d.ok };
210
+ } catch {
211
+ drift = null; // drift summary is best-effort; materialization already succeeded
212
+ }
213
+ return { report, drift };
214
+ } catch (error) {
215
+ return { failed: (error as Error).message };
216
+ } finally {
217
+ store.close();
218
+ }
219
+ }
220
+
221
+ export async function applyGraphCommand(args: string, ctx: CommandContext): Promise<void> {
222
+ if (denyActivePlanning(ctx)) {
223
+ ctx.ui.notify("code-graph apply refused: a planning run is currently planning or accepted.", "error");
224
+ return;
225
+ }
226
+ const flags = parseCommandArgs(args).flags;
227
+ const core = await applyGraphCore(ctx.cwd, { force: flags.has("force") });
228
+ if (core.refused) {
229
+ ctx.ui.notify(core.refused, "error");
230
+ return;
231
+ }
232
+ if (core.failed || !core.report) {
233
+ ctx.ui.notify(`code-graph apply failed: ${core.failed ?? "unknown error"}`, "error");
234
+ return;
235
+ }
236
+ const report = core.report;
237
+ const stale = report.files.filter((file) => file.status === "stale").length;
238
+ const errors = report.files.filter((file) => file.status === "error").length;
239
+ const ok = report.files.filter((file) => file.status === "ok").length;
240
+ const deleted = report.files.filter((file) => file.status === "deleted").length;
241
+ const skipped = report.files.filter((file) => file.status === "skipped-missing").length;
242
+ ctx.ui.notify(
243
+ `code-graph apply: ${ok} ok, ${deleted} deleted, ${stale} stale, ${skipped} skipped-missing, ${errors} error`,
244
+ errors > 0 ? "error" : "info",
245
+ );
246
+ const active = readActive(ctx.cwd);
247
+ if (active) setRunStatus(ctx.cwd, active.run_id, "executing");
248
+ }
249
+
250
+ export async function graphStatusCommand(_args: string, ctx: CommandContext): Promise<void> {
251
+ const bootstrapResult = await bootstrap(ctx, {});
252
+ if (!bootstrapResult) return;
253
+ const { store, paths } = bootstrapResult;
254
+ try {
255
+ const files = store.read(() => store.db.prepare("SELECT COUNT(*) AS c FROM files").get()) as { c: number };
256
+ const functions = store.read(() => store.db.prepare("SELECT COUNT(*) AS c FROM functions").get()) as { c: number };
257
+ const edges = store.read(() => store.db.prepare("SELECT COUNT(*) AS c FROM call_edges").get()) as { c: number };
258
+ ctx.ui.notify(
259
+ `code-graph: ${functions.c} functions, ${files.c} files, ${edges.c} edges — ${paths.codeGraphDb}`,
260
+ "info",
261
+ );
262
+ } finally {
263
+ store.close();
264
+ }
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // /update-graph, /graph-drift, /enable-graph, /disable-graph
269
+ // ---------------------------------------------------------------------------
270
+
271
+ import { gitAddAllAndCommit, gitDiffNameOnly, gitHead, gitStatusPorcelain, parseCommandArgs } from "./git.ts";
272
+ import { isIndexablePath } from "./discovery.ts";
273
+ import { hashText } from "./parser.ts";
274
+ import * as fs from "node:fs";
275
+ import * as path from "node:path";
276
+ import { loadConfig, resolveStateRootOrNull, setGraphEnabled } from "../state.ts";
277
+
278
+ export interface DriftResult {
279
+ ok: boolean;
280
+ /** Invariant (a): per-file hash drift, unless pending_kind is set. */
281
+ hashDrift: Array<{ path: string; kind: "hash-mismatch" | "pending-update" | "pending-delete" }>;
282
+ /** Invariant (b): indexable uncommitted paths missing from the DB. */
283
+ unindexed: string[];
284
+ /** Invariant (c), informational: snapshot vs current git state. */
285
+ snapshot: { stale: boolean; headCommit: string; snapshotHead: string; uncommittedAtSnapshot: string[] };
286
+ pending: Array<{ path: string; kind: string }>;
287
+ recommendation: string;
288
+ }
289
+
290
+ /** Normalize a porcelain/git path ("src/a.ts", "a.ts") to the DB key form ("./a.ts"). */
291
+ export function toDbKey(porcelainPath: string): string {
292
+ return porcelainPath.includes("/") ? porcelainPath : `./${porcelainPath}`;
293
+ }
294
+
295
+ export function computeDrift(store: Store, worktreeRoot: string): DriftResult {
296
+ const entries = gitStatusPorcelain(worktreeRoot);
297
+ const renameOrigPaths = new Set(
298
+ entries.filter((entry) => entry.origPath !== null).map((entry) => toDbKey(entry.origPath!)),
299
+ );
300
+ const porcelainPaths = entries.filter((entry) => !entry.status.includes("D")).map((entry) => toDbKey(entry.path));
301
+ const indexableChanged = [...new Set([...porcelainPaths, ...renameOrigPaths])].filter(isIndexablePath);
302
+
303
+ const filesRows = store.read(() =>
304
+ store.db.prepare(`SELECT file_dir, file_name, source_hash, pending_kind FROM files`).all(),
305
+ ) as Array<{ file_dir: string; file_name: string; source_hash: string; pending_kind: string | null }>;
306
+ const byPath = new Map(filesRows.map((row) => [`${row.file_dir}/${row.file_name}`, row]));
307
+
308
+ const hashDrift: DriftResult["hashDrift"] = [];
309
+ const pending: DriftResult["pending"] = [];
310
+ const unindexed: string[] = [];
311
+ for (const row of filesRows) {
312
+ const rel = `${row.file_dir}/${row.file_name}`;
313
+ if (row.pending_kind === "update") {
314
+ pending.push({ path: rel, kind: "update" });
315
+ hashDrift.push({ path: rel, kind: "pending-update" });
316
+ continue;
317
+ }
318
+ if (row.pending_kind === "delete") {
319
+ pending.push({ path: rel, kind: "delete" });
320
+ hashDrift.push({ path: rel, kind: "pending-delete" });
321
+ continue;
322
+ }
323
+ const absolute = path.join(worktreeRoot, row.file_dir === "." ? row.file_name : path.join(row.file_dir, row.file_name));
324
+ let onDisk: string | null = null;
325
+ try {
326
+ onDisk = fs.readFileSync(absolute, "utf8");
327
+ } catch {
328
+ onDisk = null;
329
+ }
330
+ if (onDisk === null) {
331
+ // Deleted on disk but not marked pending: untracked deletion.
332
+ hashDrift.push({ path: rel, kind: "hash-mismatch" });
333
+ continue;
334
+ }
335
+ if (hashText(onDisk) !== row.source_hash) {
336
+ hashDrift.push({ path: rel, kind: "hash-mismatch" });
337
+ }
338
+ }
339
+ for (const changed of indexableChanged) {
340
+ if (renameOrigPaths.has(changed)) continue; // rename-old: purge is the fix, not reindex
341
+ if (!byPath.has(changed)) unindexed.push(changed);
342
+ }
343
+
344
+ const snapshot = store.readLatestSnapshot();
345
+ const head = gitHead(worktreeRoot);
346
+ const snapshotInfo = {
347
+ stale: snapshot !== null && snapshot.headCommit !== head,
348
+ headCommit: head,
349
+ snapshotHead: snapshot?.headCommit ?? "(none)",
350
+ uncommittedAtSnapshot: snapshot?.uncommittedPaths ?? [],
351
+ };
352
+
353
+ const needsUpdate = hashDrift.some((item) => item.kind === "hash-mismatch") || unindexed.length > 0;
354
+ const needsApply = pending.length > 0;
355
+ const recommendation = needsUpdate
356
+ ? "run /update-graph to reindex changed paths"
357
+ : needsApply
358
+ ? "run code_graph apply (or /apply-graph) to materialize pending DB edits"
359
+ : "in sync";
360
+ return {
361
+ ok: hashDrift.every((item) => item.kind !== "hash-mismatch") && unindexed.length === 0,
362
+ hashDrift,
363
+ unindexed,
364
+ snapshot: snapshotInfo,
365
+ pending,
366
+ recommendation,
367
+ };
368
+ }
369
+
370
+ async function runChangedPathSync(
371
+ args: string,
372
+ ctx: CommandContext,
373
+ store: Store,
374
+ paths: Bootstrap["paths"],
375
+ parsers: Record<Language, ParserBackend>,
376
+ ): Promise<void> {
377
+ const { flags, values } = parseCommandArgs(args);
378
+ const entries = gitStatusPorcelain(paths.worktreeRoot);
379
+ const renameOrigPaths = new Set(
380
+ entries.filter((entry) => entry.origPath !== null).map((entry) => toDbKey(entry.origPath!)),
381
+ );
382
+ const porcelainPaths = entries.map((entry) => toDbKey(entry.path));
383
+ // --base <commit>: union porcelain with diff-vs-base so pinned-base
384
+ // changes (possibly already committed) are included.
385
+ const basePath = values.get("base");
386
+ const basePaths = basePath
387
+ ? gitDiffNameOnly(paths.worktreeRoot, basePath).map(toDbKey)
388
+ : [];
389
+ const candidates = [...new Set([...porcelainPaths, ...renameOrigPaths, ...basePaths])].filter(isIndexablePath);
390
+ if (candidates.length === 0) {
391
+ ctx.ui.notify("code-graph update: no changed indexable paths — nothing to do", "info");
392
+ return;
393
+ }
394
+ if (flags.has("dry-run")) {
395
+ ctx.ui.notify(`code-graph update (dry-run): would reindex ${candidates.length} path(s):\n${candidates.join("\n")}`, "info");
396
+ return;
397
+ }
398
+ const report = await runIndex({ store, worktreeRoot: paths.worktreeRoot, parsers, paths: candidates });
399
+ ctx.ui.notify(
400
+ `code-graph update: ${report.reindexedPaths.length} reindexed, ${report.purgedPaths.length} purged, ${report.functionsIndexed} function(s)`,
401
+ "info",
402
+ );
403
+ }
404
+
405
+ export async function updateGraphCommand(args: string, ctx: CommandContext): Promise<void> {
406
+ const bootstrapResult = await bootstrap(ctx, {});
407
+ if (!bootstrapResult) return;
408
+ const { store, paths, parsers } = bootstrapResult;
409
+ try {
410
+ await runChangedPathSync(args, ctx, store, paths, parsers);
411
+ } catch (error) {
412
+ ctx.ui.notify(`code-graph update failed: ${(error as Error).message}`, "error");
413
+ } finally {
414
+ store.close();
415
+ }
416
+ }
417
+
418
+ export async function graphDriftCommand(args: string, ctx: CommandContext): Promise<void> {
419
+ const { flags } = parseCommandArgs(args);
420
+ const bootstrapResult = await bootstrap(ctx, {});
421
+ if (!bootstrapResult) return;
422
+ const { store, paths } = bootstrapResult;
423
+ try {
424
+ const drift = computeDrift(store, paths.worktreeRoot);
425
+ if (flags.has("json")) {
426
+ const payload = flags.has("commit-aware")
427
+ ? { ...drift, gitStatus: gitStatusPorcelain(paths.worktreeRoot) }
428
+ : drift;
429
+ ctx.ui.notify(JSON.stringify(payload, null, 2), "info");
430
+ return;
431
+ }
432
+ const lines: string[] = [];
433
+ lines.push(`graph drift: ${drift.ok ? "OK" : "DIRTY"} — ${drift.recommendation}`);
434
+ for (const item of drift.hashDrift) {
435
+ if (item.kind === "hash-mismatch") lines.push(` (a) hash mismatch: ${item.path}`);
436
+ else if (item.kind === "pending-update") lines.push(` (a) pending apply (update): ${item.path}`);
437
+ else lines.push(` (a) pending apply (delete): ${item.path}`);
438
+ }
439
+ for (const missing of drift.unindexed) lines.push(` (b) uncommitted but unindexed: ${missing}`);
440
+ if (drift.snapshot.stale) {
441
+ lines.push(` (c) snapshot stale: recorded ${drift.snapshot.snapshotHead.slice(0, 8)} vs HEAD ${drift.snapshot.headCommit.slice(0, 8)} (run /init-graph after committing to refresh)`);
442
+ }
443
+ if (flags.has("commit-aware")) {
444
+ for (const entry of gitStatusPorcelain(paths.worktreeRoot)) {
445
+ lines.push(` (git) ${entry.status} ${entry.path}${entry.origPath ? ` (from ${entry.origPath})` : ""}`);
446
+ }
447
+ }
448
+ ctx.ui.notify(lines.join("\n"), drift.ok ? "info" : "warning");
449
+ } catch (error) {
450
+ ctx.ui.notify(`graph drift failed: ${(error as Error).message}`, "error");
451
+ } finally {
452
+ store.close();
453
+ }
454
+ }
455
+
456
+ export async function enableGraphCommand(_args: string, ctx: CommandContext): Promise<void> {
457
+ setGraphEnabled(ctx.cwd, true);
458
+ ctx.ui.notify("code-graph enabled: agents will use graph-aware read/write/edit on indexed source files. Run /init-graph to index.", "info");
459
+ }
460
+
461
+ export async function disableGraphCommand(_args: string, ctx: CommandContext): Promise<void> {
462
+ const stateRoot = resolveStateRootOrNull(ctx.cwd);
463
+ if (stateRoot && loadConfig(stateRoot).graph_enabled === true) {
464
+ const bootstrapResult = await bootstrap(ctx, {});
465
+ if (bootstrapResult) {
466
+ const { store, paths } = bootstrapResult;
467
+ try {
468
+ const drift = computeDrift(store, paths.worktreeRoot);
469
+ if (!drift.ok || drift.pending.length > 0) {
470
+ ctx.ui.notify(
471
+ `code-graph disable refused: worktree/DB is dirty (${drift.recommendation}). Fix drift first, then disable.`,
472
+ "error",
473
+ );
474
+ return;
475
+ }
476
+ } finally {
477
+ store.close();
478
+ }
479
+ }
480
+ }
481
+ setGraphEnabled(ctx.cwd, false);
482
+ ctx.ui.notify("code-graph disabled: agents fall back to Read/grep/ls. Re-enable anytime with /enable-graph.", "info");
483
+ }