@cruxy/cli 0.7.0 → 0.9.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.
- package/README.md +46 -13
- package/dist/agent/loop.d.ts +35 -6
- package/dist/agent/loop.js +84 -10
- package/dist/agent/prompts.d.ts +2 -0
- package/dist/agent/prompts.js +8 -0
- package/dist/agent/session.d.ts +6 -4
- package/dist/agent/session.js +6 -5
- package/dist/approval/classify.js +26 -0
- package/dist/approval/prompt.d.ts +9 -0
- package/dist/approval/prompt.js +2 -77
- package/dist/checkpoint/capture.d.ts +17 -0
- package/dist/checkpoint/capture.js +73 -0
- package/dist/checkpoint/git-store.d.ts +61 -0
- package/dist/checkpoint/git-store.js +171 -0
- package/dist/checkpoint/index.d.ts +6 -0
- package/dist/checkpoint/index.js +6 -0
- package/dist/checkpoint/restore.d.ts +23 -0
- package/dist/checkpoint/restore.js +195 -0
- package/dist/checkpoint/service.d.ts +80 -0
- package/dist/checkpoint/service.js +276 -0
- package/dist/checkpoint/shadow-store.d.ts +23 -0
- package/dist/checkpoint/shadow-store.js +93 -0
- package/dist/checkpoint/types.d.ts +117 -0
- package/dist/checkpoint/types.js +18 -0
- package/dist/cli/commands/checkpoint.d.ts +7 -0
- package/dist/cli/commands/checkpoint.js +31 -0
- package/dist/cli/commands/rollback.d.ts +10 -0
- package/dist/cli/commands/rollback.js +51 -0
- package/dist/cli/commands/run.js +24 -10
- package/dist/cli/onboard.js +9 -4
- package/dist/cli/program.js +4 -0
- package/dist/cli/repl.d.ts +10 -4
- package/dist/cli/repl.js +26 -12
- package/dist/cli/session-factory.d.ts +15 -1
- package/dist/cli/session-factory.js +104 -18
- package/dist/config/schema.d.ts +133 -0
- package/dist/config/schema.js +40 -0
- package/dist/errors/constructors.d.ts +25 -0
- package/dist/errors/constructors.js +86 -0
- package/dist/errors/types.d.ts +7 -0
- package/dist/errors/types.js +16 -0
- package/dist/indexing/walker.d.ts +11 -0
- package/dist/indexing/walker.js +11 -6
- package/dist/plan/execute.d.ts +8 -0
- package/dist/plan/execute.js +36 -22
- package/dist/plan/service.d.ts +2 -1
- package/dist/plan/service.js +7 -3
- package/dist/plan/submit-plan.d.ts +4 -4
- package/dist/render/capabilities.d.ts +12 -0
- package/dist/render/capabilities.js +27 -0
- package/dist/render/diff.d.ts +19 -0
- package/dist/render/diff.js +107 -0
- package/dist/render/highlight.d.ts +47 -0
- package/dist/render/highlight.js +265 -0
- package/dist/render/index.d.ts +15 -0
- package/dist/render/index.js +21 -0
- package/dist/render/plain-renderer.d.ts +38 -0
- package/dist/render/plain-renderer.js +87 -0
- package/dist/render/state.d.ts +31 -0
- package/dist/render/state.js +83 -0
- package/dist/render/tty-renderer.d.ts +83 -0
- package/dist/render/tty-renderer.js +276 -0
- package/dist/render/types.d.ts +160 -0
- package/dist/render/types.js +1 -0
- package/dist/subagent/budget.d.ts +34 -0
- package/dist/subagent/budget.js +57 -0
- package/dist/subagent/index.d.ts +5 -0
- package/dist/subagent/index.js +5 -0
- package/dist/subagent/orchestrator.d.ts +67 -0
- package/dist/subagent/orchestrator.js +241 -0
- package/dist/subagent/registry-scope.d.ts +28 -0
- package/dist/subagent/registry-scope.js +63 -0
- package/dist/subagent/spawn-tool.d.ts +29 -0
- package/dist/subagent/spawn-tool.js +94 -0
- package/dist/subagent/types.d.ts +55 -0
- package/dist/subagent/types.js +1 -0
- package/dist/tools/types.d.ts +20 -2
- package/package.json +1 -1
|
@@ -335,6 +335,92 @@ export function planApprovalRequired() {
|
|
|
335
335
|
],
|
|
336
336
|
});
|
|
337
337
|
}
|
|
338
|
+
// ── checkpoint + rollback (exit 7 / 2 / 10) ───────────────────────────────────
|
|
339
|
+
/**
|
|
340
|
+
* Creating, reading, or restoring a working-tree checkpoint failed (C.32).
|
|
341
|
+
* Fail-loud by design: an agent run never mutates files without its undo
|
|
342
|
+
* protection unless the user explicitly disables it.
|
|
343
|
+
*/
|
|
344
|
+
export function checkpointFailed(reason, underlying) {
|
|
345
|
+
return new CruxyError({
|
|
346
|
+
code: ErrorCode.CheckpointFailed,
|
|
347
|
+
title: "the working-tree checkpoint operation failed",
|
|
348
|
+
cause: reason,
|
|
349
|
+
nextSteps: [
|
|
350
|
+
"re-run with --verbose for details",
|
|
351
|
+
"if this happened during rollback, re-run `cruxy rollback <id>` — it recomputes from disk and is safe to retry",
|
|
352
|
+
"set `checkpoint.enabled = false` in config to run without undo protection (not recommended)",
|
|
353
|
+
],
|
|
354
|
+
underlying,
|
|
355
|
+
meta: { reason },
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
/** The requested checkpoint id doesn't exist (or no checkpoints exist at all). */
|
|
359
|
+
export function checkpointNotFound(id) {
|
|
360
|
+
return new CruxyError({
|
|
361
|
+
code: ErrorCode.CheckpointNotFound,
|
|
362
|
+
title: id
|
|
363
|
+
? `checkpoint "${id}" not found`
|
|
364
|
+
: "no checkpoints exist in this project",
|
|
365
|
+
cause: id
|
|
366
|
+
? "no manifest with that id under .cruxy/checkpoints/"
|
|
367
|
+
: "a checkpoint is created automatically before an agent run's first file change",
|
|
368
|
+
nextSteps: [
|
|
369
|
+
"run `cruxy checkpoint list` to see the saved checkpoints",
|
|
370
|
+
"checkpoints are pruned by retention — adjust `checkpoint.retention` in config to keep more",
|
|
371
|
+
],
|
|
372
|
+
meta: { id },
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Rollback needs interactive approval but cruxy is running non-interactively.
|
|
377
|
+
* Restoring is destructive and deliberate — there is no auto-rollback path, ever.
|
|
378
|
+
*/
|
|
379
|
+
export function rollbackApprovalRequired() {
|
|
380
|
+
return new CruxyError({
|
|
381
|
+
code: ErrorCode.RollbackApprovalRequired,
|
|
382
|
+
title: "rollback needs your approval, but cruxy is running non-interactively",
|
|
383
|
+
cause: "restoring a checkpoint overwrites working-tree files and can only be confirmed in an interactive terminal",
|
|
384
|
+
nextSteps: [
|
|
385
|
+
"run `cruxy rollback` in an interactive terminal to review the preview and confirm",
|
|
386
|
+
],
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
// ── subagent (exit 2 / 11) ────────────────────────────────────────────────────
|
|
390
|
+
/**
|
|
391
|
+
* A subagent spawn was attempted past the configured nesting cap (C.14). The
|
|
392
|
+
* spawn tool is structurally withheld at the cap, so reaching this means the
|
|
393
|
+
* orchestrator seam was driven directly — fail loud, never spawn.
|
|
394
|
+
*/
|
|
395
|
+
export function subagentDepthExceeded(depth, maxDepth) {
|
|
396
|
+
return new CruxyError({
|
|
397
|
+
code: ErrorCode.SubagentDepthExceeded,
|
|
398
|
+
title: `subagent nesting depth ${depth + 1} exceeds the configured cap (${maxDepth})`,
|
|
399
|
+
cause: "subagents may not spawn subagents beyond subagent.maxDepth",
|
|
400
|
+
nextSteps: [
|
|
401
|
+
"raise `subagent.maxDepth` in config if deeper nesting is intended",
|
|
402
|
+
"or restructure the task so the parent dispatches the subtasks directly",
|
|
403
|
+
],
|
|
404
|
+
meta: { depth, maxDepth },
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* A subagent run failed outright (provider error, tool crash) before producing
|
|
409
|
+
* a result. Normally folded into the structured `SubagentResult` the parent
|
|
410
|
+
* reasons over; thrown only when the orchestrator itself cannot proceed.
|
|
411
|
+
*/
|
|
412
|
+
export function subagentFailed(underlying) {
|
|
413
|
+
return new CruxyError({
|
|
414
|
+
code: ErrorCode.SubagentFailed,
|
|
415
|
+
title: "the subagent run failed",
|
|
416
|
+
cause: messageOf(underlying),
|
|
417
|
+
nextSteps: [
|
|
418
|
+
"re-run with --verbose for details",
|
|
419
|
+
"retry the task, or narrow the subagent's scope",
|
|
420
|
+
],
|
|
421
|
+
underlying,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
338
424
|
// ── internal (exit 1) ─────────────────────────────────────────────────────────
|
|
339
425
|
export function internal(underlying) {
|
|
340
426
|
return new CruxyError({
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export declare const ErrorCode: {
|
|
|
19
19
|
readonly GitProtectedBranch: "CRUXY_E_GIT_PROTECTED_BRANCH";
|
|
20
20
|
readonly PlanInvalid: "CRUXY_E_PLAN_INVALID";
|
|
21
21
|
readonly PlanRevisionLimit: "CRUXY_E_PLAN_REVISION_LIMIT";
|
|
22
|
+
readonly CheckpointNotFound: "CRUXY_E_CHECKPOINT_NOT_FOUND";
|
|
22
23
|
readonly ConfigParse: "CRUXY_E_CONFIG_PARSE";
|
|
23
24
|
readonly ConfigInvalid: "CRUXY_E_CONFIG_INVALID";
|
|
24
25
|
readonly AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY";
|
|
@@ -34,6 +35,7 @@ export declare const ErrorCode: {
|
|
|
34
35
|
readonly FileNotFound: "CRUXY_E_FILE_NOT_FOUND";
|
|
35
36
|
readonly PermissionDenied: "CRUXY_E_PERMISSION_DENIED";
|
|
36
37
|
readonly PathEscape: "CRUXY_E_PATH_ESCAPE";
|
|
38
|
+
readonly CheckpointFailed: "CRUXY_E_CHECKPOINT_FAILED";
|
|
37
39
|
readonly IndexEmbedderUnavailable: "CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE";
|
|
38
40
|
readonly IndexStoreUnavailable: "CRUXY_E_INDEX_STORE_UNAVAILABLE";
|
|
39
41
|
readonly IndexFailed: "CRUXY_E_INDEX_FAILED";
|
|
@@ -41,6 +43,11 @@ export declare const ErrorCode: {
|
|
|
41
43
|
readonly SkillNotFound: "CRUXY_E_SKILL_NOT_FOUND";
|
|
42
44
|
readonly ApprovalRequired: "CRUXY_E_APPROVAL_REQUIRED";
|
|
43
45
|
readonly PlanApprovalRequired: "CRUXY_E_PLAN_APPROVAL_REQUIRED";
|
|
46
|
+
readonly RollbackApprovalRequired: "CRUXY_E_ROLLBACK_APPROVAL_REQUIRED";
|
|
47
|
+
readonly SubagentDepthExceeded: "CRUXY_E_SUBAGENT_DEPTH_EXCEEDED";
|
|
48
|
+
/** Carried inside a SubagentResult (informational) — never fatal by itself. */
|
|
49
|
+
readonly SubagentBudget: "CRUXY_E_SUBAGENT_BUDGET";
|
|
50
|
+
readonly SubagentFailed: "CRUXY_E_SUBAGENT_FAILED";
|
|
44
51
|
};
|
|
45
52
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
46
53
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
package/dist/errors/types.js
CHANGED
|
@@ -21,6 +21,7 @@ export const ErrorCode = {
|
|
|
21
21
|
GitProtectedBranch: "CRUXY_E_GIT_PROTECTED_BRANCH",
|
|
22
22
|
PlanInvalid: "CRUXY_E_PLAN_INVALID",
|
|
23
23
|
PlanRevisionLimit: "CRUXY_E_PLAN_REVISION_LIMIT",
|
|
24
|
+
CheckpointNotFound: "CRUXY_E_CHECKPOINT_NOT_FOUND",
|
|
24
25
|
// config (exit 3)
|
|
25
26
|
ConfigParse: "CRUXY_E_CONFIG_PARSE",
|
|
26
27
|
ConfigInvalid: "CRUXY_E_CONFIG_INVALID",
|
|
@@ -41,6 +42,7 @@ export const ErrorCode = {
|
|
|
41
42
|
FileNotFound: "CRUXY_E_FILE_NOT_FOUND",
|
|
42
43
|
PermissionDenied: "CRUXY_E_PERMISSION_DENIED",
|
|
43
44
|
PathEscape: "CRUXY_E_PATH_ESCAPE",
|
|
45
|
+
CheckpointFailed: "CRUXY_E_CHECKPOINT_FAILED",
|
|
44
46
|
// index (exit 8)
|
|
45
47
|
IndexEmbedderUnavailable: "CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE",
|
|
46
48
|
IndexStoreUnavailable: "CRUXY_E_INDEX_STORE_UNAVAILABLE",
|
|
@@ -51,6 +53,12 @@ export const ErrorCode = {
|
|
|
51
53
|
// approval (exit 10)
|
|
52
54
|
ApprovalRequired: "CRUXY_E_APPROVAL_REQUIRED",
|
|
53
55
|
PlanApprovalRequired: "CRUXY_E_PLAN_APPROVAL_REQUIRED",
|
|
56
|
+
RollbackApprovalRequired: "CRUXY_E_ROLLBACK_APPROVAL_REQUIRED",
|
|
57
|
+
// subagent (exit 2 / 11)
|
|
58
|
+
SubagentDepthExceeded: "CRUXY_E_SUBAGENT_DEPTH_EXCEEDED",
|
|
59
|
+
/** Carried inside a SubagentResult (informational) — never fatal by itself. */
|
|
60
|
+
SubagentBudget: "CRUXY_E_SUBAGENT_BUDGET",
|
|
61
|
+
SubagentFailed: "CRUXY_E_SUBAGENT_FAILED",
|
|
54
62
|
};
|
|
55
63
|
/**
|
|
56
64
|
* Category exit codes. Distinct per category so a caller (CI, a script) can
|
|
@@ -64,6 +72,7 @@ const EXIT_CODES = {
|
|
|
64
72
|
[ErrorCode.GitProtectedBranch]: 2,
|
|
65
73
|
[ErrorCode.PlanInvalid]: 2,
|
|
66
74
|
[ErrorCode.PlanRevisionLimit]: 2,
|
|
75
|
+
[ErrorCode.CheckpointNotFound]: 2,
|
|
67
76
|
[ErrorCode.ConfigParse]: 3,
|
|
68
77
|
[ErrorCode.ConfigInvalid]: 3,
|
|
69
78
|
[ErrorCode.AuthMissingKey]: 4,
|
|
@@ -79,6 +88,7 @@ const EXIT_CODES = {
|
|
|
79
88
|
[ErrorCode.FileNotFound]: 7,
|
|
80
89
|
[ErrorCode.PermissionDenied]: 7,
|
|
81
90
|
[ErrorCode.PathEscape]: 7,
|
|
91
|
+
[ErrorCode.CheckpointFailed]: 7,
|
|
82
92
|
[ErrorCode.IndexEmbedderUnavailable]: 8,
|
|
83
93
|
[ErrorCode.IndexStoreUnavailable]: 8,
|
|
84
94
|
[ErrorCode.IndexFailed]: 8,
|
|
@@ -86,6 +96,12 @@ const EXIT_CODES = {
|
|
|
86
96
|
[ErrorCode.SkillNotFound]: 9,
|
|
87
97
|
[ErrorCode.ApprovalRequired]: 10,
|
|
88
98
|
[ErrorCode.PlanApprovalRequired]: 10,
|
|
99
|
+
[ErrorCode.RollbackApprovalRequired]: 10,
|
|
100
|
+
// Depth-exceed is a misuse of the spawn seam (usage); the other two surface
|
|
101
|
+
// inside a SubagentResult and only exit the process if thrown directly.
|
|
102
|
+
[ErrorCode.SubagentDepthExceeded]: 2,
|
|
103
|
+
[ErrorCode.SubagentBudget]: 11,
|
|
104
|
+
[ErrorCode.SubagentFailed]: 11,
|
|
89
105
|
};
|
|
90
106
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
|
91
107
|
export function exitCodeFor(code) {
|
|
@@ -18,7 +18,18 @@ export interface WalkOptions {
|
|
|
18
18
|
maxFileBytes: number;
|
|
19
19
|
/** Ignore-file names to honor, relative to each directory (gitignore syntax). */
|
|
20
20
|
ignoreFileNames?: string[];
|
|
21
|
+
/**
|
|
22
|
+
* Yield binary files too (default false). The index never wants them; the
|
|
23
|
+
* checkpoint capturer (C.32) does — a snapshot must be complete to restore.
|
|
24
|
+
*/
|
|
25
|
+
includeBinary?: boolean;
|
|
21
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* The C.17 secrets denylist test, shared beyond the index: the checkpoint
|
|
29
|
+
* capturer (C.32) applies the exact same rule so a working-tree snapshot can
|
|
30
|
+
* never contain a secret-bearing file either.
|
|
31
|
+
*/
|
|
32
|
+
export declare function isSecretPath(relPath: string): boolean;
|
|
22
33
|
/**
|
|
23
34
|
* A compiled set of gitignore-style patterns, matched against paths relative to
|
|
24
35
|
* the file's own directory. Supports comments, blank lines, `!` negation,
|
package/dist/indexing/walker.js
CHANGED
|
@@ -16,7 +16,12 @@ const SECRET_PATTERNS = [
|
|
|
16
16
|
/(^|\/)\.(npmrc|netrc|pgpass)$/i, // credential dotfiles
|
|
17
17
|
/(^|\/)\.aws\/credentials$/i,
|
|
18
18
|
];
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* The C.17 secrets denylist test, shared beyond the index: the checkpoint
|
|
21
|
+
* capturer (C.32) applies the exact same rule so a working-tree snapshot can
|
|
22
|
+
* never contain a secret-bearing file either.
|
|
23
|
+
*/
|
|
24
|
+
export function isSecretPath(relPath) {
|
|
20
25
|
return SECRET_PATTERNS.some((re) => re.test(relPath));
|
|
21
26
|
}
|
|
22
27
|
/**
|
|
@@ -121,9 +126,9 @@ async function isBinaryFile(absPath) {
|
|
|
121
126
|
export async function* walkRepo(root, opts) {
|
|
122
127
|
const absRoot = path.resolve(root);
|
|
123
128
|
const ignoreFileNames = opts.ignoreFileNames ?? DEFAULT_IGNORE_FILES;
|
|
124
|
-
yield* walkDir(absRoot, absRoot, [], ignoreFileNames, opts
|
|
129
|
+
yield* walkDir(absRoot, absRoot, [], ignoreFileNames, opts);
|
|
125
130
|
}
|
|
126
|
-
async function* walkDir(dir, root, parentStack, ignoreFileNames,
|
|
131
|
+
async function* walkDir(dir, root, parentStack, ignoreFileNames, opts) {
|
|
127
132
|
const scope = await loadIgnoreScope(dir, ignoreFileNames);
|
|
128
133
|
const stack = scope ? [...parentStack, scope] : parentStack;
|
|
129
134
|
let entries;
|
|
@@ -147,7 +152,7 @@ async function* walkDir(dir, root, parentStack, ignoreFileNames, maxFileBytes) {
|
|
|
147
152
|
if (isIgnored(stack, absPath, isDir))
|
|
148
153
|
continue;
|
|
149
154
|
if (isDir) {
|
|
150
|
-
yield* walkDir(absPath, root, stack, ignoreFileNames,
|
|
155
|
+
yield* walkDir(absPath, root, stack, ignoreFileNames, opts);
|
|
151
156
|
continue;
|
|
152
157
|
}
|
|
153
158
|
let size;
|
|
@@ -157,9 +162,9 @@ async function* walkDir(dir, root, parentStack, ignoreFileNames, maxFileBytes) {
|
|
|
157
162
|
catch {
|
|
158
163
|
continue;
|
|
159
164
|
}
|
|
160
|
-
if (size > maxFileBytes)
|
|
165
|
+
if (size > opts.maxFileBytes)
|
|
161
166
|
continue;
|
|
162
|
-
if (await isBinaryFile(absPath))
|
|
167
|
+
if (!opts.includeBinary && (await isBinaryFile(absPath)))
|
|
163
168
|
continue;
|
|
164
169
|
yield { relPath, absPath, size };
|
|
165
170
|
}
|
package/dist/plan/execute.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PromptIO } from "../approval/index.js";
|
|
2
|
+
import type { StreamRenderer } from "../render/index.js";
|
|
2
3
|
import type { Plan, PlanExecutionResult, PlanStep } from "./types.js";
|
|
3
4
|
/**
|
|
4
5
|
* Execute an approved plan step-by-step (C.31): mark each step `running`, run it,
|
|
@@ -16,5 +17,12 @@ export interface ExecuteDeps {
|
|
|
16
17
|
*/
|
|
17
18
|
runStep: (step: PlanStep) => Promise<void>;
|
|
18
19
|
io: PromptIO;
|
|
20
|
+
/**
|
|
21
|
+
* Live step progress (U.4): each step feeds `[i/n] title` to the renderer's
|
|
22
|
+
* progress register, straight from this walk of `plan.steps` — the same
|
|
23
|
+
* statuses the committed trail renders, never recomputed. Optional so the
|
|
24
|
+
* executor stays drivable without a terminal (tests, future CI mode).
|
|
25
|
+
*/
|
|
26
|
+
renderer?: StreamRenderer;
|
|
19
27
|
}
|
|
20
28
|
export declare function executePlan(plan: Plan, deps: ExecuteDeps): Promise<PlanExecutionResult>;
|
package/dist/plan/execute.js
CHANGED
|
@@ -2,30 +2,44 @@ import { CruxyError } from "../errors/index.js";
|
|
|
2
2
|
import { promptContinueAfterFailure } from "./approve.js";
|
|
3
3
|
import { renderStepStatus } from "./render.js";
|
|
4
4
|
export async function executePlan(plan, deps) {
|
|
5
|
-
const { runStep, io } = deps;
|
|
6
|
-
|
|
7
|
-
step.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
const { runStep, io, renderer } = deps;
|
|
6
|
+
try {
|
|
7
|
+
for (const [index, step] of plan.steps.entries()) {
|
|
8
|
+
step.status = "running";
|
|
9
|
+
// Live: "[2/5] title · working…" until the step's agent turn takes over.
|
|
10
|
+
renderer?.progress({
|
|
11
|
+
step: index + 1,
|
|
12
|
+
of: plan.steps.length,
|
|
13
|
+
title: step.title,
|
|
14
|
+
});
|
|
15
|
+
renderer?.setPhase({ kind: "executing-step" });
|
|
12
16
|
io.write(renderStepStatus(step, io.color) + "\n");
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
try {
|
|
18
|
+
await runStep(step);
|
|
19
|
+
step.status = "done";
|
|
20
|
+
io.write(renderStepStatus(step, io.color) + "\n");
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
step.status = "failed";
|
|
24
|
+
io.write(renderStepStatus(step, io.color) + "\n");
|
|
25
|
+
// Surface the failure via the U.5 shape when we have it.
|
|
26
|
+
const detail = err instanceof CruxyError
|
|
27
|
+
? `${err.title}${err.cause ? ` — ${err.cause}` : ""}`
|
|
28
|
+
: err.message;
|
|
29
|
+
io.write(` ${detail}\n`);
|
|
30
|
+
const cont = await promptContinueAfterFailure(io);
|
|
31
|
+
if (!cont) {
|
|
32
|
+
return { completed: false, halted: true, failedStepId: step.id };
|
|
33
|
+
}
|
|
34
|
+
// User chose to continue despite the failure; move to the next step.
|
|
25
35
|
}
|
|
26
|
-
// User chose to continue despite the failure; move to the next step.
|
|
27
36
|
}
|
|
37
|
+
const completed = plan.steps.every((s) => s.status === "done");
|
|
38
|
+
return { completed, halted: false };
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
// The plan owns the progress register; release it on every exit path so
|
|
42
|
+
// no stale "[i/n]" prefix outlives the run.
|
|
43
|
+
renderer?.progress(null);
|
|
28
44
|
}
|
|
29
|
-
const completed = plan.steps.every((s) => s.status === "done");
|
|
30
|
-
return { completed, halted: false };
|
|
31
45
|
}
|
package/dist/plan/service.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { CruxyConfig } from "../config/index.js";
|
|
|
3
3
|
import type { PromptIO } from "../approval/index.js";
|
|
4
4
|
import { ToolRegistry, type ToolContext } from "../tools/index.js";
|
|
5
5
|
import { type AgentResult } from "../agent/loop.js";
|
|
6
|
+
import type { StreamRenderer } from "../render/index.js";
|
|
6
7
|
import { PlanExecutionPolicy } from "./policy.js";
|
|
7
8
|
/**
|
|
8
9
|
* Orchestrates a plan-mode turn (C.31): propose → approve/revise (capped) →
|
|
@@ -32,7 +33,7 @@ export interface PlanSessionArgs {
|
|
|
32
33
|
dirty: boolean;
|
|
33
34
|
} | null;
|
|
34
35
|
projectInstructions?: string | null;
|
|
35
|
-
|
|
36
|
+
renderer?: StreamRenderer;
|
|
36
37
|
/** Revision cap (defaults to {@link MAX_PLAN_REVISIONS}). */
|
|
37
38
|
maxRevisions?: number;
|
|
38
39
|
}
|
package/dist/plan/service.js
CHANGED
|
@@ -73,7 +73,7 @@ export async function runPlanSession(args) {
|
|
|
73
73
|
ctx: args.ctx,
|
|
74
74
|
git: args.git,
|
|
75
75
|
projectInstructions: args.projectInstructions,
|
|
76
|
-
|
|
76
|
+
renderer: args.renderer,
|
|
77
77
|
planMode: true,
|
|
78
78
|
}));
|
|
79
79
|
if (!holder.plan) {
|
|
@@ -105,10 +105,14 @@ export async function runPlanSession(args) {
|
|
|
105
105
|
ctx: args.ctx,
|
|
106
106
|
git: args.git,
|
|
107
107
|
projectInstructions: args.projectInstructions,
|
|
108
|
-
|
|
108
|
+
renderer: args.renderer,
|
|
109
109
|
}));
|
|
110
110
|
};
|
|
111
|
-
await executePlan(plan, {
|
|
111
|
+
await executePlan(plan, {
|
|
112
|
+
runStep,
|
|
113
|
+
io: args.io,
|
|
114
|
+
renderer: args.renderer,
|
|
115
|
+
});
|
|
112
116
|
return finish();
|
|
113
117
|
}
|
|
114
118
|
feedback = decision.feedback;
|
|
@@ -7,24 +7,24 @@ declare const parameters: z.ZodObject<{
|
|
|
7
7
|
rationale: z.ZodString;
|
|
8
8
|
kind: z.ZodEnum<["read", "mutate", "destructive"]>;
|
|
9
9
|
}, "strip", z.ZodTypeAny, {
|
|
10
|
-
title: string;
|
|
11
10
|
kind: "read" | "mutate" | "destructive";
|
|
11
|
+
title: string;
|
|
12
12
|
rationale: string;
|
|
13
13
|
}, {
|
|
14
|
-
title: string;
|
|
15
14
|
kind: "read" | "mutate" | "destructive";
|
|
15
|
+
title: string;
|
|
16
16
|
rationale: string;
|
|
17
17
|
}>, "many">;
|
|
18
18
|
}, "strip", z.ZodTypeAny, {
|
|
19
19
|
steps: {
|
|
20
|
-
title: string;
|
|
21
20
|
kind: "read" | "mutate" | "destructive";
|
|
21
|
+
title: string;
|
|
22
22
|
rationale: string;
|
|
23
23
|
}[];
|
|
24
24
|
}, {
|
|
25
25
|
steps: {
|
|
26
|
-
title: string;
|
|
27
26
|
kind: "read" | "mutate" | "destructive";
|
|
27
|
+
title: string;
|
|
28
28
|
rationale: string;
|
|
29
29
|
}[];
|
|
30
30
|
}>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RenderCapabilities, RenderStream } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Probe the environment once and reduce it to {@link RenderCapabilities}. Pure
|
|
4
|
+
* given its inputs (stream + env are injectable), so every row of the
|
|
5
|
+
* degradation matrix is directly testable.
|
|
6
|
+
*
|
|
7
|
+
* Color reuses {@link shouldUseColor} (NO_COLOR / FORCE_COLOR / TTY) with one
|
|
8
|
+
* refinement: `TERM=dumb` terminals get no color even though they are TTYs.
|
|
9
|
+
* Cursor control and color are independent axes — a NO_COLOR terminal still
|
|
10
|
+
* supports in-place status updates; a dumb terminal supports neither.
|
|
11
|
+
*/
|
|
12
|
+
export declare function detectCapabilities(stream?: RenderStream, env?: NodeJS.ProcessEnv): RenderCapabilities;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { shouldUseColor } from "../errors/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Probe the environment once and reduce it to {@link RenderCapabilities}. Pure
|
|
4
|
+
* given its inputs (stream + env are injectable), so every row of the
|
|
5
|
+
* degradation matrix is directly testable.
|
|
6
|
+
*
|
|
7
|
+
* Color reuses {@link shouldUseColor} (NO_COLOR / FORCE_COLOR / TTY) with one
|
|
8
|
+
* refinement: `TERM=dumb` terminals get no color even though they are TTYs.
|
|
9
|
+
* Cursor control and color are independent axes — a NO_COLOR terminal still
|
|
10
|
+
* supports in-place status updates; a dumb terminal supports neither.
|
|
11
|
+
*/
|
|
12
|
+
export function detectCapabilities(stream = process.stdout, env = process.env) {
|
|
13
|
+
const tty = Boolean(stream.isTTY);
|
|
14
|
+
const dumb = env.TERM === "dumb";
|
|
15
|
+
const cursor = tty && !dumb;
|
|
16
|
+
return {
|
|
17
|
+
tty,
|
|
18
|
+
color: shouldUseColor(stream, env) && !dumb,
|
|
19
|
+
cursor,
|
|
20
|
+
// Same set-and-non-empty convention as NO_COLOR: any value disables.
|
|
21
|
+
spinner: cursor &&
|
|
22
|
+
!(env.CRUXY_NO_SPINNER !== undefined && env.CRUXY_NO_SPINNER !== ""),
|
|
23
|
+
width: typeof stream.columns === "number" && stream.columns > 0
|
|
24
|
+
? stream.columns
|
|
25
|
+
: 80,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import type { ActionPreview } from "../tools/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The one diff/action-preview renderer (U.2). The approval prompt, PR preview,
|
|
5
|
+
* and the streaming render path all draw diffs through here — there is no
|
|
6
|
+
* second implementation to drift. Pure data → string; color is gated on a
|
|
7
|
+
* picocolors instance, so NO_COLOR/non-TTY callers get symbol-only `+`/`-`
|
|
8
|
+
* lines from the exact same code path.
|
|
9
|
+
*/
|
|
10
|
+
/** The picocolors instance type (colorless or not), from createColors. */
|
|
11
|
+
export type Colors = ReturnType<typeof pc.createColors>;
|
|
12
|
+
/** Cap on rendered preview lines before collapsing the rest. */
|
|
13
|
+
export declare const PREVIEW_MAX_LINES = 40;
|
|
14
|
+
/**
|
|
15
|
+
* Render any {@link ActionPreview} as an indented block: a diff for edits and
|
|
16
|
+
* patches, a create/overwrite listing for writes, the publish plan for PRs.
|
|
17
|
+
* Long previews collapse past {@link PREVIEW_MAX_LINES}.
|
|
18
|
+
*/
|
|
19
|
+
export declare function renderActionPreview(preview: ActionPreview | undefined, c: Colors): string;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** Cap on rendered preview lines before collapsing the rest. */
|
|
2
|
+
export const PREVIEW_MAX_LINES = 40;
|
|
3
|
+
function diffLines(oldStr, newStr, c) {
|
|
4
|
+
const removed = oldStr.split("\n").map((l) => c.red(`- ${l}`));
|
|
5
|
+
const added = newStr.split("\n").map((l) => c.green(`+ ${l}`));
|
|
6
|
+
return [...removed, ...added];
|
|
7
|
+
}
|
|
8
|
+
function renderPatchFiles(files, c) {
|
|
9
|
+
const out = [];
|
|
10
|
+
for (const file of files) {
|
|
11
|
+
if (file.op === "delete") {
|
|
12
|
+
out.push(c.red(`delete ${file.path}`));
|
|
13
|
+
}
|
|
14
|
+
else if (file.op === "create") {
|
|
15
|
+
out.push(c.green(`create ${file.path}`));
|
|
16
|
+
out.push(...file.lines.map((l) => c.green(`+ ${l}`)));
|
|
17
|
+
if (file.omittedLines > 0)
|
|
18
|
+
out.push(c.dim(` ...${file.omittedLines} more lines`));
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
out.push(c.yellow(`update ${file.path}`));
|
|
22
|
+
for (const hunk of file.hunks)
|
|
23
|
+
out.push(...diffLines(hunk.oldStr, hunk.newStr, c));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/** Render a `vcs` pull-request publish plan: branch, commit, and PR body. */
|
|
29
|
+
function renderPrPreview(preview, c) {
|
|
30
|
+
const out = [];
|
|
31
|
+
out.push(`${c.bold("branch")} ${c.green(preview.branch)} → ${preview.base}`);
|
|
32
|
+
out.push("");
|
|
33
|
+
out.push(c.bold("commit"));
|
|
34
|
+
out.push(` ${preview.commitSubject}`);
|
|
35
|
+
for (const line of bodyLines(preview.commitBody))
|
|
36
|
+
out.push(c.dim(` ${line}`));
|
|
37
|
+
out.push("");
|
|
38
|
+
out.push(`${c.bold("pull request")} ${preview.prTitle}`);
|
|
39
|
+
for (const line of bodyLines(preview.prBody))
|
|
40
|
+
out.push(c.dim(` ${line}`));
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Render a `rollback` restore plan (C.32). Header, warnings, and the
|
|
45
|
+
* out-of-scope note come **before** the file diffs on purpose: the global
|
|
46
|
+
* {@link PREVIEW_MAX_LINES} collapse trims from the tail, and the blast-radius
|
|
47
|
+
* warnings must never be the part that gets hidden.
|
|
48
|
+
*/
|
|
49
|
+
function renderRollbackPreview(preview, c) {
|
|
50
|
+
const out = [];
|
|
51
|
+
out.push(`${c.bold("restore checkpoint")} ${c.cyan(preview.checkpointId)} ${c.dim(`(${preview.createdAt})`)}`);
|
|
52
|
+
if (preview.runSummary)
|
|
53
|
+
out.push(c.dim(`run: ${preview.runSummary}`));
|
|
54
|
+
out.push(c.dim("working-tree files only — commits, pushes, and PRs made during the run are not undone"));
|
|
55
|
+
if (preview.externalPaths.length > 0) {
|
|
56
|
+
out.push(c.red(c.bold("changed outside this run — rollback will overwrite these too:")));
|
|
57
|
+
for (const p of preview.externalPaths)
|
|
58
|
+
out.push(c.red(`! ${p}`));
|
|
59
|
+
}
|
|
60
|
+
if (preview.attributionUnknown) {
|
|
61
|
+
out.push(c.yellow("this run executed shell commands; some changes below may not have been made by the run"));
|
|
62
|
+
}
|
|
63
|
+
out.push("");
|
|
64
|
+
out.push(...renderPatchFiles(preview.files, c));
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/** Split a multi-line body into trimmed-of-trailing lines, dropping a trailing blank. */
|
|
68
|
+
function bodyLines(body) {
|
|
69
|
+
const lines = body.replace(/\s+$/, "").split("\n");
|
|
70
|
+
return lines.length === 1 && lines[0] === "" ? [] : lines;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Render any {@link ActionPreview} as an indented block: a diff for edits and
|
|
74
|
+
* patches, a create/overwrite listing for writes, the publish plan for PRs.
|
|
75
|
+
* Long previews collapse past {@link PREVIEW_MAX_LINES}.
|
|
76
|
+
*/
|
|
77
|
+
export function renderActionPreview(preview, c) {
|
|
78
|
+
if (!preview)
|
|
79
|
+
return "";
|
|
80
|
+
let lines;
|
|
81
|
+
if (preview.type === "edit") {
|
|
82
|
+
lines = diffLines(preview.oldStr, preview.newStr, c);
|
|
83
|
+
}
|
|
84
|
+
else if (preview.type === "patch") {
|
|
85
|
+
lines = renderPatchFiles(preview.files, c);
|
|
86
|
+
}
|
|
87
|
+
else if (preview.type === "pr") {
|
|
88
|
+
lines = renderPrPreview(preview, c);
|
|
89
|
+
}
|
|
90
|
+
else if (preview.type === "rollback") {
|
|
91
|
+
lines = renderRollbackPreview(preview, c);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
const header = preview.exists
|
|
95
|
+
? c.yellow("OVERWRITE existing")
|
|
96
|
+
: c.green("create");
|
|
97
|
+
const body = preview.lines.map((l) => ` ${l}`);
|
|
98
|
+
if (preview.omittedLines > 0)
|
|
99
|
+
body.push(c.dim(` ...${preview.omittedLines} more lines`));
|
|
100
|
+
lines = [header, ...body];
|
|
101
|
+
}
|
|
102
|
+
if (lines.length > PREVIEW_MAX_LINES) {
|
|
103
|
+
const hidden = lines.length - PREVIEW_MAX_LINES;
|
|
104
|
+
lines = [...lines.slice(0, PREVIEW_MAX_LINES), c.dim(`...${hidden} more`)];
|
|
105
|
+
}
|
|
106
|
+
return lines.map((l) => ` ${l}`).join("\n");
|
|
107
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Colors } from "./diff.js";
|
|
2
|
+
/**
|
|
3
|
+
* Best-effort, bounded syntax highlighting for fenced code blocks in streamed
|
|
4
|
+
* markdown (U.2). Priorities, in order: never crash the stream, never hold the
|
|
5
|
+
* stream back, then look nice.
|
|
6
|
+
*
|
|
7
|
+
* - Prose passes through immediately, byte for byte. The only hold-back is a
|
|
8
|
+
* line that is still a plausible fence opener (a leading backtick run), held
|
|
9
|
+
* until disambiguated — bounded by one short line, never a block.
|
|
10
|
+
* - Inside a fence, lines are highlighted incrementally: each line is emitted
|
|
11
|
+
* the moment its newline arrives, so latency is one line, and committed
|
|
12
|
+
* output is never repainted.
|
|
13
|
+
* - Unknown language → plain text. A tokenizer throw → that line plain. The
|
|
14
|
+
* tokenizer is hand-rolled (no highlighter dependency, in the same spirit as
|
|
15
|
+
* the hand-rolled HTTP client) and colors only what it is sure about:
|
|
16
|
+
* comments, strings, keywords, numbers.
|
|
17
|
+
*/
|
|
18
|
+
/** Cross-line tokenizer state (block comments / multi-line strings). */
|
|
19
|
+
export interface HighlightCarry {
|
|
20
|
+
/** Inside a block comment (`/* … *``/`). */
|
|
21
|
+
blockComment: boolean;
|
|
22
|
+
/** Inside a multi-line string; the delimiter that will close it. */
|
|
23
|
+
stringDelim: string | null;
|
|
24
|
+
}
|
|
25
|
+
/** One line of code → styled line + the carry for the next line. */
|
|
26
|
+
export type LineHighlighter = (line: string, lang: string | null, carry: HighlightCarry) => {
|
|
27
|
+
text: string;
|
|
28
|
+
carry: HighlightCarry;
|
|
29
|
+
};
|
|
30
|
+
/** Incremental highlighter for one streamed text segment. */
|
|
31
|
+
export interface StreamHighlighter {
|
|
32
|
+
/** Feed a delta; returns the styled text that is safe to emit now. */
|
|
33
|
+
push(delta: string): string;
|
|
34
|
+
/** Return whatever is still held (segment end); resets to prose state. */
|
|
35
|
+
flush(): string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Create the per-segment streaming highlighter. `highlightLine` is injectable
|
|
39
|
+
* for tests (e.g. to prove a throwing tokenizer degrades to plain text).
|
|
40
|
+
*/
|
|
41
|
+
export declare function createStreamHighlighter(c: Colors, highlightLine?: LineHighlighter): StreamHighlighter;
|
|
42
|
+
/**
|
|
43
|
+
* Build the default per-line tokenizer over `c`. A plain left-to-right scan:
|
|
44
|
+
* comments dim, strings green, keywords magenta, numbers yellow, everything
|
|
45
|
+
* else untouched. Unknown language → identity.
|
|
46
|
+
*/
|
|
47
|
+
export declare function defaultLineHighlighter(c: Colors): LineHighlighter;
|