@cruxy/cli 0.4.0 → 0.7.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 (105) hide show
  1. package/README.md +57 -16
  2. package/dist/agent/index.d.ts +0 -1
  3. package/dist/agent/index.js +0 -1
  4. package/dist/agent/loop.d.ts +2 -0
  5. package/dist/agent/loop.js +1 -1
  6. package/dist/agent/prompts.d.ts +2 -2
  7. package/dist/agent/prompts.js +9 -3
  8. package/dist/agent/session.d.ts +24 -0
  9. package/dist/agent/session.js +33 -7
  10. package/dist/approval/classify.d.ts +18 -0
  11. package/dist/approval/classify.js +162 -0
  12. package/dist/approval/index.d.ts +5 -0
  13. package/dist/approval/index.js +5 -0
  14. package/dist/approval/policy.d.ts +37 -0
  15. package/dist/approval/policy.js +81 -0
  16. package/dist/approval/prompt.d.ts +33 -0
  17. package/dist/approval/prompt.js +212 -0
  18. package/dist/approval/service.d.ts +36 -0
  19. package/dist/approval/service.js +37 -0
  20. package/dist/approval/types.d.ts +64 -0
  21. package/dist/approval/types.js +1 -0
  22. package/dist/cli/commands/init.d.ts +7 -0
  23. package/dist/cli/commands/init.js +40 -0
  24. package/dist/cli/commands/login.d.ts +8 -0
  25. package/dist/cli/commands/login.js +36 -0
  26. package/dist/cli/commands/pr.d.ts +8 -0
  27. package/dist/cli/commands/pr.js +87 -0
  28. package/dist/cli/commands/run.js +30 -52
  29. package/dist/cli/onboard.d.ts +25 -0
  30. package/dist/cli/onboard.js +54 -0
  31. package/dist/cli/program.js +21 -1
  32. package/dist/cli/repl.js +10 -1
  33. package/dist/cli/session-factory.d.ts +12 -0
  34. package/dist/cli/session-factory.js +88 -0
  35. package/dist/config/credentials.d.ts +10 -0
  36. package/dist/config/credentials.js +69 -0
  37. package/dist/config/index.d.ts +1 -0
  38. package/dist/config/index.js +1 -0
  39. package/dist/config/manager.d.ts +6 -1
  40. package/dist/config/manager.js +11 -1
  41. package/dist/config/schema.d.ts +48 -9
  42. package/dist/config/schema.js +15 -4
  43. package/dist/constants.d.ts +6 -0
  44. package/dist/constants.js +6 -0
  45. package/dist/errors/constructors.d.ts +35 -0
  46. package/dist/errors/constructors.js +129 -2
  47. package/dist/errors/types.d.ts +8 -0
  48. package/dist/errors/types.js +17 -0
  49. package/dist/onboarding/detect.d.ts +26 -0
  50. package/dist/onboarding/detect.js +56 -0
  51. package/dist/onboarding/flow.d.ts +28 -0
  52. package/dist/onboarding/flow.js +100 -0
  53. package/dist/onboarding/index.d.ts +5 -0
  54. package/dist/onboarding/index.js +5 -0
  55. package/dist/onboarding/io.d.ts +8 -0
  56. package/dist/onboarding/io.js +133 -0
  57. package/dist/onboarding/steps.d.ts +17 -0
  58. package/dist/onboarding/steps.js +100 -0
  59. package/dist/onboarding/types.d.ts +81 -0
  60. package/dist/onboarding/types.js +6 -0
  61. package/dist/plan/approve.d.ts +16 -0
  62. package/dist/plan/approve.js +46 -0
  63. package/dist/plan/execute.d.ts +20 -0
  64. package/dist/plan/execute.js +31 -0
  65. package/dist/plan/index.d.ts +7 -0
  66. package/dist/plan/index.js +7 -0
  67. package/dist/plan/policy.d.ts +26 -0
  68. package/dist/plan/policy.js +45 -0
  69. package/dist/plan/render.d.ts +5 -0
  70. package/dist/plan/render.js +47 -0
  71. package/dist/plan/service.d.ts +39 -0
  72. package/dist/plan/service.js +118 -0
  73. package/dist/plan/submit-plan.d.ts +33 -0
  74. package/dist/plan/submit-plan.js +57 -0
  75. package/dist/plan/types.d.ts +60 -0
  76. package/dist/plan/types.js +6 -0
  77. package/dist/tools/create-pull-request.d.ts +24 -0
  78. package/dist/tools/create-pull-request.js +83 -0
  79. package/dist/tools/file/apply-patch.js +3 -3
  80. package/dist/tools/file/edit-file.js +6 -3
  81. package/dist/tools/file/write-file.js +6 -3
  82. package/dist/tools/index.d.ts +1 -0
  83. package/dist/tools/index.js +1 -0
  84. package/dist/tools/registry.js +2 -0
  85. package/dist/tools/shell/run-command.js +11 -3
  86. package/dist/tools/types.d.ts +25 -6
  87. package/dist/vcs/auth.d.ts +22 -0
  88. package/dist/vcs/auth.js +29 -0
  89. package/dist/vcs/generate.d.ts +72 -0
  90. package/dist/vcs/generate.js +265 -0
  91. package/dist/vcs/git.d.ts +52 -0
  92. package/dist/vcs/git.js +152 -0
  93. package/dist/vcs/github.d.ts +44 -0
  94. package/dist/vcs/github.js +145 -0
  95. package/dist/vcs/guidance.d.ts +20 -0
  96. package/dist/vcs/guidance.js +76 -0
  97. package/dist/vcs/index.d.ts +7 -0
  98. package/dist/vcs/index.js +7 -0
  99. package/dist/vcs/service.d.ts +53 -0
  100. package/dist/vcs/service.js +79 -0
  101. package/dist/vcs/types.d.ts +57 -0
  102. package/dist/vcs/types.js +6 -0
  103. package/package.json +1 -1
  104. package/dist/agent/approval.d.ts +0 -41
  105. package/dist/agent/approval.js +0 -179
