@cruxy/cli 0.22.1 → 0.23.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 (60) hide show
  1. package/dist/approval/classify.js +18 -0
  2. package/dist/approval/prompt.js +11 -0
  3. package/dist/checkpoint/gate.d.ts +65 -0
  4. package/dist/checkpoint/gate.js +86 -0
  5. package/dist/checkpoint/index.d.ts +2 -0
  6. package/dist/checkpoint/index.js +2 -0
  7. package/dist/checkpoint/set-rollback.d.ts +51 -0
  8. package/dist/checkpoint/set-rollback.js +74 -0
  9. package/dist/cli/commands/rollback.d.ts +11 -6
  10. package/dist/cli/commands/rollback.js +93 -33
  11. package/dist/cli/commands/run.js +59 -10
  12. package/dist/cli/onboard.js +4 -1
  13. package/dist/cli/repl.d.ts +2 -2
  14. package/dist/cli/session-factory.d.ts +4 -3
  15. package/dist/cli/session-factory.js +98 -12
  16. package/dist/errors/constructors.d.ts +28 -0
  17. package/dist/errors/constructors.js +59 -0
  18. package/dist/errors/types.d.ts +20 -0
  19. package/dist/errors/types.js +26 -0
  20. package/dist/indexing/retriever.d.ts +29 -0
  21. package/dist/indexing/retriever.js +26 -0
  22. package/dist/indexing/service.js +3 -1
  23. package/dist/indexing/types.d.ts +7 -0
  24. package/dist/lsp/tools/common.d.ts +34 -7
  25. package/dist/lsp/tools/common.js +33 -11
  26. package/dist/lsp/tools/find-definition.js +2 -2
  27. package/dist/lsp/tools/find-references.js +10 -4
  28. package/dist/lsp/tools/get-diagnostics.js +6 -4
  29. package/dist/render/diff.js +42 -5
  30. package/dist/subagent/orchestrator.d.ts +15 -0
  31. package/dist/subagent/orchestrator.js +2 -0
  32. package/dist/testing/run-tests-tool.js +3 -0
  33. package/dist/tools/create-pull-request.d.ts +3 -0
  34. package/dist/tools/create-pull-request.js +50 -4
  35. package/dist/tools/file/apply-patch.js +2 -2
  36. package/dist/tools/file/edit-file.js +2 -2
  37. package/dist/tools/file/glob.d.ts +9 -2
  38. package/dist/tools/file/glob.js +73 -19
  39. package/dist/tools/file/grep-files.d.ts +12 -2
  40. package/dist/tools/file/grep-files.js +113 -38
  41. package/dist/tools/file/paths.d.ts +122 -9
  42. package/dist/tools/file/paths.js +165 -10
  43. package/dist/tools/file/read-file.js +2 -2
  44. package/dist/tools/file/write-file.js +2 -2
  45. package/dist/tools/git-status.d.ts +8 -1
  46. package/dist/tools/git-status.js +43 -11
  47. package/dist/tools/list-files.d.ts +9 -3
  48. package/dist/tools/list-files.js +48 -13
  49. package/dist/tools/search-codebase.d.ts +10 -0
  50. package/dist/tools/search-codebase.js +117 -14
  51. package/dist/tools/shell/exec.js +8 -1
  52. package/dist/tools/types.d.ts +63 -1
  53. package/dist/vcs/git.d.ts +8 -0
  54. package/dist/vcs/git.js +14 -0
  55. package/dist/vcs/github.d.ts +7 -1
  56. package/dist/vcs/github.js +10 -1
  57. package/dist/vcs/service.d.ts +8 -0
  58. package/dist/vcs/service.js +33 -1
  59. package/dist/vcs/types.d.ts +18 -2
  60. package/package.json +1 -1
@@ -1,7 +1,13 @@
1
1
  import { z } from "zod";
2
2
  import type { Tool } from "./types.js";
3
3
  /**
4
- * Proof-of-concept tool: list the entries of `ctx.cwd`, marking each as a
5
- * directory or a file. Takes no arguments.
4
+ * List the entries of a workspace root, marking each as a directory or a file.
5
+ *
6
+ * Multi-repo (C.26, Funnel B): with more than one declared root, it fans every
7
+ * root — each `fs.readdir(root.absPath)` reads exactly the root its section
8
+ * header names. A per-root read error is NAMED (not fatal), and a `root` argument
9
+ * scopes to one root.
6
10
  */
