@cruxy/cli 0.23.0 → 0.25.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/dist/agent/loop.d.ts +21 -2
- package/dist/agent/loop.js +21 -5
- package/dist/agent/session.d.ts +13 -0
- package/dist/agent/session.js +6 -0
- package/dist/approval/index.d.ts +1 -0
- package/dist/approval/index.js +1 -0
- package/dist/approval/mutex.d.ts +45 -0
- package/dist/approval/mutex.js +57 -0
- package/dist/checkpoint/gate-hook.d.ts +28 -0
- package/dist/checkpoint/gate-hook.js +98 -0
- package/dist/checkpoint/gate.d.ts +7 -1
- package/dist/checkpoint/gate.js +8 -2
- package/dist/checkpoint/index.d.ts +1 -0
- package/dist/checkpoint/index.js +1 -0
- package/dist/checkpoint/service.d.ts +9 -0
- package/dist/checkpoint/service.js +20 -0
- package/dist/cli/commands/rollback.d.ts +4 -1
- package/dist/cli/commands/rollback.js +16 -9
- package/dist/cli/commands/run.js +62 -16
- package/dist/cli/onboard.js +2 -2
- package/dist/cli/repl.d.ts +1 -1
- package/dist/cli/repl.js +145 -0
- package/dist/cli/session-factory.d.ts +24 -10
- package/dist/cli/session-factory.js +179 -135
- package/dist/config/schema.d.ts +110 -0
- package/dist/config/schema.js +50 -0
- package/dist/errors/constructors.d.ts +41 -0
- package/dist/errors/constructors.js +87 -0
- package/dist/errors/types.d.ts +21 -0
- package/dist/errors/types.js +33 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/router.d.ts +58 -0
- package/dist/hooks/router.js +136 -0
- package/dist/hooks/runner.d.ts +12 -0
- package/dist/hooks/runner.js +23 -1
- package/dist/jobs/approval-queue.d.ts +85 -0
- package/dist/jobs/approval-queue.js +96 -0
- package/dist/jobs/dispatch-tool.d.ts +34 -0
- package/dist/jobs/dispatch-tool.js +96 -0
- package/dist/jobs/index.d.ts +6 -0
- package/dist/jobs/index.js +6 -0
- package/dist/jobs/log-buffer.d.ts +31 -0
- package/dist/jobs/log-buffer.js +30 -0
- package/dist/jobs/log-renderer.d.ts +32 -0
- package/dist/jobs/log-renderer.js +70 -0
- package/dist/jobs/manager.d.ts +139 -0
- package/dist/jobs/manager.js +397 -0
- package/dist/jobs/types.d.ts +81 -0
- package/dist/jobs/types.js +10 -0
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/sibling-banner.d.ts +25 -0
- package/dist/mcp/sibling-banner.js +34 -0
- package/dist/memory/recall.d.ts +24 -0
- package/dist/memory/recall.js +54 -0
- package/dist/memory/remember-tool.d.ts +3 -0
- package/dist/memory/remember-tool.js +11 -1
- package/dist/sandbox/policy.js +14 -5
- package/dist/sandbox/service.d.ts +8 -1
- package/dist/sandbox/service.js +4 -1
- package/dist/subagent/index.d.ts +1 -0
- package/dist/subagent/index.js +1 -0
- package/dist/subagent/orchestrator.d.ts +76 -2
- package/dist/subagent/orchestrator.js +208 -18
- package/dist/subagent/registry-scope.d.ts +13 -0
- package/dist/subagent/registry-scope.js +28 -2
- package/dist/subagent/semaphore.d.ts +56 -0
- package/dist/subagent/semaphore.js +53 -0
- package/dist/subagent/spawn-tool.d.ts +57 -0
- package/dist/subagent/spawn-tool.js +104 -9
- package/dist/subagent/types.d.ts +17 -2
- package/dist/testing/run-tests-tool.js +1 -1
- package/dist/tools/file/paths.d.ts +5 -6
- package/dist/tools/file/paths.js +7 -8
- package/dist/tools/shell/exec.js +36 -4
- package/dist/tools/types.d.ts +16 -5
- package/dist/workspace/add-root.d.ts +27 -0
- package/dist/workspace/add-root.js +16 -0
- package/dist/workspace/index.d.ts +2 -1
- package/dist/workspace/index.js +2 -1
- package/dist/workspace/workspace.d.ts +9 -4
- package/dist/workspace/workspace.js +9 -4
- package/package.json +1 -1
|
@@ -190,12 +190,53 @@ export declare function checkpointSetPartial(runId: string, restored: string[],
|
|
|
190
190
|
* orchestrator seam was driven directly — fail loud, never spawn.
|
|
191
191
|
*/
|
|
192
192
|
export declare function subagentDepthExceeded(depth: number, maxDepth: number): CruxyError;
|
|
193
|
+
/** One workspace root claimed by two-or-more WRITING children of a single
|
|
194
|
+
* `spawn_subagents` call — the unit of a disjoint-scope violation (C.33). */
|
|
195
|
+
export interface ScopeConflict {
|
|
196
|
+
/** The root name both children declared write scope on. */
|
|
197
|
+
root: string;
|
|
198
|
+
/** The task labels of every writing child that claimed `root` (always ≥2). */
|
|
199
|
+
tasks: string[];
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* A parallel fan-out (C.33) declared children with overlapping write scope — two
|
|
203
|
+
* or more target the SAME workspace root while both hold mutating tools. The
|
|
204
|
+
* ENTIRE batch is refused BEFORE any child is dispatched, so concurrent siblings
|
|
205
|
+
* can never race on one root's files or checkpoint. Every colliding group is
|
|
206
|
+
* named (the conflicting task pairs, per root) so the model can fix all of them
|
|
207
|
+
* at once. Surfaced as a tool error to correct (assign distinct roots, or drop
|
|
208
|
+
* write tools on all but one child per root).
|
|
209
|
+
*
|
|
210
|
+
* THE HONEST LIMIT, stated in the message: the check is on DECLARED scope — the
|
|
211
|
+
* tools + root each child was granted — NOT the files actually written. Two
|
|
212
|
+
* children could still touch disjoint paths under one root; cruxy refuses anyway
|
|
213
|
+
* rather than let two potential writers race on that root's single checkpoint.
|
|
214
|
+
*/
|
|
215
|
+
export declare function subagentScopeOverlap(conflicts: readonly ScopeConflict[]): CruxyError;
|
|
193
216
|
/**
|
|
194
217
|
* A subagent run failed outright (provider error, tool crash) before producing
|
|
195
218
|
* a result. Normally folded into the structured `SubagentResult` the parent
|
|
196
219
|
* reasons over; thrown only when the orchestrator itself cannot proceed.
|
|
197
220
|
*/
|
|
198
221
|
export declare function subagentFailed(underlying?: unknown): CruxyError;
|
|
222
|
+
/**
|
|
223
|
+
* A `run_in_background` dispatch was refused because the session already holds
|
|
224
|
+
* `jobs.maxJobs` live jobs (queued + running + paused). The MODEL corrects it, so
|
|
225
|
+
* this is a coded tool error, not a silent drop: wait for a job to finish (or
|
|
226
|
+
* cancel one) and retry, or run the work in the foreground.
|
|
227
|
+
*/
|
|
228
|
+
export declare function jobLimitExceeded(maxJobs: number, live: number): CruxyError;
|
|
229
|
+
/**
|
|
230
|
+
* `cruxy cancel/logs/rollback <id>` named a job that does not exist in this
|
|
231
|
+
* session. Jobs are session-scoped (NOT a daemon), so an id from a prior session
|
|
232
|
+
* is legitimately gone — fail loud with the id rather than a silent no-op.
|
|
233
|
+
*/
|
|
234
|
+
export declare function jobNotFound(id: string): CruxyError;
|
|
235
|
+
/**
|
|
236
|
+
* A background-jobs command was used while the feature is disabled. Surfaced with
|
|
237
|
+
* how to enable it rather than pretending there are simply no jobs.
|
|
238
|
+
*/
|
|
239
|
+
export declare function jobsDisabled(): CruxyError;
|
|
199
240
|
/**
|
|
200
241
|
* No test command could be detected and none is configured (C.13). cruxy never
|
|
201
242
|
* invents a test command — the fix is always to declare one.
|
|
@@ -737,6 +737,44 @@ export function subagentDepthExceeded(depth, maxDepth) {
|
|
|
737
737
|
meta: { depth, maxDepth },
|
|
738
738
|
});
|
|
739
739
|
}
|
|
740
|
+
/**
|
|
741
|
+
* A parallel fan-out (C.33) declared children with overlapping write scope — two
|
|
742
|
+
* or more target the SAME workspace root while both hold mutating tools. The
|
|
743
|
+
* ENTIRE batch is refused BEFORE any child is dispatched, so concurrent siblings
|
|
744
|
+
* can never race on one root's files or checkpoint. Every colliding group is
|
|
745
|
+
* named (the conflicting task pairs, per root) so the model can fix all of them
|
|
746
|
+
* at once. Surfaced as a tool error to correct (assign distinct roots, or drop
|
|
747
|
+
* write tools on all but one child per root).
|
|
748
|
+
*
|
|
749
|
+
* THE HONEST LIMIT, stated in the message: the check is on DECLARED scope — the
|
|
750
|
+
* tools + root each child was granted — NOT the files actually written. Two
|
|
751
|
+
* children could still touch disjoint paths under one root; cruxy refuses anyway
|
|
752
|
+
* rather than let two potential writers race on that root's single checkpoint.
|
|
753
|
+
*/
|
|
754
|
+
export function subagentScopeOverlap(conflicts) {
|
|
755
|
+
const list = conflicts
|
|
756
|
+
.map((c) => `root "${c.root}" ← ${c.tasks.map((t) => `“${t}”`).join(", ")}`)
|
|
757
|
+
.join("; ");
|
|
758
|
+
return new CruxyError({
|
|
759
|
+
code: ErrorCode.SubagentScopeOverlap,
|
|
760
|
+
title: conflicts.length === 1
|
|
761
|
+
? `parallel subagents overlap on root "${conflicts[0].root}"`
|
|
762
|
+
: `parallel subagents overlap on ${conflicts.length} roots`,
|
|
763
|
+
cause: `these children of one spawn_subagents call declared write scope on the ` +
|
|
764
|
+
`same root — ${list}. The check is on DECLARED scope (the tools and root ` +
|
|
765
|
+
`each child was granted), NOT the files actually written — two children ` +
|
|
766
|
+
`could still touch disjoint paths — but cruxy refuses the whole batch ` +
|
|
767
|
+
`rather than let two writers race on one root's checkpoint.`,
|
|
768
|
+
nextSteps: [
|
|
769
|
+
"give each writing child a distinct `root` (parallel writes require disjoint roots)",
|
|
770
|
+
"or grant write/shell tools to only one child per root and keep the rest read-only",
|
|
771
|
+
"or run the conflicting subtasks sequentially with spawn_subagent",
|
|
772
|
+
],
|
|
773
|
+
meta: {
|
|
774
|
+
conflicts: conflicts.map((c) => ({ root: c.root, tasks: [...c.tasks] })),
|
|
775
|
+
},
|
|
776
|
+
});
|
|
777
|
+
}
|
|
740
778
|
/**
|
|
741
779
|
* A subagent run failed outright (provider error, tool crash) before producing
|
|
742
780
|
* a result. Normally folded into the structured `SubagentResult` the parent
|
|
@@ -754,6 +792,55 @@ export function subagentFailed(underlying) {
|
|
|
754
792
|
underlying,
|
|
755
793
|
});
|
|
756
794
|
}
|
|
795
|
+
// ── background jobs (exit 2 / 19) — C.28 ──────────────────────────────────────
|
|
796
|
+
/**
|
|
797
|
+
* A `run_in_background` dispatch was refused because the session already holds
|
|
798
|
+
* `jobs.maxJobs` live jobs (queued + running + paused). The MODEL corrects it, so
|
|
799
|
+
* this is a coded tool error, not a silent drop: wait for a job to finish (or
|
|
800
|
+
* cancel one) and retry, or run the work in the foreground.
|
|
801
|
+
*/
|
|
802
|
+
export function jobLimitExceeded(maxJobs, live) {
|
|
803
|
+
return new CruxyError({
|
|
804
|
+
code: ErrorCode.JobLimit,
|
|
805
|
+
title: `too many background jobs: ${live} live, limit ${maxJobs}`,
|
|
806
|
+
cause: "the count of queued + running + paused jobs is at `jobs.maxJobs`; a new " +
|
|
807
|
+
"dispatch would exceed the ceiling on OUTSTANDING jobs (distinct from the " +
|
|
808
|
+
"shared execution cap `subagent.maxConcurrency`)",
|
|
809
|
+
nextSteps: [
|
|
810
|
+
"wait for a running job to finish, or `cruxy cancel <id>` one you no longer need",
|
|
811
|
+
"raise `jobs.maxJobs` in config if more concurrent jobs are intended",
|
|
812
|
+
"or run this task in the foreground instead of the background",
|
|
813
|
+
],
|
|
814
|
+
meta: { maxJobs, live },
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* `cruxy cancel/logs/rollback <id>` named a job that does not exist in this
|
|
819
|
+
* session. Jobs are session-scoped (NOT a daemon), so an id from a prior session
|
|
820
|
+
* is legitimately gone — fail loud with the id rather than a silent no-op.
|
|
821
|
+
*/
|
|
822
|
+
export function jobNotFound(id) {
|
|
823
|
+
return new CruxyError({
|
|
824
|
+
code: ErrorCode.JobNotFound,
|
|
825
|
+
title: `no background job with id "${id}" in this session`,
|
|
826
|
+
cause: "background jobs live only for the session that dispatched them; an id from " +
|
|
827
|
+
"a previous session, or a mistyped one, has no live job",
|
|
828
|
+
nextSteps: ["run `cruxy jobs` to list the live jobs and their ids"],
|
|
829
|
+
meta: { id },
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* A background-jobs command was used while the feature is disabled. Surfaced with
|
|
834
|
+
* how to enable it rather than pretending there are simply no jobs.
|
|
835
|
+
*/
|
|
836
|
+
export function jobsDisabled() {
|
|
837
|
+
return new CruxyError({
|
|
838
|
+
code: ErrorCode.JobsDisabled,
|
|
839
|
+
title: "background jobs are disabled",
|
|
840
|
+
cause: "`jobs.enabled` is false, so no jobs can be dispatched or listed",
|
|
841
|
+
nextSteps: ["enable it: `cruxy config set jobs.enabled true`"],
|
|
842
|
+
});
|
|
843
|
+
}
|
|
757
844
|
// ── testing (exit 2) ──────────────────────────────────────────────────────────
|
|
758
845
|
/**
|
|
759
846
|
* No test command could be detected and none is configured (C.13). cruxy never
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -52,6 +52,14 @@ export declare const ErrorCode: {
|
|
|
52
52
|
/** Carried inside a SubagentResult (informational) — never fatal by itself. */
|
|
53
53
|
readonly SubagentBudget: "CRUXY_E_SUBAGENT_BUDGET";
|
|
54
54
|
readonly SubagentFailed: "CRUXY_E_SUBAGENT_FAILED";
|
|
55
|
+
/** Two children of one parallel fan-out (C.33) declare overlapping write scope
|
|
56
|
+
* (same root). Refused pre-dispatch so concurrent siblings can never race on a
|
|
57
|
+
* root — the model corrects it (distinct roots, or read-only). */
|
|
58
|
+
readonly SubagentScopeOverlap: "CRUXY_E_SUBAGENT_SCOPE_OVERLAP";
|
|
59
|
+
/** Carried inside a SubagentResult (informational): the run was cancelled — a
|
|
60
|
+
* fatal sibling failure or Ctrl-C aborted the whole fan-out (C.33). Never a
|
|
61
|
+
* fabricated success; the parent sees exactly which children did not finish. */
|
|
62
|
+
readonly SubagentCancelled: "CRUXY_E_SUBAGENT_CANCELLED";
|
|
55
63
|
readonly TestCommandNotFound: "CRUXY_E_TEST_COMMAND_NOT_FOUND";
|
|
56
64
|
/** Carried inside a run_tests result (informational) — never fatal by itself. */
|
|
57
65
|
readonly TestIterationLimit: "CRUXY_E_TEST_ITERATION_LIMIT";
|
|
@@ -165,6 +173,19 @@ export declare const ErrorCode: {
|
|
|
165
173
|
* is a single-repo artifact, so it is refused (naming both) rather than silently
|
|
166
174
|
* PR one half. */
|
|
167
175
|
readonly VcsCrossRoot: "CRUXY_E_VCS_CROSS_ROOT";
|
|
176
|
+
/** A `run_in_background` dispatch was refused because the live job count
|
|
177
|
+
* (queued + running + paused) already sits at `jobs.maxJobs`. The MODEL corrects
|
|
178
|
+
* it — wait for a job to finish (or cancel one) and retry — so it is a usage-tier
|
|
179
|
+
* coded error, never a silently-dropped dispatch. */
|
|
180
|
+
readonly JobLimit: "CRUXY_E_JOB_LIMIT";
|
|
181
|
+
/** `cruxy cancel/logs/rollback <id>` named a job id that does not exist in this
|
|
182
|
+
* session. Jobs are session-scoped (NOT a daemon), so an id from a prior session
|
|
183
|
+
* is legitimately unknown — fail loud with the id rather than a silent no-op. */
|
|
184
|
+
readonly JobNotFound: "CRUXY_E_JOB_NOT_FOUND";
|
|
185
|
+
/** A background-jobs command (`cruxy jobs/logs/cancel`) was used while
|
|
186
|
+
* `jobs.enabled` is false. The feature is opt-in; surfaced with how to enable it
|
|
187
|
+
* rather than pretending there are simply no jobs. */
|
|
188
|
+
readonly JobsDisabled: "CRUXY_E_JOBS_DISABLED";
|
|
168
189
|
};
|
|
169
190
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
170
191
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
package/dist/errors/types.js
CHANGED
|
@@ -63,6 +63,14 @@ export const ErrorCode = {
|
|
|
63
63
|
/** Carried inside a SubagentResult (informational) — never fatal by itself. */
|
|
64
64
|
SubagentBudget: "CRUXY_E_SUBAGENT_BUDGET",
|
|
65
65
|
SubagentFailed: "CRUXY_E_SUBAGENT_FAILED",
|
|
66
|
+
/** Two children of one parallel fan-out (C.33) declare overlapping write scope
|
|
67
|
+
* (same root). Refused pre-dispatch so concurrent siblings can never race on a
|
|
68
|
+
* root — the model corrects it (distinct roots, or read-only). */
|
|
69
|
+
SubagentScopeOverlap: "CRUXY_E_SUBAGENT_SCOPE_OVERLAP",
|
|
70
|
+
/** Carried inside a SubagentResult (informational): the run was cancelled — a
|
|
71
|
+
* fatal sibling failure or Ctrl-C aborted the whole fan-out (C.33). Never a
|
|
72
|
+
* fabricated success; the parent sees exactly which children did not finish. */
|
|
73
|
+
SubagentCancelled: "CRUXY_E_SUBAGENT_CANCELLED",
|
|
66
74
|
// testing (exit 2 / 11)
|
|
67
75
|
TestCommandNotFound: "CRUXY_E_TEST_COMMAND_NOT_FOUND",
|
|
68
76
|
/** Carried inside a run_tests result (informational) — never fatal by itself. */
|
|
@@ -185,6 +193,20 @@ export const ErrorCode = {
|
|
|
185
193
|
* is a single-repo artifact, so it is refused (naming both) rather than silently
|
|
186
194
|
* PR one half. */
|
|
187
195
|
VcsCrossRoot: "CRUXY_E_VCS_CROSS_ROOT",
|
|
196
|
+
// session-scoped background jobs (exit 2 / 19) — C.28
|
|
197
|
+
/** A `run_in_background` dispatch was refused because the live job count
|
|
198
|
+
* (queued + running + paused) already sits at `jobs.maxJobs`. The MODEL corrects
|
|
199
|
+
* it — wait for a job to finish (or cancel one) and retry — so it is a usage-tier
|
|
200
|
+
* coded error, never a silently-dropped dispatch. */
|
|
201
|
+
JobLimit: "CRUXY_E_JOB_LIMIT",
|
|
202
|
+
/** `cruxy cancel/logs/rollback <id>` named a job id that does not exist in this
|
|
203
|
+
* session. Jobs are session-scoped (NOT a daemon), so an id from a prior session
|
|
204
|
+
* is legitimately unknown — fail loud with the id rather than a silent no-op. */
|
|
205
|
+
JobNotFound: "CRUXY_E_JOB_NOT_FOUND",
|
|
206
|
+
/** A background-jobs command (`cruxy jobs/logs/cancel`) was used while
|
|
207
|
+
* `jobs.enabled` is false. The feature is opt-in; surfaced with how to enable it
|
|
208
|
+
* rather than pretending there are simply no jobs. */
|
|
209
|
+
JobsDisabled: "CRUXY_E_JOBS_DISABLED",
|
|
188
210
|
};
|
|
189
211
|
/**
|
|
190
212
|
* Category exit codes. Distinct per category so a caller (CI, a script) can
|
|
@@ -230,6 +252,10 @@ const EXIT_CODES = {
|
|
|
230
252
|
[ErrorCode.SubagentDepthExceeded]: 2,
|
|
231
253
|
[ErrorCode.SubagentBudget]: 11,
|
|
232
254
|
[ErrorCode.SubagentFailed]: 11,
|
|
255
|
+
// Overlapping fan-out scope is a spawn misuse (usage, model-correctable);
|
|
256
|
+
// cancellation surfaces inside a SubagentResult and is never fatal by itself.
|
|
257
|
+
[ErrorCode.SubagentScopeOverlap]: 2,
|
|
258
|
+
[ErrorCode.SubagentCancelled]: 11,
|
|
233
259
|
// No test command is a configuration gap (usage); the iteration limit
|
|
234
260
|
// surfaces inside a run_tests result and is never fatal by itself.
|
|
235
261
|
[ErrorCode.TestCommandNotFound]: 2,
|
|
@@ -292,6 +318,13 @@ const EXIT_CODES = {
|
|
|
292
318
|
// refusals kin to the other cross-root guards — they share the greppable code.
|
|
293
319
|
[ErrorCode.VcsRemoteChanged]: 18,
|
|
294
320
|
[ErrorCode.VcsCrossRoot]: 18,
|
|
321
|
+
// Background jobs (C.28). The dispatch-limit refusal is model-correctable, so
|
|
322
|
+
// it shares the usage exit code (2) with the other model-facing coded errors
|
|
323
|
+
// (subagent depth/scope). The CLI-facing ones — an unknown job id, the feature
|
|
324
|
+
// disabled — get a distinct greppable category (19).
|
|
325
|
+
[ErrorCode.JobLimit]: 2,
|
|
326
|
+
[ErrorCode.JobNotFound]: 19,
|
|
327
|
+
[ErrorCode.JobsDisabled]: 19,
|
|
295
328
|
};
|
|
296
329
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
|
297
330
|
export function exitCodeFor(code) {
|
package/dist/hooks/index.d.ts
CHANGED
|
@@ -4,3 +4,4 @@ export { fileTrustStore, fingerprintHooks, isTrusted, memoryTrustStore, trustPat
|
|
|
4
4
|
export { HookRunner, type HookRunnerDeps, type TrustPromptInfo, } from "./runner.js";
|
|
5
5
|
export { BUILTIN_SLASH_COMMANDS, expandTemplate, isBuiltinSlash, resolveSlash, type SlashResolution, } from "./slash.js";
|
|
6
6
|
export { buildHooksService, type BuildHooksServiceOptions, type HooksService, } from "./service.js";
|
|
7
|
+
export { buildHooksRouter, type BuildHooksRouterOptions, type HooksRouterResult, type UntrustedHookRoot, } from "./router.js";
|
package/dist/hooks/index.js
CHANGED
|
@@ -4,3 +4,4 @@ export { fileTrustStore, fingerprintHooks, isTrusted, memoryTrustStore, trustPat
|
|
|
4
4
|
export { HookRunner, } from "./runner.js";
|
|
5
5
|
export { BUILTIN_SLASH_COMMANDS, expandTemplate, isBuiltinSlash, resolveSlash, } from "./slash.js";
|
|
6
6
|
export { buildHooksService, } from "./service.js";
|
|
7
|
+
export { buildHooksRouter, } from "./router.js";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { CruxyConfig } from "../config/index.js";
|
|
2
|
+
import type { LifecycleHookRunner } from "../agent/loop.js";
|
|
3
|
+
import type { logger as Logger } from "../utils/logger.js";
|
|
4
|
+
import type { DeclaredRoot, Workspace } from "../workspace/index.js";
|
|
5
|
+
import { type TrustStore } from "./trust.js";
|
|
6
|
+
import type { HookCatalog, SlashCommandSpec } from "./types.js";
|
|
7
|
+
/**
|
|
8
|
+
* Per-root hook dispatch for a multi-root session (C.26 step 5).
|
|
9
|
+
*
|
|
10
|
+
* The isolation here is a PROPERTY OF CONSTRUCTION, not a guard: each root gets
|
|
11
|
+
* its OWN {@link HookRunner} (own project-hook catalog, own trust key), so there
|
|
12
|
+
* is no code path from root A's runner to a root-B action. The router only
|
|
13
|
+
* decides WHICH runner to invoke:
|
|
14
|
+
*
|
|
15
|
+
* - Tool-scoped events (`before-tool` / `after-tool` / `on-file-change`) fire
|
|
16
|
+
* exactly the ONE acting root's runner — the same root the tool resolved via
|
|
17
|
+
* {@link selectRoot}. A's project hooks structurally cannot fire for a
|
|
18
|
+
* B-scoped call (⚖︎10, pinned by test).
|
|
19
|
+
* - Session lifecycle events (`before-run` / `after-run`) have NO acting root,
|
|
20
|
+
* so they fan EVERY trusted root sequentially, in declaration order,
|
|
21
|
+
* root-labeled; a blocking failure in any root aborts the run naming that
|
|
22
|
+
* root (JC-1). Untrusted roots are skipped and named in the startup banner —
|
|
23
|
+
* never silent (a user's pre-run guard in an untrusted root must not vanish
|
|
24
|
+
* without a word).
|
|
25
|
+
*
|
|
26
|
+
* User-scope hooks (`~/.cruxy`) are session-global — authored by you, shared by
|
|
27
|
+
* every root — so they fire EXACTLY ONCE per event through a single shared
|
|
28
|
+
* runner, never once per root.
|
|
29
|
+
*/
|
|
30
|
+
/** A root whose project hooks exist but are not trusted → named in the banner. */
|
|
31
|
+
export interface UntrustedHookRoot {
|
|
32
|
+
name: string;
|
|
33
|
+
absPath: string;
|
|
34
|
+
count: number;
|
|
35
|
+
}
|
|
36
|
+
export interface HooksRouterResult {
|
|
37
|
+
/** The lifecycle firing seam (a {@link LifecycleHookRunner}). */
|
|
38
|
+
runner: LifecycleHookRunner;
|
|
39
|
+
/** Custom slash commands (primary root only this release). */
|
|
40
|
+
commands: SlashCommandSpec[];
|
|
41
|
+
/** Roots with untrusted project hooks — named in the banner, never silent. */
|
|
42
|
+
untrustedHookRoots: UntrustedHookRoot[];
|
|
43
|
+
}
|
|
44
|
+
export interface BuildHooksRouterOptions {
|
|
45
|
+
workspace: Workspace;
|
|
46
|
+
config: CruxyConfig;
|
|
47
|
+
logger: typeof Logger;
|
|
48
|
+
trust?: TrustStore;
|
|
49
|
+
/** Per-root catalog loader; defaults to the layered on-disk loader. */
|
|
50
|
+
loadCatalog?: (root: DeclaredRoot) => Promise<HookCatalog>;
|
|
51
|
+
now?: () => string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Build the multi-root hook router. One project-hook runner per declared root
|
|
55
|
+
* (trust-gated against that root's key) plus one shared user-hook runner. Load
|
|
56
|
+
* errors are surfaced through the logger exactly as the single-root path does.
|
|
57
|
+
*/
|
|
58
|
+
export declare function buildHooksRouter(opts: BuildHooksRouterOptions): Promise<HooksRouterResult>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { selectRoot } from "../workspace/index.js";
|
|
2
|
+
import { defaultHookSources, loadHookCatalog } from "./config.js";
|
|
3
|
+
import { HookRunner } from "./runner.js";
|
|
4
|
+
import { fileTrustStore } from "./trust.js";
|
|
5
|
+
const LIFECYCLE_EVENTS = new Set(["before-run", "after-run"]);
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the ONE acting root for a tool-scoped event from the raw tool-call
|
|
8
|
+
* input, reusing the exact {@link selectRoot} precedence the tool itself uses so
|
|
9
|
+
* the hook's root always matches the tool's root. Bare/ambiguous/unknown → the
|
|
10
|
+
* primary (a hook never crosses into a root the call didn't address).
|
|
11
|
+
*/
|
|
12
|
+
function resolveActingRoot(ws, input) {
|
|
13
|
+
if (input && typeof input === "object") {
|
|
14
|
+
const rec = input;
|
|
15
|
+
const root = typeof rec.root === "string" ? rec.root : undefined;
|
|
16
|
+
const p = typeof rec.path === "string" ? rec.path : undefined;
|
|
17
|
+
if (root !== undefined || p !== undefined) {
|
|
18
|
+
try {
|
|
19
|
+
return selectRoot(ws, { root, path: p ?? "." }).root;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Unknown root name / not-contained absolute path → fall to primary.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return ws.primary();
|
|
27
|
+
}
|
|
28
|
+
class HookRouter {
|
|
29
|
+
deps;
|
|
30
|
+
constructor(deps) {
|
|
31
|
+
this.deps = deps;
|
|
32
|
+
}
|
|
33
|
+
async fire(event, ctx, hint) {
|
|
34
|
+
if (!this.deps.enabled)
|
|
35
|
+
return;
|
|
36
|
+
if (LIFECYCLE_EVENTS.has(event)) {
|
|
37
|
+
// Session lifecycle: user hooks once, then every TRUSTED root in order.
|
|
38
|
+
await this.deps.userRunner.fire(event, ctx);
|
|
39
|
+
for (const root of this.deps.workspace.roots()) {
|
|
40
|
+
const runner = this.deps.perRoot.get(root.absPath);
|
|
41
|
+
if (!runner || !runner.projectTrusted)
|
|
42
|
+
continue; // skipped; banner-named
|
|
43
|
+
// A blocking failure throws `hookFailed("<root> ▸ <hook>", …)` — the run
|
|
44
|
+
// aborts naming which root refused (the runner carries `label`).
|
|
45
|
+
await runner.fire(event, rootCtx(ctx, root));
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// Tool-scoped: exactly the ONE acting root (+ the shared user hooks).
|
|
50
|
+
const acting = resolveActingRoot(this.deps.workspace, hint?.input);
|
|
51
|
+
const actingCtx = rootCtx(ctx, acting);
|
|
52
|
+
await this.deps.userRunner.fire(event, actingCtx);
|
|
53
|
+
const runner = this.deps.perRoot.get(acting.absPath);
|
|
54
|
+
if (runner && runner.projectTrusted) {
|
|
55
|
+
await runner.fire(event, actingCtx);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** A per-root view of the tool context: the hook command runs in THAT root's
|
|
60
|
+
* directory (`runGatedShell` uses `ctx.cwd`), so a root's hook always executes
|
|
61
|
+
* against its own tree — never the primary's. */
|
|
62
|
+
function rootCtx(ctx, root) {
|
|
63
|
+
return { ...ctx, cwd: root.absPath };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Build the multi-root hook router. One project-hook runner per declared root
|
|
67
|
+
* (trust-gated against that root's key) plus one shared user-hook runner. Load
|
|
68
|
+
* errors are surfaced through the logger exactly as the single-root path does.
|
|
69
|
+
*/
|
|
70
|
+
export async function buildHooksRouter(opts) {
|
|
71
|
+
const trust = opts.trust ?? fileTrustStore();
|
|
72
|
+
const enabled = opts.config.hooks.enabled;
|
|
73
|
+
const perRoot = new Map();
|
|
74
|
+
const untrustedHookRoots = [];
|
|
75
|
+
let userHooks = [];
|
|
76
|
+
let primaryCommands = [];
|
|
77
|
+
const loadCatalog = opts.loadCatalog ??
|
|
78
|
+
((root) => loadHookCatalog(defaultHookSources(root.absPath)));
|
|
79
|
+
for (const root of opts.workspace.roots()) {
|
|
80
|
+
const catalog = await loadCatalog(root);
|
|
81
|
+
for (const err of catalog.errors) {
|
|
82
|
+
opts.logger.warn(`ignoring malformed ${err.source} hook/command "${err.name}" in ${root.name}: ${err.message}`);
|
|
83
|
+
}
|
|
84
|
+
// Each root's runner owns ONLY its project hooks. User hooks are global —
|
|
85
|
+
// loaded once (from the primary, whose `sources.user` is identical for every
|
|
86
|
+
// root) and fired through the shared runner, never per-root.
|
|
87
|
+
const projectHooks = catalog.hooks.filter((h) => h.source === "project");
|
|
88
|
+
if (root.primary) {
|
|
89
|
+
userHooks = catalog.hooks.filter((h) => h.source === "user");
|
|
90
|
+
primaryCommands = catalog.commands;
|
|
91
|
+
}
|
|
92
|
+
const runner = new HookRunner({
|
|
93
|
+
hooks: projectHooks,
|
|
94
|
+
trust,
|
|
95
|
+
enabled,
|
|
96
|
+
// Multi-root never lazy-prompts (a barrage of per-sibling prompts mid-run
|
|
97
|
+
// is worse than a named skip). Untrusted roots are skipped + banner-named;
|
|
98
|
+
// `cruxy hooks trust <path>` is the deliberate opt-in.
|
|
99
|
+
trustPrompt: false,
|
|
100
|
+
interactive: false,
|
|
101
|
+
cwd: root.absPath,
|
|
102
|
+
label: root.name,
|
|
103
|
+
announce: (m) => opts.logger.info(`[${root.name}] ${m}`),
|
|
104
|
+
reportFailure: (m) => opts.logger.warn(`[${root.name}] ${m}`),
|
|
105
|
+
now: opts.now,
|
|
106
|
+
});
|
|
107
|
+
perRoot.set(root.absPath, runner);
|
|
108
|
+
if (!runner.projectTrusted) {
|
|
109
|
+
untrustedHookRoots.push({
|
|
110
|
+
name: root.name,
|
|
111
|
+
absPath: root.absPath,
|
|
112
|
+
count: runner.projectHookCount,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Shared user-hook runner: user hooks carry no project source, so its trust
|
|
117
|
+
// gate never triggers (you authored them) — it just fires them once.
|
|
118
|
+
const userRunner = new HookRunner({
|
|
119
|
+
hooks: userHooks,
|
|
120
|
+
trust,
|
|
121
|
+
enabled,
|
|
122
|
+
trustPrompt: false,
|
|
123
|
+
interactive: false,
|
|
124
|
+
cwd: opts.workspace.primary().absPath,
|
|
125
|
+
announce: (m) => opts.logger.info(m),
|
|
126
|
+
reportFailure: (m) => opts.logger.warn(m),
|
|
127
|
+
now: opts.now,
|
|
128
|
+
});
|
|
129
|
+
const runner = new HookRouter({
|
|
130
|
+
workspace: opts.workspace,
|
|
131
|
+
perRoot,
|
|
132
|
+
userRunner,
|
|
133
|
+
enabled,
|
|
134
|
+
});
|
|
135
|
+
return { runner, commands: primaryCommands, untrustedHookRoots };
|
|
136
|
+
}
|
package/dist/hooks/runner.d.ts
CHANGED
|
@@ -36,6 +36,10 @@ export interface HookRunnerDeps {
|
|
|
36
36
|
interactive: boolean;
|
|
37
37
|
/** Project root — the trust key and fingerprint scope. */
|
|
38
38
|
cwd: string;
|
|
39
|
+
/** Root label for multi-root fan-out (C.26 step 5). When set, a blocking
|
|
40
|
+
* failure names its root (`<label> ▸ <hook>`) so an aborted run says which
|
|
41
|
+
* root refused. Undefined in the single-root path → messages unchanged. */
|
|
42
|
+
label?: string;
|
|
39
43
|
/** Interactive trust prompt (returns true to trust). Required only when a
|
|
40
44
|
* project defines hooks and `trustPrompt` + `interactive` are both on. */
|
|
41
45
|
promptTrust?: (info: TrustPromptInfo) => Promise<boolean>;
|
|
@@ -51,6 +55,14 @@ export declare class HookRunner {
|
|
|
51
55
|
constructor(deps: HookRunnerDeps);
|
|
52
56
|
/** The project hooks — the trust-gated subset. */
|
|
53
57
|
private get projectHooks();
|
|
58
|
+
/** Prefix a hook name with the root label (multi-root), else leave it. */
|
|
59
|
+
private qualify;
|
|
60
|
+
/** Whether this root's project hooks are trusted for their current
|
|
61
|
+
* fingerprint. Read-only (never prompts/records) — the router uses it to skip
|
|
62
|
+
* untrusted roots and to name them in the banner. */
|
|
63
|
+
get projectTrusted(): boolean;
|
|
64
|
+
/** Count of project (trust-gated) hooks — for the untrusted-root banner. */
|
|
65
|
+
get projectHookCount(): number;
|
|
54
66
|
/**
|
|
55
67
|
* Fire every hook registered for `event`, in catalog order. Resolves normally
|
|
56
68
|
* when all hooks pass (or advisory ones fail); THROWS `CRUXY_E_HOOK_FAILED`
|
package/dist/hooks/runner.js
CHANGED
|
@@ -10,12 +10,33 @@ export class HookRunner {
|
|
|
10
10
|
get projectHooks() {
|
|
11
11
|
return this.deps.hooks.filter((h) => h.source === "project");
|
|
12
12
|
}
|
|
13
|
+
/** Prefix a hook name with the root label (multi-root), else leave it. */
|
|
14
|
+
qualify(name) {
|
|
15
|
+
return this.deps.label ? `${this.deps.label} ▸ ${name}` : name;
|
|
16
|
+
}
|
|
17
|
+
/** Whether this root's project hooks are trusted for their current
|
|
18
|
+
* fingerprint. Read-only (never prompts/records) — the router uses it to skip
|
|
19
|
+
* untrusted roots and to name them in the banner. */
|
|
20
|
+
get projectTrusted() {
|
|
21
|
+
const projectHooks = this.projectHooks;
|
|
22
|
+
if (projectHooks.length === 0)
|
|
23
|
+
return true;
|
|
24
|
+
return isTrusted(this.deps.trust, this.deps.cwd, fingerprintHooks(projectHooks));
|
|
25
|
+
}
|
|
26
|
+
/** Count of project (trust-gated) hooks — for the untrusted-root banner. */
|
|
27
|
+
get projectHookCount() {
|
|
28
|
+
return this.projectHooks.length;
|
|
29
|
+
}
|
|
13
30
|
/**
|
|
14
31
|
* Fire every hook registered for `event`, in catalog order. Resolves normally
|
|
15
32
|
* when all hooks pass (or advisory ones fail); THROWS `CRUXY_E_HOOK_FAILED`
|
|
16
33
|
* when a blocking hook fails, or `CRUXY_E_HOOK_UNTRUSTED` when a project's
|
|
17
34
|
* hooks are not trusted. A no-op when hooks are disabled or none match.
|
|
18
35
|
*/
|
|
36
|
+
// The LifecycleHookRunner seam passes an optional acting-root hint as a 3rd
|
|
37
|
+
// arg; the single-root runner owns exactly one root and ignores it (a method
|
|
38
|
+
// with fewer params is still assignable to the interface), so single-root
|
|
39
|
+
// firing stays byte-identical.
|
|
19
40
|
async fire(event, ctx) {
|
|
20
41
|
if (!this.deps.enabled)
|
|
21
42
|
return;
|
|
@@ -33,7 +54,8 @@ export class HookRunner {
|
|
|
33
54
|
continue;
|
|
34
55
|
if (hook.blocking) {
|
|
35
56
|
// Fail-closed: stop, do not run the remaining hooks, abort the action.
|
|
36
|
-
|
|
57
|
+
// `label` (multi-root) names the refusing root in the aborted-run error.
|
|
58
|
+
throw hookFailed(this.qualify(hook.name), verdict.reason ?? "hook failed");
|
|
37
59
|
}
|
|
38
60
|
// Advisory: the action already happened (or proceeds) — report, continue.
|
|
39
61
|
this.deps.reportFailure?.(`hook "${hook.name}" failed (advisory): ${verdict.reason ?? "unknown"}`);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ApprovalDecision, RiskTier } from "../approval/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* The single foreground pending-approval queue (C.28) — the seam that lets a
|
|
4
|
+
* NON-interactive background job get a gated action approved by the ONE
|
|
5
|
+
* interactive foreground human, without becoming a second interactive context.
|
|
6
|
+
*
|
|
7
|
+
* The §0 model: a background job is not a competing consumer of the terminal —
|
|
8
|
+
* it is a PRODUCER on this queue. When a job hits a gated action it `submit`s the
|
|
9
|
+
* request here and blocks on the returned promise; it holds no terminal, no
|
|
10
|
+
* mutex, and (having released its execution slot) no compute while it waits. The
|
|
11
|
+
* foreground drains the queue when it is idle (between turns): {@link serviceAll}
|
|
12
|
+
* runs each request's `finalize` — the REAL U.3 prompt + checkpoint, serialized
|
|
13
|
+
* through the shared approval mutex INSIDE `finalize` — and settles the producer
|
|
14
|
+
* with the human's decision. Because the prompt happens on the foreground under
|
|
15
|
+
* the shared mutex, a background job can never paint a second prompt over a
|
|
16
|
+
* foreground one, and the action never executes until a human has decided.
|
|
17
|
+
*
|
|
18
|
+
* This queue is pure transport: it holds pending entries and settles them. It
|
|
19
|
+
* knows nothing about checkpoints, the mutex, or how a decision is reached — the
|
|
20
|
+
* manager composes that into each entry's `finalize`.
|
|
21
|
+
*/
|
|
22
|
+
/** A read-only view of one pending request (for `/jobs` display + tests). */
|
|
23
|
+
export interface PendingApproval {
|
|
24
|
+
/** Queue-unique id. */
|
|
25
|
+
readonly id: string;
|
|
26
|
+
/** The job that is blocked on this decision. */
|
|
27
|
+
readonly jobId: string;
|
|
28
|
+
/** One plain line describing the action (e.g. "run: rm -rf build"). */
|
|
29
|
+
readonly summary: string;
|
|
30
|
+
/** Risk tier (mutate | destructive — reads never reach the queue). */
|
|
31
|
+
readonly tier: RiskTier;
|
|
32
|
+
}
|
|
33
|
+
/** What a producing job submits: how to describe it, and how to actually decide. */
|
|
34
|
+
export interface ApprovalSubmission {
|
|
35
|
+
jobId: string;
|
|
36
|
+
summary: string;
|
|
37
|
+
tier: RiskTier;
|
|
38
|
+
/**
|
|
39
|
+
* Run the REAL decision: the interactive U.3 prompt + the job's checkpoint
|
|
40
|
+
* snapshot, serialized on the shared approval mutex. Composed by the manager so
|
|
41
|
+
* this queue stays decoupled. Called by {@link serviceAll} on the foreground;
|
|
42
|
+
* its resolved decision settles the producer's `submit` promise. A throw
|
|
43
|
+
* (e.g. a checkpoint failure) rejects the producer's promise so the job fails
|
|
44
|
+
* loud rather than hanging.
|
|
45
|
+
*/
|
|
46
|
+
finalize: () => Promise<ApprovalDecision>;
|
|
47
|
+
}
|
|
48
|
+
export declare class ApprovalQueue {
|
|
49
|
+
private readonly queue;
|
|
50
|
+
private seq;
|
|
51
|
+
/**
|
|
52
|
+
* A job submits a gated action and awaits the decision. Resolves when the
|
|
53
|
+
* foreground services it ({@link serviceAll}) or a caller {@link withdraw}s it;
|
|
54
|
+
* rejects if the finalize throws. The action does NOT execute here — the caller
|
|
55
|
+
* only proceeds after this resolves `{allow:true}`.
|
|
56
|
+
*/
|
|
57
|
+
submit(sub: ApprovalSubmission): Promise<ApprovalDecision>;
|
|
58
|
+
/** Whether any request is waiting to be serviced. */
|
|
59
|
+
hasPending(): boolean;
|
|
60
|
+
/** Count of requests waiting. */
|
|
61
|
+
get size(): number;
|
|
62
|
+
/** Read-only snapshot of every pending request, FIFO order. */
|
|
63
|
+
pending(): PendingApproval[];
|
|
64
|
+
/** The pending request for one job (a job has at most one at a time), if any. */
|
|
65
|
+
pendingFor(jobId: string): PendingApproval | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* Foreground drain: service every currently-pending request, FIFO, ONE AT A
|
|
68
|
+
* TIME — each `finalize` fully settles (including the human's keypress) before
|
|
69
|
+
* the next begins, so two background prompts never overlap and the shared mutex
|
|
70
|
+
* inside `finalize` also serializes them against any foreground action. Returns
|
|
71
|
+
* the number serviced. Requests that arrive AFTER draining starts wait for the
|
|
72
|
+
* next drain (bounded work per idle window). A finalize throw rejects that one
|
|
73
|
+
* producer and drains on — one job's checkpoint failure never wedges the queue.
|
|
74
|
+
*/
|
|
75
|
+
serviceAll(): Promise<number>;
|
|
76
|
+
/**
|
|
77
|
+
* Withdraw a job's pending request WITHOUT prompting — used when the job is
|
|
78
|
+
* cancelled (or the session exits) while it is paused: settle the producer with
|
|
79
|
+
* `decision` (a deny) so its `submit` promise resolves and the job can finish
|
|
80
|
+
* tearing down instead of blocking forever. Returns true if one was withdrawn.
|
|
81
|
+
*/
|
|
82
|
+
withdraw(jobId: string, decision: ApprovalDecision): boolean;
|
|
83
|
+
/** Settle an entry exactly once (double-settle is a defensive no-op). */
|
|
84
|
+
private settle;
|
|
85
|
+
}
|