@@ -0,0 +1,100 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import pc from "picocolors";
4
+ import { CREATE_KEY_URL } from "../constants.js";
5
+ import { loadProjectInstructions } from "../config/index.js";
6
+ /**
7
+ * The individual onboarding steps (U.6). Each returns a {@link StepResult} and
8
+ * never throws across its boundary; the secret is read masked and is never echoed
9
+ * or logged. Rendering is gated on `io.color` so output respects NO_COLOR / pipes.
10
+ */
11
+ const MAX_KEY_ATTEMPTS = 3;
12
+ const c = (io) => pc.createColors(io.color);
13
+ /**
14
+ * Acquire and persist a provider key: print the create-key URL, read it masked,
15
+ * validate it live, and **only then** write it to the credentials store. Loops on
16
+ * a rejected key (up to 3 tries); a network failure or an empty entry stops.
17
+ */
18
+ export async function acquireKeyStep(io, deps, provider) {
19
+ const col = c(io);
20
+ io.write(`\nYou'll need a Cruxy API key. Create one at ${col.cyan(CREATE_KEY_URL)}\n`);
21
+ for (let attempt = 1; attempt <= MAX_KEY_ATTEMPTS; attempt++) {
22
+ io.write(col.bold("Paste your API key: "));
23
+ const key = (await io.readSecret()).trim();
24
+ if (key === "") {
25
+ // Empty / Ctrl-C / EOF — treat as an abort of the mandatory step.
26
+ return { status: "aborted" };
27
+ }
28
+ io.write(col.dim("validating…\n"));
29
+ const outcome = await deps.validateKey(provider, key);
30
+ if (outcome === "valid") {
31
+ deps.writeCredential(provider, key);
32
+ io.write(`${col.green("✓")} key validated and saved to ~/.cruxy\n`);
33
+ return { status: "ok", apiKey: key };
34
+ }
35
+ if (outcome === "unreachable") {
36
+ io.write(`${col.red("✗")} couldn't reach the gateway to validate the key.\n`);
37
+ return {
38
+ status: "failed",
39
+ message: "network unreachable — try again with `cruxy login`",
40
+ };
41
+ }
42
+ // invalid
43
+ const left = MAX_KEY_ATTEMPTS - attempt;
44
+ io.write(`${col.red("✗")} that key was rejected${left > 0 ? ` (${left} ${left === 1 ? "try" : "tries"} left)` : ""}.\n`);
45
+ }
46
+ return { status: "failed", message: "key rejected after 3 attempts" };
47
+ }
48
+ const CRUXY_MD_TEMPLATE = `# Project instructions for cruxy
49
+
50
+ These notes are loaded into cruxy's context on every run. Keep them short and
51
+ high-signal — conventions, where things live, how to build and test.
52
+
53
+ ## Conventions
54
+
55
+ - (e.g. language, style, naming rules the agent should follow)
56
+
57
+ ## Build & test
58
+
59
+ - (e.g. how to install deps, run the app, run the test suite)
60
+
61
+ ## Gotchas
62
+
63
+ - (e.g. anything non-obvious about this codebase)
64
+ `;
65
+ /**
66
+ * Offer to scaffold a project `CRUXY.md`. Skipped silently when one already
67
+ * exists (or `AGENTS.md`); otherwise a `y` confirmation writes the template.
68
+ */
69
+ export async function scaffoldStep(io, cwd) {
70
+ const col = c(io);
71
+ if (loadProjectInstructions(cwd) !== null) {
72
+ return { status: "skipped" };
73
+ }
74
+ io.write(`\nScaffold a ${col.bold("CRUXY.md")} to guide cruxy in this project? ${col.dim("[y/N]")} `);
75
+ const key = (await io.readKey()).toLowerCase();
76
+ io.write("\n");
77
+ if (key !== "y")
78
+ return { status: "skipped" };
79
+ const file = join(cwd, "CRUXY.md");
80
+ writeFileSync(file, CRUXY_MD_TEMPLATE, "utf8");
81
+ io.write(`${col.green("✓")} wrote ${col.bold("CRUXY.md")}\n`);
82
+ return { status: "ok" };
83
+ }
84
+ const FIRST_WIN_PROMPT = "Give me a concise 3-sentence summary of what this repository does, based on its README and structure.";
85
+ /**
86
+ * Offer the first-win demo run. Skipped when no runner is wired or the user
87
+ * declines; otherwise runs one real task so they see value immediately.
88
+ */
89
+ export async function firstWinStep(io, deps) {
90
+ const col = c(io);
91
+ if (!deps.runTask)
92
+ return { status: "skipped" };
93
+ io.write(`\nRun a quick demo now — let cruxy summarize this repo? ${col.dim("[Y/n]")} `);
94
+ const key = (await io.readKey()).toLowerCase();
95
+ io.write("\n");
96
+ if (key === "n")
97
+ return { status: "skipped" };
98
+ await deps.runTask(FIRST_WIN_PROMPT);
99
+ return { status: "ok" };
100
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Types for the first-run onboarding flow (U.6). The flow is built from injected
3
+ * dependencies (validation, persistence, the first-win runner) so every step is
4
+ * unit-testable with scripted IO and no real network or filesystem.
5
+ */
6
+ /** Outcome of validating a candidate key against the gateway. */
7
+ export type ValidationOutcome = "valid" | "invalid" | "unreachable";
8
+ /** Why a step ended. `aborted` is reserved for the mandatory key step. */
9
+ export type StepStatus = "ok" | "skipped" | "aborted" | "failed";
10
+ export interface StepResult {
11
+ readonly status: StepStatus;
12
+ /** The validated key, when a key step succeeds. */
13
+ readonly apiKey?: string;
14
+ /** A short human note (e.g. why a step failed) for the caller to surface. */
15
+ readonly message?: string;
16
+ }
17
+ /** Persisted onboarding state / completion marker (`~/.cruxy/onboarding.json`). */
18
+ export interface OnboardingState {
19
+ readonly version: number;
20
+ /** ISO timestamp; **its presence is the completion marker**. */
21
+ readonly completedAt?: string;
22
+ /** Whether a key has been successfully configured at least once. */
23
+ readonly keyConfigured?: boolean;
24
+ }
25
+ export interface OnboardingResult {
26
+ /** True once the flow ran to completion and wrote the marker. */
27
+ readonly completed: boolean;
28
+ /** True when the user aborted (Ctrl-C / EOF) before a key was saved. */
29
+ readonly aborted: boolean;
30
+ /** The key in effect after onboarding, if any. */
31
+ readonly apiKey?: string;
32
+ }
33
+ /**
34
+ * IO surface for onboarding — a superset of the approval `PromptIO` with a
35
+ * **masked** `readSecret()`. Injectable so tests script keystrokes and capture
36
+ * every write (to assert the secret is never echoed).
37
+ */
38
+ export interface OnboardingIO {
39
+ /** Emit prompt text (to stderr in the default impl). */
40
+ write(text: string): void;
41
+ /** Read one line in cooked mode; "" on EOF. */
42
+ readLine(): Promise<string>;
43
+ /** Read a single keypress (y/n prompts); "" on EOF / Ctrl-C. */
44
+ readKey(): Promise<string>;
45
+ /** Read a secret with no echo (each char shown as `*`); "" on Ctrl-C / EOF. */
46
+ readSecret(): Promise<string>;
47
+ /** Whether to emit ANSI color. */
48
+ color: boolean;
49
+ }
50
+ /** Injected dependencies — the seams for testing and for the default wiring. */
51
+ export interface OnboardingDeps {
52
+ /** Validate a candidate key with one cheap live call. */
53
+ validateKey(provider: string, apiKey: string): Promise<ValidationOutcome>;
54
+ /** Persist a validated key to the credentials store (`0600`). */
55
+ writeCredential(provider: string, key: string): void;
56
+ /** Resolve the currently effective key (env → store). */
57
+ resolveApiKey(provider: string): string | undefined;
58
+ /** Read persisted onboarding state, or `null`. */
59
+ readState(): OnboardingState | null;
60
+ /** Persist onboarding state. */
61
+ writeState(state: OnboardingState): void;
62
+ /** Run the first-win task; absent ⇒ the first-win step is skipped. */
63
+ runTask?: (prompt: string) => Promise<void>;
64
+ /** Project root (for the scaffold step). */
65
+ cwd: string;
66
+ /** ISO timestamp source (injectable for deterministic tests). */
67
+ now: () => string;
68
+ }
69
+ export type OnboardingMode = "first-run" | "login" | "init";
70
+ export interface OnboardingOptions {
71
+ readonly provider: string;
72
+ readonly mode: OnboardingMode;
73
+ /** Run the key step even if a key already resolves (re-key, `cruxy login`). */
74
+ readonly forceKey?: boolean;
75
+ /** Offer the first-win demo run (only when there's no real task queued). */
76
+ readonly offerFirstWin?: boolean;
77
+ /** Offer to scaffold a project `CRUXY.md` (init). */
78
+ readonly offerScaffold?: boolean;
79
+ readonly io: OnboardingIO;
80
+ readonly deps: OnboardingDeps;
81
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Types for the first-run onboarding flow (U.6). The flow is built from injected
3
+ * dependencies (validation, persistence, the first-win runner) so every step is
4
+ * unit-testable with scripted IO and no real network or filesystem.
5
+ */
6
+ export {};
@@ -0,0 +1,16 @@
1
+ import type { PromptIO } from "../approval/index.js";
2
+ import type { Plan, PlanDecision } from "./types.js";
3
+ /**
4
+ * The plan-approval prompt (C.31). Renders the plan and reads a single-key,
5
+ * 3-way decision, reusing the U.3 {@link PromptIO} (so it's testable with the
6
+ * same scripted-IO helper and honors NO_COLOR / non-TTY). The copy is explicit
7
+ * that "allow safe steps" never covers destructive or irreversible actions.
8
+ *
9
+ * Default-deny: EOF / Ctrl-C / any unrecognized key → `abort`.
10
+ */
11
+ export declare function promptPlanDecision(plan: Plan, io: PromptIO): Promise<PlanDecision>;
12
+ /**
13
+ * After a failed step, ask whether to continue with the remaining steps or abort
14
+ * the run. Default-deny → abort (the safe choice: stop on failure).
15
+ */
16
+ export declare function promptContinueAfterFailure(io: PromptIO): Promise<boolean>;
@@ -0,0 +1,46 @@
1
+ import pc from "picocolors";
2
+ import { renderPlan } from "./render.js";
3
+ /**
4
+ * The plan-approval prompt (C.31). Renders the plan and reads a single-key,
5
+ * 3-way decision, reusing the U.3 {@link PromptIO} (so it's testable with the
6
+ * same scripted-IO helper and honors NO_COLOR / non-TTY). The copy is explicit
7
+ * that "allow safe steps" never covers destructive or irreversible actions.
8
+ *
9
+ * Default-deny: EOF / Ctrl-C / any unrecognized key → `abort`.
10
+ */
11
+ export async function promptPlanDecision(plan, io) {
12
+ const c = pc.createColors(io.color);
13
+ io.write(renderPlan(plan, io.color));
14
+ io.write("\n\n");
15
+ io.write(` ${c.dim("Approving consents to the shape of the work — every action still asks before it runs.")}\n`);
16
+ io.write(` ${c.dim("[a] approve ·")} [g] approve ${c.bold("+ allow the read/mutate steps")} this run ${c.dim("(destructive still confirms)")} ${c.dim("· [e] reject & revise")} `);
17
+ const key = (await io.readKey()).toLowerCase();
18
+ io.write("\n");
19
+ switch (key) {
20
+ case "a":
21
+ return { kind: "approve" };
22
+ case "g":
23
+ return { kind: "approve-grant" };
24
+ case "e":
25
+ case "n": {
26
+ io.write(" what should change about the plan? ");
27
+ const feedback = (await io.readLine()).trim();
28
+ // No feedback ⇒ treat as an abort, not an empty revision request.
29
+ return feedback ? { kind: "revise", feedback } : { kind: "abort" };
30
+ }
31
+ default:
32
+ // Unrecognized key, empty, EOF, Ctrl-C → default-deny.
33
+ return { kind: "abort" };
34
+ }
35
+ }
36
+ /**
37
+ * After a failed step, ask whether to continue with the remaining steps or abort
38
+ * the run. Default-deny → abort (the safe choice: stop on failure).
39
+ */
40
+ export async function promptContinueAfterFailure(io) {
41
+ const c = pc.createColors(io.color);
42
+ io.write(` ${c.red("step failed.")} ${c.dim("[c] continue with the remaining steps · [any other key] abort")} `);
43
+ const key = (await io.readKey()).toLowerCase();
44
+ io.write("\n");
45
+ return key === "c";
46
+ }
@@ -0,0 +1,20 @@
1
+ import type { PromptIO } from "../approval/index.js";
2
+ import type { Plan, PlanExecutionResult, PlanStep } from "./types.js";
3
+ /**
4
+ * Execute an approved plan step-by-step (C.31): mark each step `running`, run it,
5
+ * mark `done` / `failed`, rendering live status. Fail-loud — a failed step
6
+ * **halts** by default (no silent skip); the user chooses continue or abort.
7
+ *
8
+ * The per-step runner is injected (`runStep`) so ordering, status, and
9
+ * halt-on-failure are testable without a real model. In production it drives one
10
+ * agent turn scoped to the step, with per-action U.3 gating still applying.
11
+ */
12
+ export interface ExecuteDeps {
13
+ /**
14
+ * Run one step. Resolve normally on success; **throw** (ideally a
15
+ * {@link CruxyError}) to signal failure — that halts the run.
16
+ */
17
+ runStep: (step: PlanStep) => Promise<void>;
18
+ io: PromptIO;
19
+ }
20
+ export declare function executePlan(plan: Plan, deps: ExecuteDeps): Promise<PlanExecutionResult>;
@@ -0,0 +1,31 @@
1
+ import { CruxyError } from "../errors/index.js";
2
+ import { promptContinueAfterFailure } from "./approve.js";
3
+ import { renderStepStatus } from "./render.js";
4
+ export async function executePlan(plan, deps) {
5
+ const { runStep, io } = deps;
6
+ for (const step of plan.steps) {
7
+ step.status = "running";
8
+ io.write(renderStepStatus(step, io.color) + "\n");
9
+ try {
10
+ await runStep(step);
11
+ step.status = "done";
12
+ io.write(renderStepStatus(step, io.color) + "\n");
13
+ }
14
+ catch (err) {
15
+ step.status = "failed";
16
+ io.write(renderStepStatus(step, io.color) + "\n");
17
+ // Surface the failure via the U.5 shape when we have it.
18
+ const detail = err instanceof CruxyError
19
+ ? `${err.title}${err.cause ? ` — ${err.cause}` : ""}`
20
+ : err.message;
21
+ io.write(` ${detail}\n`);
22
+ const cont = await promptContinueAfterFailure(io);
23
+ if (!cont) {
24
+ return { completed: false, halted: true, failedStepId: step.id };
25
+ }
26
+ // User chose to continue despite the failure; move to the next step.
27
+ }
28
+ }
29
+ const completed = plan.steps.every((s) => s.status === "done");
30
+ return { completed, halted: false };
31
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./types.js";
2
+ export * from "./submit-plan.js";
3
+ export * from "./policy.js";
4
+ export * from "./render.js";
5
+ export * from "./approve.js";
6
+ export * from "./execute.js";
7
+ export * from "./service.js";
@@ -0,0 +1,7 @@
1
+ export * from "./types.js";
2
+ export * from "./submit-plan.js";
3
+ export * from "./policy.js";
4
+ export * from "./render.js";
5
+ export * from "./approve.js";
6
+ export * from "./execute.js";
7
+ export * from "./service.js";
@@ -0,0 +1,26 @@
1
+ import type { ApprovalDecision, ApprovalPolicy, ApprovalRequest, SessionAllowlist } from "../approval/index.js";
2
+ /**
3
+ * The execution-phase approval policy for plan mode (C.31). It wraps the base
4
+ * (interactive) policy and adds exactly one behavior, gated behind the user's
5
+ * "approve + allow safe steps" choice: auto-allow **grantable mutate** actions
6
+ * for the rest of the run, recording the real scope in the shared
7
+ * {@link SessionAllowlist} so U.3's own scoping (`scopeCovers`) governs reuse.
8
+ *
9
+ * What it deliberately does NOT do — the two-tier boundary:
10
+ * • **destructive** actions always fall through to the base policy (prompt);
11
+ * • **ungrantable** actions (`scope:"none"`) always fall through (prompt),
12
+ * even if the tier is `mutate` — because `SessionAllowlist.grant` is a no-op
13
+ * on `none`, an ungrantable action can never be pre-approved.
14
+ *
15
+ * So approving a plan (even with "allow safe steps") never consents to a
16
+ * destructive or irreversible action; those re-confirm at execution time.
17
+ */
18
+ export declare class PlanExecutionPolicy implements ApprovalPolicy {
19
+ private readonly allowlist;
20
+ private readonly base;
21
+ private safeStepGrants;
22
+ constructor(allowlist: SessionAllowlist, base: ApprovalPolicy);
23
+ /** Turn on auto-allow for safe (grantable mutate) steps — the `[g]` choice. */
24
+ enableSafeStepGrants(): void;
25
+ decide(request: ApprovalRequest): Promise<ApprovalDecision>;
26
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The execution-phase approval policy for plan mode (C.31). It wraps the base
3
+ * (interactive) policy and adds exactly one behavior, gated behind the user's
4
+ * "approve + allow safe steps" choice: auto-allow **grantable mutate** actions
5
+ * for the rest of the run, recording the real scope in the shared
6
+ * {@link SessionAllowlist} so U.3's own scoping (`scopeCovers`) governs reuse.
7
+ *
8
+ * What it deliberately does NOT do — the two-tier boundary:
9
+ * • **destructive** actions always fall through to the base policy (prompt);
10
+ * • **ungrantable** actions (`scope:"none"`) always fall through (prompt),
11
+ * even if the tier is `mutate` — because `SessionAllowlist.grant` is a no-op
12
+ * on `none`, an ungrantable action can never be pre-approved.
13
+ *
14
+ * So approving a plan (even with "allow safe steps") never consents to a
15
+ * destructive or irreversible action; those re-confirm at execution time.
16
+ */
17
+ export class PlanExecutionPolicy {
18
+ allowlist;
19
+ base;
20
+ safeStepGrants = false;
21
+ constructor(allowlist, base) {
22
+ this.allowlist = allowlist;
23
+ this.base = base;
24
+ }
25
+ /** Turn on auto-allow for safe (grantable mutate) steps — the `[g]` choice. */
26
+ enableSafeStepGrants() {
27
+ this.safeStepGrants = true;
28
+ }
29
+ async decide(request) {
30
+ // Already covered by a prior session grant (scoped, tier-keyed) → allow.
31
+ if (this.allowlist.allows(request))
32
+ return { allow: true };
33
+ // The one plan-mode shortcut: a grantable mutate action, pre-consented by
34
+ // the "allow safe steps" choice. Record its concrete scope, then allow.
35
+ if (this.safeStepGrants &&
36
+ request.tier === "mutate" &&
37
+ request.scope.kind !== "none") {
38
+ this.allowlist.grant(request);
39
+ return { allow: true };
40
+ }
41
+ // Everything else (destructive, ungrantable, or a non-consented mutate) is
42
+ // decided by the base policy — i.e. the user is prompted.
43
+ return this.base.decide(request);
44
+ }
45
+ }
@@ -0,0 +1,5 @@
1
+ import type { Plan, PlanStep } from "./types.js";
2
+ /** Render the whole plan as a block (header + one line per step + rationale). */
3
+ export declare function renderPlan(plan: Plan, color: boolean): string;
4
+ /** A one-line status update for a single step (used during execution). */
5
+ export declare function renderStepStatus(step: PlanStep, color: boolean): string;
@@ -0,0 +1,47 @@
1
+ import pc from "picocolors";
2
+ /** Status glyph, colored when enabled. */
3
+ function statusMark(status, c) {
4
+ switch (status) {
5
+ case "pending":
6
+ return c.dim("○");
7
+ case "running":
8
+ return c.cyan("◐");
9
+ case "done":
10
+ return c.green("✓");
11
+ case "failed":
12
+ return c.red("✗");
13
+ }
14
+ }
15
+ /** A short tag for the step's risk estimate. */
16
+ function kindTag(kind, c) {
17
+ switch (kind) {
18
+ case "read":
19
+ return c.dim("[read]");
20
+ case "mutate":
21
+ return c.yellow("[mutate]");
22
+ case "destructive":
23
+ return c.red(c.bold("[destructive]"));
24
+ }
25
+ }
26
+ function renderStep(step, c) {
27
+ const head = ` ${statusMark(step.status, c)} ${c.bold(step.id + ".")} ${step.title} ${kindTag(step.kind, c)}`;
28
+ const why = step.rationale.trim()
29
+ ? `\n ${c.dim(step.rationale.trim())}`
30
+ : "";
31
+ return head + why;
32
+ }
33
+ /** Render the whole plan as a block (header + one line per step + rationale). */
34
+ export function renderPlan(plan, color) {
35
+ const c = pc.createColors(color);
36
+ const lines = [
37
+ c.bold(`Plan — ${plan.steps.length} step${plan.steps.length === 1 ? "" : "s"}:`),
38
+ ];
39
+ for (const step of plan.steps)
40
+ lines.push(renderStep(step, c));
41
+ return lines.join("\n");
42
+ }
43
+ /** A one-line status update for a single step (used during execution). */
44
+ export function renderStepStatus(step, color) {
45
+ const c = pc.createColors(color);
46
+ return `${statusMark(step.status, c)} ${c.bold(step.id + ".")} ${step.title}`;
47
+ }
@@ -0,0 +1,39 @@
1
+ import type { Message, Provider } from "@cruxy/sdk";
2
+ import type { CruxyConfig } from "../config/index.js";
3
+ import type { PromptIO } from "../approval/index.js";
4
+ import { ToolRegistry, type ToolContext } from "../tools/index.js";
5
+ import { type AgentResult } from "../agent/loop.js";
6
+ import { PlanExecutionPolicy } from "./policy.js";
7
+ /**
8
+ * Orchestrates a plan-mode turn (C.31): propose → approve/revise (capped) →
9
+ * execute, all on the ordinary agent loop. The propose phase runs with a
10
+ * read-only + `submit_plan` registry so the agent cannot act before approval;
11
+ * execution runs with the full registry, one step at a time, per-action U.3
12
+ * gating intact.
13
+ */
14
+ /** Default cap on plan revisions before failing loud. */
15
+ export declare const MAX_PLAN_REVISIONS = 3;
16
+ export interface PlanSessionArgs {
17
+ provider: Provider;
18
+ config: CruxyConfig;
19
+ ctx: ToolContext;
20
+ /** The full (execution-phase) tool registry. */
21
+ execRegistry: ToolRegistry;
22
+ /** The plan-mode approval policy (flipped on the `[g]` choice). */
23
+ planPolicy: PlanExecutionPolicy;
24
+ /** Prompt IO for plan approval + status (reuses U.3's PromptIO). */
25
+ io: PromptIO;
26
+ /** Whether we can actually prompt (a TTY). Non-interactive → fail loud. */
27
+ interactive: boolean;
28
+ /** The running conversation (with the user turn already appended). */
29
+ messages: Message[];
30
+ git?: {
31
+ branch: string;
32
+ dirty: boolean;
33
+ } | null;
34
+ projectInstructions?: string | null;
35
+ onText?: (delta: string) => void;
36
+ /** Revision cap (defaults to {@link MAX_PLAN_REVISIONS}). */
37
+ maxRevisions?: number;
38
+ }
39
+ export declare function runPlanSession(args: PlanSessionArgs): Promise<AgentResult>;
@@ -0,0 +1,118 @@
1
+ import { planApprovalRequired, planInvalid, planRevisionLimit, } from "../errors/index.js";
2
+ import { ToolRegistry } from "../tools/index.js";
3
+ import { runAgent } from "../agent/loop.js";
4
+ import { promptPlanDecision } from "./approve.js";
5
+ import { executePlan } from "./execute.js";
6
+ import { makeSubmitPlanTool } from "./submit-plan.js";
7
+ /**
8
+ * Orchestrates a plan-mode turn (C.31): propose → approve/revise (capped) →
9
+ * execute, all on the ordinary agent loop. The propose phase runs with a
10
+ * read-only + `submit_plan` registry so the agent cannot act before approval;
11
+ * execution runs with the full registry, one step at a time, per-action U.3
12
+ * gating intact.
13
+ */
14
+ /** Default cap on plan revisions before failing loud. */
15
+ export const MAX_PLAN_REVISIONS = 3;
16
+ /** Tools available during the propose phase (read-only) — plus `submit_plan`. */
17
+ const PLAN_PHASE_TOOLS = new Set([
18
+ "list_files",
19
+ "read_file",
20
+ "glob",
21
+ "grep_files",
22
+ "git_status",
23
+ "search_codebase",
24
+ "list_skills",
25
+ "load_skill",
26
+ ]);
27
+ /** Build the read-only + `submit_plan` registry for the propose phase. */
28
+ function buildPlanPhaseRegistry(execRegistry, holder) {
29
+ const registry = new ToolRegistry();
30
+ for (const tool of execRegistry.list()) {
31
+ if (PLAN_PHASE_TOOLS.has(tool.name))
32
+ registry.register(tool);
33
+ }
34
+ registry.register(makeSubmitPlanTool(holder));
35
+ return registry;
36
+ }
37
+ export async function runPlanSession(args) {
38
+ // Fail loud before proposing — a plan is never auto-approved (U.3 discipline).
39
+ if (!args.interactive)
40
+ throw planApprovalRequired();
41
+ const maxRevisions = args.maxRevisions ?? MAX_PLAN_REVISIONS;
42
+ const usage = { input_tokens: 0, output_tokens: 0 };
43
+ let messages = args.messages;
44
+ let iterations = 0;
45
+ const accumulate = (r) => {
46
+ messages = r.messages;
47
+ usage.input_tokens += r.usage.input_tokens;
48
+ usage.output_tokens += r.usage.output_tokens;
49
+ iterations += r.iterations;
50
+ };
51
+ const finish = () => ({
52
+ messages,
53
+ iterations,
54
+ stop: "completed",
55
+ usage,
56
+ });
57
+ // ── propose / revise loop (capped) ─────────────────────────────────────────
58
+ let feedback = null;
59
+ for (let rev = 0; rev <= maxRevisions; rev++) {
60
+ if (feedback !== null) {
61
+ messages.push({
62
+ role: "user",
63
+ content: `Revise the plan based on this feedback, then call submit_plan again with the updated steps:\n\n${feedback}`,
64
+ });
65
+ }
66
+ const holder = { plan: null };
67
+ const planRegistry = buildPlanPhaseRegistry(args.execRegistry, holder);
68
+ accumulate(await runAgent({
69
+ messages,
70
+ provider: args.provider,
71
+ registry: planRegistry,
72
+ config: args.config,
73
+ ctx: args.ctx,
74
+ git: args.git,
75
+ projectInstructions: args.projectInstructions,
76
+ onText: args.onText,
77
+ planMode: true,
78
+ }));
79
+ if (!holder.plan) {
80
+ throw planInvalid("the model ended its turn without calling submit_plan");
81
+ }
82
+ const plan = holder.plan;
83
+ const decision = await promptPlanDecision(plan, args.io);
84
+ if (decision.kind === "abort") {
85
+ args.io.write("plan aborted — nothing was executed.\n");
86
+ return finish();
87
+ }
88
+ if (decision.kind !== "revise") {
89
+ if (decision.kind === "approve-grant") {
90
+ args.planPolicy.enableSafeStepGrants();
91
+ }
92
+ // Execute step-by-step, driving one agent turn per step against the full
93
+ // registry. Each step's actions still pass through the U.3 gate.
94
+ const runStep = async (step) => {
95
+ messages.push({
96
+ role: "user",
97
+ content: `The plan is approved. Do ONLY step ${step.id}: ${step.title}. ${step.rationale} ` +
98
+ "Do not start any other step. When this step is complete, stop.",
99
+ });
100
+ accumulate(await runAgent({
101
+ messages,
102
+ provider: args.provider,
103
+ registry: args.execRegistry,
104
+ config: args.config,
105
+ ctx: args.ctx,
106
+ git: args.git,
107
+ projectInstructions: args.projectInstructions,
108
+ onText: args.onText,
109
+ }));
110
+ };
111
+ await executePlan(plan, { runStep, io: args.io });
112
+ return finish();
113
+ }
114
+ feedback = decision.feedback;
115
+ }
116
+ // Rejected past the cap without converging.
117
+ throw planRevisionLimit(maxRevisions);
118
+ }
@@ -0,0 +1,33 @@
1
+ import { z } from "zod";
2
+ import type { Tool } from "../tools/index.js";
3
+ import type { PlanHolder } from "./types.js";
4
+ declare const parameters: z.ZodObject<{
5
+ steps: z.ZodArray<z.ZodObject<{
6
+ title: z.ZodString;
7
+ rationale: z.ZodString;
8
+ kind: z.ZodEnum<["read", "mutate", "destructive"]>;
9
+ }, "strip", z.ZodTypeAny, {
10
+ title: string;
11
+ kind: "read" | "mutate" | "destructive";
12
+ rationale: string;
13
+ }, {
14
+ title: string;
15
+ kind: "read" | "mutate" | "destructive";
16
+ rationale: string;
17
+ }>, "many">;
18
+ }, "strip", z.ZodTypeAny, {
19
+ steps: {
20
+ title: string;
21
+ kind: "read" | "mutate" | "destructive";
22
+ rationale: string;
23
+ }[];
24
+ }, {
25
+ steps: {
26
+ title: string;
27
+ kind: "read" | "mutate" | "destructive";
28
+ rationale: string;
29
+ }[];
30
+ }>;
31
+ /** Build a `submit_plan` tool bound to `holder`, which captures the last plan. */
32
+ export declare function makeSubmitPlanTool(holder: PlanHolder): Tool<typeof parameters>;
33
+ export {};