7
- export declare const listFilesTool: Tool<z.ZodObject<Record<string, never>>>;
11
+ export declare const listFilesTool: Tool<z.ZodObject<{
12
+ root: z.ZodOptional<z.ZodString>;
13
+ }>>;
@@ -1,27 +1,62 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import { z } from "zod";
3
+ import { contextWorkspace, resolveReadRoots } from "./file/paths.js";
4
+ /** Render one directory's entries (sorted, dir/file marked) or an empty note. */
5
+ async function listDir(absDir) {
6
+ const entries = await fs.readdir(absDir, { withFileTypes: true });
7
+ if (entries.length === 0) {
8
+ return "(empty directory)";
9
+ }
10
+ return entries
11
+ .slice()
12
+ .sort((a, b) => a.name.localeCompare(b.name))
13
+ .map((entry) => `${entry.isDirectory() ? "dir " : "file"} ${entry.name}`)
14
+ .join("\n");
15
+ }
3
16
  /**
4
- * Proof-of-concept tool: list the entries of `ctx.cwd`, marking each as a
5
- * directory or a file. Takes no arguments.
17
+ * List the entries of a workspace root, marking each as a directory or a file.
18
+ *
19
+ * Multi-repo (C.26, Funnel B): with more than one declared root, it fans every
20
+ * root — each `fs.readdir(root.absPath)` reads exactly the root its section
21
+ * header names. A per-root read error is NAMED (not fatal), and a `root` argument
22
+ * scopes to one root.
6
23
  */
7
24
  export const listFilesTool = {
8
25
  name: "list_files",
9
26
  description: "List the files and directories in the current working directory.",
10
- parameters: z.object({}),
11
- async execute(_input, ctx) {
27
+ parameters: z.object({
28
+ root: z
29
+ .string()
30
+ .optional()
31
+ .describe("In a multi-repo session, restrict the listing to a single declared root by name. Omit to list every root."),
32
+ }),
33
+ async execute(input, ctx) {
34
+ const ws = contextWorkspace(ctx);
35
+ let roots;
12
36
  try {
13
- const entries = await fs.readdir(ctx.cwd, { withFileTypes: true });
14
- if (entries.length === 0) {
15
- return { ok: true, output: "(empty directory)" };
16
- }
17
- const lines = entries
18
- .slice()
19
- .sort((a, b) => a.name.localeCompare(b.name))
20
- .map((entry) => `${entry.isDirectory() ? "dir " : "file"} ${entry.name}`);
21
- return { ok: true, output: lines.join("\n") };
37
+ roots = resolveReadRoots(ctx, { root: input.root });
22
38
  }
23
39
  catch (err) {
24
40
  return { ok: false, error: err.message };
25
41
  }
42
+ // Single-root: byte-identical with the pre-C.26 tool.
43
+ if (!ws.isMultiRoot) {
44
+ try {
45
+ return { ok: true, output: await listDir(roots[0].absPath) };
46
+ }
47
+ catch (err) {
48
+ return { ok: false, error: err.message };
49
+ }
50
+ }
51
+ // Multi-root: one section per root; a per-root read error is named, not fatal.
52
+ const sections = await Promise.all(roots.map(async (root) => {
53
+ try {
54
+ return `${root.name}:\n${await listDir(root.absPath)}`;
55
+ }
56
+ catch (err) {
57
+ return `${root.name}: ${err.message}`;
58
+ }
59
+ }));
60
+ return { ok: true, output: sections.join("\n\n") };
26
61
  },
27
62
  };
@@ -4,12 +4,15 @@ declare const parameters: z.ZodObject<{
4
4
  query: z.ZodString;
5
5
  k: z.ZodOptional<z.ZodNumber>;
6
6
  pathGlob: z.ZodOptional<z.ZodString>;
7
+ root: z.ZodOptional<z.ZodString>;
7
8
  }, "strip", z.ZodTypeAny, {
8
9
  query: string;
10
+ root?: string | undefined;
9
11
  k?: number | undefined;
10
12
  pathGlob?: string | undefined;
11
13
  }, {
12
14
  query: string;
15
+ root?: string | undefined;
13
16
  k?: number | undefined;
14
17
  pathGlob?: string | undefined;
15
18
  }>;
@@ -18,6 +21,13 @@ declare const parameters: z.ZodObject<{
18
21
  * approval — like read_file and grep_files. The index is built/refreshed lazily
19
22
  * on first use; results are ranked by cosine similarity and token-budgeted.
20
23
  *
24
+ * Multi-repo (C.26, Funnel B): with more than one declared root, the search fans
25
+ * every root, gets each root's own index (`getIndexService(root.absPath)` — the
26
+ * per-cwd cache is the per-root map), and merges into one globally-ranked list.
27
+ * Each hit is labelled with its source root, `k` and the token budget are applied
28
+ * AFTER the merge (global rank, ⚖︎JC-I), and any root whose index failed — or whose
29
+ * matches the cap dropped — is NAMED, never silently implied to have no matches.
30
+ *
21
31
  * Complements `grep_files`: prefer this for conceptual "where / how does X work"
22
32
  * questions, and grep for exact strings or symbols.
23
33
  */
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
- import { getIndexService } from "../indexing/index.js";
2
+ import { getIndexService, mergeRankedHits } from "../indexing/index.js";
3
+ import { contextWorkspace, labelName, resolveReadRoots } from "./file/paths.js";
3
4
  /** Hard cap on `k`, mirroring the retriever. */
4
5
  const MAX_K = 50;
5
6
  const parameters = z.object({
@@ -18,12 +19,23 @@ const parameters = z.object({
18
19
  .string()
19
20
  .optional()
20
21
  .describe("Optional glob to restrict results by path, e.g. 'src/**/*.ts' or 'packages/cli/**'."),
22
+ root: z
23
+ .string()
24
+ .optional()
25
+ .describe("In a multi-repo session, restrict the search to a single declared root by name. Omit to search every root and label each hit with the root it came from."),
21
26
  });
22
27
  /**
23
28
  * Semantic search over the project's local code index (C.17). Read-only — no
24
29
  * approval — like read_file and grep_files. The index is built/refreshed lazily
25
30
  * on first use; results are ranked by cosine similarity and token-budgeted.
26
31
  *
32
+ * Multi-repo (C.26, Funnel B): with more than one declared root, the search fans
33
+ * every root, gets each root's own index (`getIndexService(root.absPath)` — the
34
+ * per-cwd cache is the per-root map), and merges into one globally-ranked list.
35
+ * Each hit is labelled with its source root, `k` and the token budget are applied
36
+ * AFTER the merge (global rank, ⚖︎JC-I), and any root whose index failed — or whose
37
+ * matches the cap dropped — is NAMED, never silently implied to have no matches.
38
+ *
27
39
  * Complements `grep_files`: prefer this for conceptual "where / how does X work"
28
40
  * questions, and grep for exact strings or symbols.
29
41
  */
@@ -38,28 +50,79 @@ export const searchCodebaseTool = {
38
50
  error: "codebase indexing is disabled (set index.enabled = true to use search_codebase)",
39
51
  };
40
52
  }
53
+ const ws = contextWorkspace(ctx);
54
+ let roots;
41
55
  try {
42
- const service = await getIndexService(ctx.cwd, ctx.config, ctx.logger);
43
- const hits = await service.search({
44
- query: input.query,
45
- k: input.k,
46
- pathGlob: input.pathGlob,
47
- });
48
- if (hits.length === 0) {
49
- return { ok: true, output: "(no matches in the codebase index)" };
50
- }
51
- return { ok: true, output: formatHits(hits) };
56
+ roots = resolveReadRoots(ctx, { root: input.root });
52
57
  }
53
58
  catch (err) {
59
+ // Unknown root name (R1) — fail loud rather than silently searching primary.
54
60
  return { ok: false, error: err.message };
55
61
  }
62
+ // Single-root: byte-identical with the pre-C.26 tool (no label, no footer).
63
+ if (!ws.isMultiRoot) {
64
+ try {
65
+ const only = roots[0];
66
+ const service = await getIndexService(only.absPath, ctx.config, ctx.logger);
67
+ const hits = await service.search({
68
+ query: input.query,
69
+ k: input.k,
70
+ pathGlob: input.pathGlob,
71
+ root: only.name,
72
+ });
73
+ if (hits.length === 0) {
74
+ return { ok: true, output: "(no matches in the codebase index)" };
75
+ }
76
+ return { ok: true, output: formatHits(hits, false) };
77
+ }
78
+ catch (err) {
79
+ return { ok: false, error: err.message };
80
+ }
81
+ }
82
+ // Multi-root fan: one independent search per root, per-root failures isolated
83
+ // (one root's broken index must not blank the others — but it IS named).
84
+ const perRoot = [];
85
+ const rawByRoot = new Map();
86
+ const searched = [];
87
+ const failed = [];
88
+ for (const root of roots) {
89
+ try {
90
+ const service = await getIndexService(root.absPath, ctx.config, ctx.logger);
91
+ const hits = await service.search({
92
+ query: input.query,
93
+ k: input.k,
94
+ pathGlob: input.pathGlob,
95
+ root: root.name,
96
+ // Unbudgeted per root; the real budget is applied once after the merge.
97
+ tokenBudget: Number.POSITIVE_INFINITY,
98
+ });
99
+ perRoot.push(hits);
100
+ rawByRoot.set(root.name, hits.length);
101
+ searched.push(root.name);
102
+ }
103
+ catch (err) {
104
+ failed.push({ name: root.name, reason: err.message });
105
+ }
106
+ }
107
+ // Global rank + budget + cap AFTER the merge (⚖︎JC-I): a strong hit in one root
108
+ // is never crowded out by a per-root cap in another, and the token budget is
109
+ // honoured once for the whole result.
110
+ const merged = mergeRankedHits(perRoot, {
111
+ k: input.k ?? ctx.config.index.search.defaultK,
112
+ tokenBudget: ctx.config.index.search.tokenBudget,
113
+ });
114
+ return {
115
+ ok: true,
116
+ output: renderFanned(merged, { searched, failed, rawByRoot }),
117
+ };
56
118
  },
57
119
  };
58
- /** Render hits as a compact, model-readable block. */
59
- function formatHits(hits) {
120
+ /** Render hits as a compact, model-readable block; label by root when multi-root. */
121
+ function formatHits(hits, multiRoot) {
60
122
  return hits
61
123
  .map((hit) => {
62
- const header = `${hit.path}:${hit.startLine}-${hit.endLine} (score ${hit.score.toFixed(3)})`;
124
+ const loc = multiRoot ? labelName(hit.root, hit.path) : hit.path;
125
+ const header = `${loc}:${hit.startLine}-${hit.endLine} (score ${hit.score.toFixed(3)})`;
63
126
  const body = hit.snippet
64
127
  .split("\n")
65
128
  .map((line) => ` ${line}`)
@@ -68,3 +131,43 @@ function formatHits(hits) {
68
131
  })
69
132
  .join("\n\n");
70
133
  }
134
+ /**
135
+ * Render a fanned multi-root result with an honest footer: which roots were
136
+ * searched, which had matches dropped by the global cap, and which roots' indexes
137
+ * were unavailable — the last kept DISTINCT from "no matches" so an unsearched
138
+ * root is never read as an empty one (⚖︎6/JC-I).
139
+ */
140
+ function renderFanned(merged, ctx) {
141
+ const shownByRoot = new Map();
142
+ for (const hit of merged) {
143
+ shownByRoot.set(hit.root, (shownByRoot.get(hit.root) ?? 0) + 1);
144
+ }
145
+ const notes = [];
146
+ // Truncation: a root returned matches that the global cap/budget dropped.
147
+ for (const name of ctx.searched) {
148
+ const raw = ctx.rawByRoot.get(name) ?? 0;
149
+ const shown = shownByRoot.get(name) ?? 0;
150
+ if (raw > shown) {
151
+ const n = raw - shown;
152
+ notes.push(`${name}: ${n} more match${n === 1 ? "" : "es"} not shown (result cap)`);
153
+ }
154
+ }
155
+ // Per-root index failures — named, and never conflated with "no matches".
156
+ for (const f of ctx.failed) {
157
+ notes.push(`${f.name}: index unavailable — ${f.reason}`);
158
+ }
159
+ const scope = ctx.searched.length
160
+ ? `searched ${ctx.searched.length} root${ctx.searched.length === 1 ? "" : "s"}: ${ctx.searched.join(", ")}`
161
+ : "no roots could be searched";
162
+ const lines = [];
163
+ if (merged.length) {
164
+ lines.push(formatHits(merged, true), "");
165
+ }
166
+ else if (ctx.searched.length) {
167
+ lines.push("(no matches in the codebase index)", "");
168
+ }
169
+ lines.push(scope);
170
+ for (const note of notes)
171
+ lines.push(`— ${note}`);
172
+ return lines.join("\n");
173
+ }
@@ -7,7 +7,14 @@ import { spawn } from "node:child_process";
7
7
  * sandbox that can't run throws its coded error (fail loud, no host fallback).
8
8
  */
9
9
  export async function runGatedShell(command, ctx) {
10
- const decision = await ctx.requestApproval({ kind: "shell", command });
10
+ // `root` is the C.26 attribution seam (JC-β): populated with the primary root
11
+ // name for Steps 4/5. Shell is primary-only this release, so the checkpoint gate
12
+ // hard-attributes it to the primary regardless of this value.
13
+ const decision = await ctx.requestApproval({
14
+ kind: "shell",
15
+ command,
16
+ root: ctx.workspace?.primary().name,
17
+ });
11
18
  if (!decision.allow) {
12
19
  return { approved: false, rejection: decision.feedback };
13
20
  }
@@ -2,6 +2,7 @@ import type { z, ZodTypeAny } from "zod";
2
2
  import type { CruxyConfig } from "../config/index.js";
3
3
  import type { ApprovalDecision } from "../approval/types.js";
4
4
  import type { SandboxService } from "../sandbox/index.js";
5
+ import type { Workspace } from "../workspace/index.js";
5
6
  import type { logger } from "../utils/logger.js";
6
7
  /** The leveled logger instance shared across the CLI. */
7
8
  type Logger = typeof logger;
@@ -67,7 +68,11 @@ export type ActionPreview =
67
68
  /**
68
69
  * The whole publish plan for a `vcs` action (C.15): the feature branch, the
69
70
  * conventional commit, and the PR title/body — shown as one block so the user
70
- * approves the entire branch → commit → push → open-PR sequence at once.
71
+ * approves the entire branch → commit → push → open-PR sequence at once. C.26
72
+ * Step 4 adds `target` (the resolved `host/owner/repo` the PR will open against)
73
+ * so the human sees the real API destination — not just the root name — before
74
+ * approving (⚖︎JC-4); it is re-resolved and re-checked immediately before the API
75
+ * call, so what is shown here is what the guard holds them to.
71
76
  */
72
77
  | {
73
78
  type: "pr";
@@ -77,6 +82,12 @@ export type ActionPreview =
77
82
  commitBody: string;
78
83
  prTitle: string;
79
84
  prBody: string;
85
+ /** The resolved forge target parsed from `origin` at approval time. */
86
+ target: {
87
+ host: string;
88
+ owner: string;
89
+ repo: string;
90
+ };
80
91
  }
81
92
  /**
82
93
  * The full blast radius of a checkpoint restore (C.32): every file rollback
@@ -95,6 +106,25 @@ export type ActionPreview =
95
106
  externalPaths: string[];
96
107
  /** The run executed shell commands, so per-file attribution is impossible. */
97
108
  attributionUnknown: boolean;
109
+ }
110
+ /**
111
+ * A multi-root rollback set (C.26 step 3): one combined preview grouped by root,
112
+ * each root carrying its own file diffs and external-change warnings, so a single
113
+ * U.3 approval covers restoring every touched root of a run at once (⚖︎JC-ι). One
114
+ * `roots` entry per member of the run's `CheckpointSet`.
115
+ */
116
+ | {
117
+ type: "rollback-set";
118
+ runId: string;
119
+ createdAt: string;
120
+ runSummary: string;
121
+ roots: {
122
+ rootName: string;
123
+ checkpointId: string;
124
+ files: PatchFilePreview[];
125
+ externalPaths: string[];
126
+ attributionUnknown: boolean;
127
+ }[];
98
128
  };
99
129
  /**
100
130
  * A side-effecting action a tool wants to take, passed to `ctx.approve`. The
@@ -112,6 +142,16 @@ export interface ApproveAction {
112
142
  * covers another — and a server can never mark its own tool low-risk. */
113
143
  server?: string;
114
144
  tool?: string;
145
+ /**
146
+ * The declared workspace root this action acts in (C.26). `vcs` sets it to the
147
+ * root a pull request was selected for (C.26 Step 4): it may be non-primary, and
148
+ * the checkpoint gate attributes the vcs checkpoint to exactly this root. `shell`/
149
+ * `test` still set it to the primary as an attribution seam — their non-primary
150
+ * support is Step 5 (JC-β residual), so the gate hard-attributes them to the
151
+ * primary regardless of this value. File actions carry their root in the resolved
152
+ * absolute `path`/preview instead, so they leave this unset.
153
+ */
154
+ root?: string;
115
155
  /** Exact-change preview rendered above the prompt (write/edit/patch/vcs/rollback). */
116
156
  preview?: ActionPreview;
117
157
  }
@@ -123,6 +163,16 @@ export interface ApproveAction {
123
163
  export interface ToolContext {
124
164
  /** Absolute path the tool should treat as its working root. */
125
165
  cwd: string;
166
+ /**
167
+ * The declared workspace root set for this session (C.26). Path-taking tools
168
+ * resolve *through* this rather than through `cwd`, so a multi-root session
169
+ * confines each call to the one root it selects. Optional only so the many test
170
+ * contexts that predate multi-root stay valid: the runtime always populates it,
171
+ * and when absent the file resolver falls back to a trivial single-root
172
+ * workspace over `cwd` (byte-identical to legacy single-root behaviour). `cwd`
173
+ * remains equal to `workspace.primary().absPath`.
174
+ */
175
+ workspace?: Workspace;
126
176
  /** Fully-resolved CLI configuration. */
127
177
  config: CruxyConfig;
128
178
  /** Shared leveled logger (diagnostics to stderr, `print` to stdout). */
@@ -137,6 +187,18 @@ export interface ToolContext {
137
187
  * tools never call this.
138
188
  */
139
189
  requestApproval(action: ApproveAction): Promise<ApprovalDecision>;
190
+ /**
191
+ * Whether per-root checkpointing is active for this session (C.26 step 3). It is
192
+ * the single fact that lifts the JC-1 non-primary-write refusal: a write to a
193
+ * non-primary root is permitted **iff** this is true, because a true value means
194
+ * a per-root checkpoint gate is wired that captures the write (get-or-creates the
195
+ * root's service and snapshots it) before it reaches disk. False/absent → a
196
+ * non-primary write is still refused with `CRUXY_E_MULTIROOT_WRITE_DEFERRED`, so
197
+ * a checkpoints-disabled session never widens un-restorable writes to siblings.
198
+ * Set together with `requestApproval` (the gate), so permit and capture are one
199
+ * decision — never lift-then-verify.
200
+ */
201
+ checkpointsActive?: boolean;
140
202
  /**
141
203
  * Isolation substrate for the shell + test tools (C.16). Present ONLY when
142
204
  * the sandbox is enabled; when set, `run_command`/`run_tests` execute the
package/dist/vcs/git.d.ts CHANGED
@@ -6,6 +6,14 @@ export interface GitResult {
6
6
  }
7
7
  /** Run `git <args>` in `cwd`, capturing stdout/stderr/exit. Never throws. */
8
8
  export declare function runGitCapture(args: string[], cwd: string): GitResult;
9
+ /**
10
+ * The absolute path of the git working tree that contains `cwd` (its
11
+ * `--show-toplevel`), or `null` when `cwd` is not inside a git repository. Git
12
+ * returns a canonical (symlink-resolved) path. Used by the C.26 cross-root guard
13
+ * to detect two declared roots that share one repository (a commit for one would
14
+ * `git add -A` the other's changes too).
15
+ */
16
+ export declare function gitToplevel(cwd: string): string | null;
9
17
  /** The current branch name, or `null` when detached / not a repo. */
10
18
  export declare function currentBranch(cwd: string): string | null;
11
19
  /** The default set of branch names cruxy will never write to directly. */
package/dist/vcs/git.js CHANGED
@@ -35,6 +35,20 @@ export function runGitCapture(args, cwd) {
35
35
  }
36
36
  return { ok: res.status === 0, stdout, stderr, code: res.status };
37
37
  }
38
+ /**
39
+ * The absolute path of the git working tree that contains `cwd` (its
40
+ * `--show-toplevel`), or `null` when `cwd` is not inside a git repository. Git
41
+ * returns a canonical (symlink-resolved) path. Used by the C.26 cross-root guard
42
+ * to detect two declared roots that share one repository (a commit for one would
43
+ * `git add -A` the other's changes too).
44
+ */
45
+ export function gitToplevel(cwd) {
46
+ const res = runGitCapture(["rev-parse", "--show-toplevel"], cwd);
47
+ if (!res.ok)
48
+ return null;
49
+ const top = res.stdout.trim();
50
+ return top === "" ? null : top;
51
+ }
38
52
  /** The current branch name, or `null` when detached / not a repo. */
39
53
  export function currentBranch(cwd) {
40
54
  const res = runGitCapture(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
@@ -1,4 +1,4 @@
1
- import type { ForgeProvider, PullRequestResult, PullRequestSpec, RepoInfo } from "./types.js";
1
+ import type { ForgeProvider, PullRequestResult, PullRequestSpec, RepoIdentity, RepoInfo } from "./types.js";
2
2
  /**
3
3
  * GitHub implementation of {@link ForgeProvider} (C.15) — the only forge that
4
4
  * ships today. It talks to the REST API directly (no octokit dependency) and
@@ -16,6 +16,12 @@ export declare class GitHubProvider implements ForgeProvider {
16
16
  private readonly token;
17
17
  private readonly fetchImpl;
18
18
  constructor(opts: GitHubProviderOptions);
19
+ /**
20
+ * The LOCAL half of {@link getRepoInfo}: read `origin` and parse it, no network.
21
+ * Shared by the full lookup and by the Step-4 wrong-repo re-check so both derive
22
+ * the target from the exact same rule.
23
+ */
24
+ resolveRepoIdentity(cwd: string): RepoIdentity;
19
25
  getRepoInfo(cwd: string): Promise<RepoInfo>;
20
26
  createPullRequest(repo: RepoInfo, spec: PullRequestSpec): Promise<PullRequestResult>;
21
27
  /** Look up an open PR for `owner:head`, or `null` if there isn't one. */
@@ -8,7 +8,12 @@ export class GitHubProvider {
8
8
  this.token = opts.token;
9
9
  this.fetchImpl = opts.fetchImpl ?? fetch;
10
10
  }
11
- async getRepoInfo(cwd) {
11
+ /**
12
+ * The LOCAL half of {@link getRepoInfo}: read `origin` and parse it, no network.
13
+ * Shared by the full lookup and by the Step-4 wrong-repo re-check so both derive
14
+ * the target from the exact same rule.
15
+ */
16
+ resolveRepoIdentity(cwd) {
12
17
  const remote = runGitCapture(["remote", "get-url", "origin"], cwd);
13
18
  if (!remote.ok || remote.stdout.trim() === "") {
14
19
  throw usageError("no `origin` remote is configured for this repository", [
@@ -20,6 +25,10 @@ export class GitHubProvider {
20
25
  if (!parsed) {
21
26
  throw usageError(`could not parse the origin remote as a GitHub repository: ${remote.stdout.trim()}`, ["ensure `origin` points at a github.com (or GitHub Enterprise) repo"]);
22
27
  }
28
+ return parsed;
29
+ }
30
+ async getRepoInfo(cwd) {
31
+ const parsed = this.resolveRepoIdentity(cwd);
23
32
  // Best-effort: resolve the default branch so the PR base can default to it.
24
33
  const defaultBranch = await this.fetchDefaultBranch(parsed).catch(() => undefined);
25
34
  return { ...parsed, defaultBranch };
@@ -21,6 +21,14 @@ export type GenerateContent = (input: {
21
21
  }) => Promise<GeneratedContent>;
22
22
  export interface PrServiceDeps {
23
23
  cwd: string;
24
+ /**
25
+ * The name of the declared workspace root this PR acts in (C.26 Step 4). It is
26
+ * the exact root the caller selected — the U.3 attribution and the wrong-repo
27
+ * error name it verbatim, rather than re-deriving a basename from `cwd`. Defaults
28
+ * to `basename(cwd)` for callers that don't select a root (the single-root `cruxy
29
+ * pr` command), which is byte-identical to the pre-Step-4 attribution.
30
+ */
31
+ rootName?: string;
24
32
  config: CruxyConfig;
25
33
  forge: ForgeProvider;
26
34
  generate: GenerateContent;
@@ -1,4 +1,5 @@
1
- import { usageError } from "../errors/index.js";
1
+ import path from "node:path";
2
+ import { usageError, vcsRemoteChanged } from "../errors/index.js";
2
3
  import { commit, currentBranch, diffAgainst, ensureFeatureBranch, hasChanges, isProtectedBranch, push, stageAll, } from "./git.js";
3
4
  /** Build a PR service from its dependencies. */
4
5
  export function createPrService(deps) {
@@ -8,6 +9,7 @@ export function createPrService(deps) {
8
9
  }
9
10
  async function openPullRequest(deps, opts) {
10
11
  const { cwd, config, forge, generate, requestApproval } = deps;
12
+ const rootName = deps.rootName ?? path.basename(cwd);
11
13
  const protectedExtra = config.git.protectedBranches;
12
14
  const branchNow = currentBranch(cwd);
13
15
  if (branchNow === null) {
@@ -36,8 +38,14 @@ async function openPullRequest(deps, opts) {
36
38
  body: opts.body,
37
39
  });
38
40
  // ── the one gate: show the whole publish plan before anything mutates ──────────
41
+ // `repo` is the target parsed from `origin` NOW; it is both shown to the human
42
+ // (⚖︎JC-4 — root name AND resolved owner/repo) and captured as the approved
43
+ // target that the wrong-repo guard re-checks before the API call.
39
44
  const decision = await requestApproval({
40
45
  kind: "vcs",
46
+ // C.26 Step 4: the acting root, named exactly as the caller selected it — the
47
+ // checkpoint gate attributes the run's git side effects to this root.
48
+ root: rootName,
41
49
  preview: {
42
50
  type: "pr",
43
51
  branch: content.branchName,
@@ -46,6 +54,7 @@ async function openPullRequest(deps, opts) {
46
54
  commitBody: content.commitBody,
47
55
  prTitle: content.prTitle,
48
56
  prBody: content.prBody,
57
+ target: { host: repo.host, owner: repo.owner, repo: repo.repo },
49
58
  },
50
59
  });
51
60
  if (!decision.allow) {
@@ -61,6 +70,18 @@ async function openPullRequest(deps, opts) {
61
70
  commit(cwd, content.commitSubject, content.commitBody, protectedExtra);
62
71
  }
63
72
  push(cwd, head, protectedExtra);
73
+ // ── wrong-repo guard (⚖︎JC-2 take A): re-resolve the origin identity LOCALLY and
74
+ // compare it to what the human approved, immediately before the API call. A
75
+ // concurrent `git remote set-url` on the mutable .git/config would otherwise open
76
+ // the PR against a repo the user never saw. Mismatch → refuse (never open); we do
77
+ // NOT re-gate or warn-and-proceed. The tiny window between this re-check and the
78
+ // HTTP send is an unclosable residual without a lock — named, not chased. (Git
79
+ // pushed to `origin` by name above; the guarded surface here is the API's
80
+ // owner/repo target, which is the value the human actually approved.)
81
+ const current = forge.resolveRepoIdentity(cwd);
82
+ if (!sameIdentity(repo, current)) {
83
+ throw vcsRemoteChanged(repo, current, rootName);
84
+ }
64
85
  const pr = await forge.createPullRequest(repo, {
65
86
  title: content.prTitle,
66
87
  body: content.prBody,
@@ -77,3 +98,14 @@ async function openPullRequest(deps, opts) {
77
98
  alreadyExists: pr.alreadyExists,
78
99
  };
79
100
  }
101
+ /**
102
+ * Are two forge identities the same repository? Host/owner/repo compared
103
+ * case-insensitively, because GitHub treats them so — a case-only remote rewrite
104
+ * is the same repo and must not trip the wrong-repo guard with a false positive.
105
+ */
106
+ function sameIdentity(a, b) {
107
+ const norm = (s) => s.toLowerCase();
108
+ return (norm(a.host) === norm(b.host) &&
109
+ norm(a.owner) === norm(b.owner) &&
110
+ norm(a.repo) === norm(b.repo));
111
+ }
@@ -3,14 +3,21 @@
3
3
  * interface is the swap seam — GitHub ships first, GitLab/Bitbucket slot in later
4
4
  * without touching call sites (same discipline as `VectorStore`/`Embedder`).
5
5
  */
6
- /** Where a repository lives and who owns it, parsed from the `origin` remote. */
7
- export interface RepoInfo {
6
+ /**
7
+ * The forge-target identity parsed from the `origin` remote — just host/owner/repo,
8
+ * no network lookup. This is the value the C.26 Step-4 wrong-repo guard compares
9
+ * across the preview→API-call window (see {@link ForgeProvider.resolveRepoIdentity}).
10
+ */
11
+ export interface RepoIdentity {
8
12
  /** Forge host, e.g. `github.com`. */
9
13
  readonly host: string;
10
14
  /** Repository owner (user or org). */
11
15
  readonly owner: string;
12
16
  /** Repository name (no `.git` suffix). */
13
17
  readonly repo: string;
18
+ }
19
+ /** Where a repository lives and who owns it, parsed from the `origin` remote. */
20
+ export interface RepoInfo extends RepoIdentity {
14
21
  /** Default branch, when the provider can resolve it (PR base fallback). */
15
22
  readonly defaultBranch?: string;
16
23
  }
@@ -40,6 +47,15 @@ export interface ForgeProvider {
40
47
  readonly id: string;
41
48
  /** Parse the repository's `origin` remote into structured {@link RepoInfo}. */
42
49
  getRepoInfo(cwd: string): Promise<RepoInfo>;
50
+ /**
51
+ * Re-parse just the `origin` remote's {@link RepoIdentity} — LOCAL only, no
52
+ * network. The C.26 Step-4 wrong-repo guard calls this immediately before the
53
+ * pull-request API call and compares it against the identity shown at approval;
54
+ * a mismatch (a mid-run `git remote set-url`) refuses the PR. It stays local so
55
+ * the re-check is cheap and cannot fail on a network hiccup. Throws the same
56
+ * coded usage errors as {@link getRepoInfo} when there is no parseable `origin`.
57
+ */
58
+ resolveRepoIdentity(cwd: string): RepoIdentity;
43
59
  /** Open a pull request and return its URL (idempotent on already-exists). */
44
60
  createPullRequest(repo: RepoInfo, spec: PullRequestSpec): Promise<PullRequestResult>;
45
61
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {