@aragon-agent/cli 0.5.0 → 0.5.2

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 (102) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/README.md +254 -7
  3. package/dist/agent/agent-mode.d.ts +53 -0
  4. package/dist/agent/agent-mode.js +69 -0
  5. package/dist/agent/controller.d.ts +69 -0
  6. package/dist/agent/controller.js +154 -1
  7. package/dist/agent/plan-prompt.d.ts +18 -0
  8. package/dist/agent/plan-prompt.js +93 -0
  9. package/dist/agent/reducer.d.ts +14 -1
  10. package/dist/agent/reducer.js +9 -0
  11. package/dist/agent/system-prompt.d.ts +15 -0
  12. package/dist/agent/system-prompt.js +8 -0
  13. package/dist/cli.d.ts +1 -1
  14. package/dist/cli.js +247 -23
  15. package/dist/commands/builtins.js +60 -0
  16. package/dist/commands/registry.d.ts +17 -0
  17. package/dist/config/app-paths.d.ts +71 -4
  18. package/dist/config/app-paths.js +139 -8
  19. package/dist/config/cli-commands.d.ts +43 -0
  20. package/dist/config/cli-commands.js +196 -0
  21. package/dist/config/env.d.ts +7 -1
  22. package/dist/config/env.js +53 -1
  23. package/dist/config/load.d.ts +10 -0
  24. package/dist/config/load.js +82 -2
  25. package/dist/config/migrate-home.d.ts +63 -0
  26. package/dist/config/migrate-home.js +165 -0
  27. package/dist/config/migrate-legacy-state.d.ts +10 -0
  28. package/dist/config/migrate-legacy-state.js +15 -6
  29. package/dist/config/schema.d.ts +81 -0
  30. package/dist/config/schema.js +85 -0
  31. package/dist/config/store.d.ts +30 -12
  32. package/dist/config/store.js +47 -35
  33. package/dist/input/mouse-events.d.ts +61 -0
  34. package/dist/input/mouse-events.js +153 -0
  35. package/dist/input/stdin-mouse-filter.d.ts +42 -0
  36. package/dist/input/stdin-mouse-filter.js +153 -0
  37. package/dist/logging/cli-commands.d.ts +25 -0
  38. package/dist/logging/cli-commands.js +221 -0
  39. package/dist/logging/file-sink.d.ts +114 -0
  40. package/dist/logging/file-sink.js +326 -0
  41. package/dist/logging/install.d.ts +163 -0
  42. package/dist/logging/install.js +302 -0
  43. package/dist/logging/levels.d.ts +40 -0
  44. package/dist/logging/levels.js +59 -0
  45. package/dist/logging/logger.d.ts +110 -0
  46. package/dist/logging/logger.js +237 -0
  47. package/dist/logging/redact.d.ts +28 -0
  48. package/dist/logging/redact.js +90 -0
  49. package/dist/logging/secret-registry.d.ts +30 -0
  50. package/dist/logging/secret-registry.js +55 -0
  51. package/dist/skills/paths.d.ts +2 -4
  52. package/dist/skills/paths.js +7 -9
  53. package/dist/tools/human-input.d.ts +179 -0
  54. package/dist/tools/human-input.js +227 -0
  55. package/dist/tools/index.d.ts +35 -3
  56. package/dist/tools/index.js +96 -9
  57. package/dist/tools/plan-tools.d.ts +64 -0
  58. package/dist/tools/plan-tools.js +198 -0
  59. package/dist/ui/App.d.ts +12 -1
  60. package/dist/ui/App.js +223 -17
  61. package/dist/ui/Composer.d.ts +9 -1
  62. package/dist/ui/Composer.js +26 -7
  63. package/dist/ui/ModeChip.d.ts +29 -0
  64. package/dist/ui/ModeChip.js +15 -0
  65. package/dist/ui/PromptInput.d.ts +22 -1
  66. package/dist/ui/PromptInput.js +49 -2
  67. package/dist/ui/StatusBar.d.ts +15 -0
  68. package/dist/ui/StatusBar.js +10 -2
  69. package/dist/ui/entries/ToolPreview.d.ts +2 -0
  70. package/dist/ui/entries/ToolPreview.js +64 -0
  71. package/dist/ui/glyphs.d.ts +21 -0
  72. package/dist/ui/glyphs.js +12 -0
  73. package/dist/ui/layout/AppShell.d.ts +10 -1
  74. package/dist/ui/layout/AppShell.js +38 -3
  75. package/dist/ui/layout/ScrollIndicator.d.ts +31 -0
  76. package/dist/ui/layout/ScrollIndicator.js +15 -0
  77. package/dist/ui/layout/ScrollViewport.d.ts +8 -2
  78. package/dist/ui/layout/ScrollViewport.js +24 -4
  79. package/dist/ui/layout/regions.d.ts +55 -0
  80. package/dist/ui/layout/regions.js +52 -0
  81. package/dist/ui/layout/scroll-indicator.d.ts +27 -0
  82. package/dist/ui/layout/scroll-indicator.js +35 -0
  83. package/dist/ui/layout/scroll.d.ts +9 -0
  84. package/dist/ui/layout/scroll.js +23 -0
  85. package/dist/ui/layout/wrap-rows.d.ts +24 -0
  86. package/dist/ui/layout/wrap-rows.js +74 -0
  87. package/dist/ui/overlays/HelpOverlay.js +15 -0
  88. package/dist/ui/overlays/PlanReviewOverlay.d.ts +50 -0
  89. package/dist/ui/overlays/PlanReviewOverlay.js +117 -0
  90. package/dist/ui/overlays/QuestionOverlay.d.ts +30 -0
  91. package/dist/ui/overlays/QuestionOverlay.js +193 -0
  92. package/dist/ui/overlays/SettingsScreen.d.ts +7 -0
  93. package/dist/ui/overlays/SettingsScreen.js +8 -1
  94. package/dist/ui/screen.d.ts +14 -1
  95. package/dist/ui/screen.js +37 -3
  96. package/dist/ui/use-startup-notices.d.ts +25 -0
  97. package/dist/ui/use-startup-notices.js +64 -0
  98. package/dist/ui/use-wheel-routing.d.ts +68 -0
  99. package/dist/ui/use-wheel-routing.js +139 -0
  100. package/package.json +3 -2
  101. package/runtime/insecure-tls-warning.cjs +62 -0
  102. package/runtime/insecure-tls-warning.d.cts +5 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The human-input channel (plan-mode §3.5 / §5.1) — types, the fail-closed
3
+ * default gate, and the two pure normalizers that repair whatever the model
4
+ * actually sent.
5
+ *
6
+ * Everything here is pure except the type declarations: no Ink, no React, no
7
+ * `node:*`. `tools/` is inside the glyph scanner's scope, so every literal is
8
+ * ASCII.
9
+ *
10
+ * WHY THE NORMALIZERS ARE THE ONLY AUTHORITY ON THE LIMITS (P0-4). The
11
+ * model-facing schemas in `plan-tools.ts` declare SHAPE only — `type`,
12
+ * `required`, `default` — and put every bound in the `description`. They used to
13
+ * carry `minItems` / `maxItems` / `maxLength`, and that had two failure modes at
14
+ * once: `ToolExecutor` rejected a six-question call outright with `Invalid
15
+ * parameters: ...` before any repair could run, wasting a full research turn on
16
+ * a cosmetic defect; and `ajv` is an OPTIONAL dependency of core, so without it
17
+ * the fallback validator checks `required` plus primitive types only and the
18
+ * same call sailed through. One design, two behaviours, selected by whether an
19
+ * optional install step succeeded. Bounds live here instead, where they are
20
+ * deterministic and unit-tested.
21
+ */
22
+ import { OTHER_OPTION_LABEL } from '../agent/agent-mode.js';
23
+ /**
24
+ * Fails CLOSED. The headless default — forgetting to pass a gate must refuse,
25
+ * never hang and never assume yes.
26
+ */
27
+ export const DENY_ALL_HUMAN_INPUT = {
28
+ neverPrompts: true,
29
+ canPrompt: () => false,
30
+ request: async () => null,
31
+ };
32
+ // ---------------------------------------------------------------------------
33
+ // Limits (the single authority; see the module header for why not the schema)
34
+ // ---------------------------------------------------------------------------
35
+ export const QUESTION_LIMITS = {
36
+ maxQuestions: 5,
37
+ minOptions: 2,
38
+ maxOptions: 4,
39
+ headerChars: 12,
40
+ questionChars: 300,
41
+ labelChars: 60,
42
+ optionDescriptionChars: 120,
43
+ };
44
+ export const PLAN_LIMITS = {
45
+ titleChars: 80,
46
+ summaryChars: 600,
47
+ maxSteps: 20,
48
+ stepTitleChars: 100,
49
+ stepDetailChars: 400,
50
+ maxFiles: 30,
51
+ maxRisks: 10,
52
+ maxOpenQuestions: 10,
53
+ };
54
+ /** How far back a truncation will look for a space before cutting hard. */
55
+ const WORD_BOUNDARY_WINDOW = 12;
56
+ // ---------------------------------------------------------------------------
57
+ // Small pure helpers
58
+ // ---------------------------------------------------------------------------
59
+ function asString(value) {
60
+ return typeof value === 'string' ? value : '';
61
+ }
62
+ function asArray(value) {
63
+ return Array.isArray(value) ? value : [];
64
+ }
65
+ function asRecord(value) {
66
+ return value && typeof value === 'object' && !Array.isArray(value)
67
+ ? value
68
+ : {};
69
+ }
70
+ /**
71
+ * Trim to `max` characters, preferring a word boundary within the last
72
+ * `WORD_BOUNDARY_WINDOW` characters. Cutting a sentence mid-word reads as
73
+ * corruption; cutting it at a space reads as a summary.
74
+ */
75
+ export function clampText(raw, max) {
76
+ const text = asString(raw).trim();
77
+ if (text.length <= max)
78
+ return text;
79
+ const head = text.slice(0, max);
80
+ const space = head.lastIndexOf(' ');
81
+ if (space >= max - WORD_BOUNDARY_WINDOW && space > 0)
82
+ return head.slice(0, space).trimEnd();
83
+ return head.trimEnd();
84
+ }
85
+ /**
86
+ * DROP FIRST, CAP SECOND, and it matters.
87
+ *
88
+ * Capping the raw array first would let blank or duplicate entries consume slots
89
+ * the model meant for real content: a list of 30 paths with two repeats would
90
+ * come back with 28 real ones and no way to tell that anything was lost.
91
+ */
92
+ function nonEmptyStrings(raw, max, dedupe = false) {
93
+ const cleaned = asArray(raw)
94
+ .map((v) => asString(v).trim())
95
+ .filter((s) => s.length > 0);
96
+ return (dedupe ? [...new Set(cleaned)] : cleaned).slice(0, max);
97
+ }
98
+ // ---------------------------------------------------------------------------
99
+ // normalizeQuestions
100
+ // ---------------------------------------------------------------------------
101
+ /**
102
+ * Repair whatever the model sent into something renderable.
103
+ *
104
+ * Returns `[]` when nothing survives, which the tool turns into the one hard
105
+ * failure this path has. Every rule below is reachable precisely BECAUSE the
106
+ * schema stopped declaring the bounds (P0-4); with `minItems` / `maxItems` in
107
+ * the schema, `ajv` rejected the call first and rules 1 and 4 were dead code.
108
+ */
109
+ export function normalizeQuestions(raw) {
110
+ const out = [];
111
+ // Rule 1a: keep the first N questions.
112
+ for (const item of asArray(raw).slice(0, QUESTION_LIMITS.maxQuestions)) {
113
+ const question = normalizeOneQuestion(asRecord(item), out.length);
114
+ if (question)
115
+ out.push(question);
116
+ }
117
+ return out;
118
+ }
119
+ function normalizeOneQuestion(src, index) {
120
+ const text = clampText(src.question, QUESTION_LIMITS.questionChars);
121
+ if (text.length === 0)
122
+ return null;
123
+ // Rule 1b + 1c: at least 2 options, at most 4.
124
+ const rawOptions = asArray(src.options).slice(0, QUESTION_LIMITS.maxOptions);
125
+ const options = [];
126
+ const seen = new Set();
127
+ for (const rawOption of rawOptions) {
128
+ const record = asRecord(rawOption);
129
+ const label = clampText(record.label, QUESTION_LIMITS.labelChars);
130
+ if (label.length === 0)
131
+ continue;
132
+ // Rule 4: deduplicate labels case-insensitively. Two options the user cannot
133
+ // tell apart is worse than one option fewer.
134
+ const key = label.toLowerCase();
135
+ if (seen.has(key))
136
+ continue;
137
+ seen.add(key);
138
+ options.push({
139
+ label,
140
+ description: clampText(record.description, QUESTION_LIMITS.optionDescriptionChars),
141
+ recommended: record.recommended === true,
142
+ isOther: false,
143
+ });
144
+ }
145
+ if (options.length < QUESTION_LIMITS.minOptions)
146
+ return null;
147
+ // Rules 2 and 3: exactly one recommended, always. The recommended option is
148
+ // the wizard's initial highlight, so "none" and "several" are both unusable.
149
+ const firstRecommended = options.findIndex((o) => o.recommended);
150
+ options.forEach((option, i) => {
151
+ option.recommended = i === (firstRecommended === -1 ? 0 : firstRecommended);
152
+ });
153
+ // Rule 5: the synthetic free-text escape hatch, unless the model already
154
+ // supplied something that reads as one.
155
+ if (!options.some((o) => /^other/i.test(o.label))) {
156
+ options.push({ label: OTHER_OPTION_LABEL, description: '', recommended: false, isOther: true });
157
+ }
158
+ else {
159
+ const existing = options.find((o) => /^other/i.test(o.label));
160
+ existing.isOther = true;
161
+ }
162
+ const id = asString(src.id).trim();
163
+ return {
164
+ id: id.length > 0 ? id : `q${index + 1}`,
165
+ header: clampText(src.header, QUESTION_LIMITS.headerChars) || `Q${index + 1}`,
166
+ question: text,
167
+ options,
168
+ allowMultiple: src.allowMultiple === true,
169
+ };
170
+ }
171
+ // ---------------------------------------------------------------------------
172
+ // normalizePlan
173
+ // ---------------------------------------------------------------------------
174
+ /**
175
+ * Repair a submitted plan. Returns `null` only when no step survives — the one
176
+ * hard failure, because there is then nothing left to review.
177
+ *
178
+ * A plan that is one step over the cap is still a plan, and rejecting it costs a
179
+ * full research turn to regain nothing. Truncation is silent to the model but
180
+ * VISIBLE to the user: the review overlay's position indicator counts the real
181
+ * rows, so a clipped plan cannot masquerade as complete.
182
+ */
183
+ export function normalizePlan(raw) {
184
+ const src = asRecord(raw);
185
+ // Same drop-first, cap-second discipline as `nonEmptyStrings`: a blank-titled
186
+ // step is noise, and letting it consume one of the twenty slots would silently
187
+ // discard a real step at the other end.
188
+ const steps = [];
189
+ for (const rawStep of asArray(src.steps)) {
190
+ const record = asRecord(rawStep);
191
+ const title = clampText(record.title, PLAN_LIMITS.stepTitleChars);
192
+ if (title.length === 0)
193
+ continue;
194
+ steps.push({ title, detail: clampText(record.detail, PLAN_LIMITS.stepDetailChars) });
195
+ if (steps.length === PLAN_LIMITS.maxSteps)
196
+ break;
197
+ }
198
+ if (steps.length === 0)
199
+ return null;
200
+ return {
201
+ title: clampText(src.title, PLAN_LIMITS.titleChars) || 'Untitled plan',
202
+ summary: clampText(src.summary, PLAN_LIMITS.summaryChars),
203
+ steps,
204
+ // Exact deduplication, NOT case-insensitive: POSIX paths are case-sensitive
205
+ // and two casings are two files.
206
+ filesTouched: nonEmptyStrings(src.filesTouched, PLAN_LIMITS.maxFiles, true),
207
+ risks: nonEmptyStrings(src.risks, PLAN_LIMITS.maxRisks),
208
+ openQuestions: nonEmptyStrings(src.openQuestions, PLAN_LIMITS.maxOpenQuestions),
209
+ };
210
+ }
211
+ /**
212
+ * Render the `ask_user` result: one human-readable line so the transcript
213
+ * preview is legible without parsing, then the JSON the model reads.
214
+ *
215
+ * `asked` is passed separately rather than derived from `answers.length`
216
+ * because a cancelled wizard returns fewer answers than it asked for, and
217
+ * "answered 1 of 3" is the whole information content of that line.
218
+ */
219
+ export function formatAnswersResult(shape, asked) {
220
+ const headline = shape.cancelled
221
+ ? 'User dismissed the questions.'
222
+ : `User answered ${shape.answers.length} of ${asked} questions.`;
223
+ const guidance = shape.cancelled
224
+ ? ' Proceed with your best assumptions and say what you assumed.'
225
+ : '';
226
+ return `${headline}${guidance}\n${JSON.stringify(shape, null, 2)}`;
227
+ }
@@ -9,6 +9,7 @@
9
9
  * before the tool runs.
10
10
  */
11
11
  import { type AgentTool, type ToolPolicyDecision, type ToolPolicyVerdict } from '@aragon-agent/core';
12
+ import type { AgentMode } from '../agent/agent-mode.js';
12
13
  export interface ConfirmRequest {
13
14
  tool: string;
14
15
  summary: string;
@@ -36,7 +37,10 @@ export interface BuiltinToolsOptions {
36
37
  * changes mid-turn (a `skill` call adds to it, a rescan can empty it), and a
37
38
  * cached decision would ignore the skill the model just loaded.
38
39
  *
39
- * Omitting this leaves every tool unwrapped — see the early return below.
40
+ * Omitting this leaves every tool unwrapped BY THIS GATE — see the first of
41
+ * the two independent guards at the end of `createBuiltinTools`. It used to be
42
+ * an early return, and P0-1 replaced it precisely because an early return also
43
+ * skipped the plan-mode gate below it (R2-P2-3).
40
44
  */
41
45
  toolPolicy?: () => ToolPolicyDecision;
42
46
  /**
@@ -52,6 +56,22 @@ export interface BuiltinToolsOptions {
52
56
  verdict: ToolPolicyVerdict;
53
57
  sourceNames: string[];
54
58
  }) => void;
59
+ /**
60
+ * Read the mode AT CALL TIME. Never cache: the mode can flip mid-run when the
61
+ * user approves a plan, and a cached `'plan'` would refuse the very work the
62
+ * user just authorized.
63
+ *
64
+ * Omitting this leaves every tool unwrapped by THIS gate — see the second,
65
+ * INDEPENDENT guard at the end of `createBuiltinTools`, which is what keeps
66
+ * the 7-tool / 11-tool baselines byte-identical (I-S1 / AC-G17).
67
+ */
68
+ agentMode?: () => AgentMode;
69
+ /**
70
+ * `ask_user` / `submit_plan`, appended verbatim. Empty in headless mode, where
71
+ * no overlay can be rendered and showing the model a tool it cannot use just
72
+ * burns a turn.
73
+ */
74
+ planTools?: AgentTool[];
55
75
  }
56
76
  /**
57
77
  * Tools the ceiling always permits (D-G6). The ONLY place that knows which of
@@ -68,7 +88,19 @@ export interface BuiltinToolsOptions {
68
88
  * is the whole of what this mechanism is worth, and saying so plainly beats
69
89
  * implying it covers more.
70
90
  */
71
- export declare const SKILL_TOOL_FLOOR: readonly ["read_file", "list_dir", "glob", "grep", "skill", "skill_find"];
91
+ export declare const SKILL_TOOL_FLOOR: readonly ["read_file", "list_dir", "glob", "grep", "skill", "skill_find", "ask_user", "submit_plan"];
92
+ /**
93
+ * The five tools plan mode refuses: exactly the complement of `SKILL_TOOL_FLOOR`
94
+ * within `HOST_TOOL_NAMES` — "the five that change something", the set this file
95
+ * already names in prose above.
96
+ *
97
+ * It lives HERE, next to the floor, rather than in `agent/agent-mode.ts` with
98
+ * the rest of the mode vocabulary. The invariant worth having is that these two
99
+ * sets PARTITION `HOST_TOOL_NAMES`, and a partition test is only as trustworthy
100
+ * as the distance between the two definitions it compares. Splitting them
101
+ * across modules is how they drift.
102
+ */
103
+ export declare const PLAN_MODE_BLOCKED_TOOLS: ReadonlySet<string>;
72
104
  /**
73
105
  * Every tool name this host CAN register (7 built-ins + 4 skill tools).
74
106
  *
@@ -89,5 +121,5 @@ export declare const SKILL_TOOL_FLOOR: readonly ["read_file", "list_dir", "glob"
89
121
  * declaration MEANS, and that reading must not change with how the CLI was
90
122
  * started.
91
123
  */
92
- export declare const HOST_TOOL_NAMES: readonly ["read_file", "write_file", "edit_file", "list_dir", "glob", "grep", "bash", "skill", "skill_find", "skill_install", "skill_create"];
124
+ export declare const HOST_TOOL_NAMES: readonly ["read_file", "write_file", "edit_file", "list_dir", "glob", "grep", "bash", "skill", "skill_find", "skill_install", "skill_create", "ask_user", "submit_plan"];
93
125
  export declare function createBuiltinTools(options: BuiltinToolsOptions): AgentTool[];
@@ -9,6 +9,7 @@
9
9
  * before the tool runs.
10
10
  */
11
11
  import { errorResult, evaluateToolCall, } from '@aragon-agent/core';
12
+ import { planRefusal, submitPlanRefusal } from '../agent/agent-mode.js';
12
13
  import { makeReadFile, makeWriteFile, makeEditFile, makeListDir } from './fs-tools.js';
13
14
  import { makeGlob, makeGrep } from './search-tools.js';
14
15
  import { makeBash } from './bash-tool.js';
@@ -35,7 +36,35 @@ export const SKILL_TOOL_FLOOR = [
35
36
  'grep',
36
37
  'skill',
37
38
  'skill_find',
39
+ // The two human-input tools join the floor for the same reason `skill` /
40
+ // `skill_find` did: a skill that declares `allowed-tools` without naming them
41
+ // would make `ask_user` unreachable and dead-end plan mode — the exact dead
42
+ // end iteration 2 of the skills work spent a round removing. Neither is a
43
+ // filesystem mutation, so the ceiling loses nothing it was protecting.
44
+ // (`submit_plan` does mutate the SESSION MODE, which is why the floor is now
45
+ // described as "everything that cannot write to disk" rather than
46
+ // "read-only".)
47
+ 'ask_user',
48
+ 'submit_plan',
38
49
  ];
50
+ /**
51
+ * The five tools plan mode refuses: exactly the complement of `SKILL_TOOL_FLOOR`
52
+ * within `HOST_TOOL_NAMES` — "the five that change something", the set this file
53
+ * already names in prose above.
54
+ *
55
+ * It lives HERE, next to the floor, rather than in `agent/agent-mode.ts` with
56
+ * the rest of the mode vocabulary. The invariant worth having is that these two
57
+ * sets PARTITION `HOST_TOOL_NAMES`, and a partition test is only as trustworthy
58
+ * as the distance between the two definitions it compares. Splitting them
59
+ * across modules is how they drift.
60
+ */
61
+ export const PLAN_MODE_BLOCKED_TOOLS = new Set([
62
+ 'write_file',
63
+ 'edit_file',
64
+ 'bash',
65
+ 'skill_install',
66
+ 'skill_create',
67
+ ]);
39
68
  /**
40
69
  * Every tool name this host CAN register (7 built-ins + 4 skill tools).
41
70
  *
@@ -68,6 +97,8 @@ export const HOST_TOOL_NAMES = [
68
97
  'skill_find',
69
98
  'skill_install',
70
99
  'skill_create',
100
+ 'ask_user',
101
+ 'submit_plan',
71
102
  ];
72
103
  function summarize(toolName, params) {
73
104
  if (toolName === 'bash')
@@ -121,6 +152,42 @@ function withToolPolicy(tool, getDecision, notify) {
121
152
  },
122
153
  };
123
154
  }
155
+ /**
156
+ * Wrap a tool in the plan-mode read-only gate. Same `{...tool, execute}` shape
157
+ * as `withConfirmation` / `withToolPolicy`, so nothing in the engine needs to
158
+ * know this happened.
159
+ *
160
+ * Read-only becomes a property of the WIRING rather than a promise in a prompt:
161
+ * the badge says PLAN because these five cannot run, not because the model was
162
+ * asked nicely.
163
+ *
164
+ * CODEACT IS NOT A HOLE TODAY, AND THAT IS WORTH WRITING DOWN. The
165
+ * ```execute-js``` path in the core agent loop runs through `ctx.sandbox`, not
166
+ * through any `AgentTool`, so no tool wrapper can see it. The CLI passes no
167
+ * `sandbox` to `new Agent(...)`, which makes the path inert. Anyone wiring a
168
+ * sandbox later must gate it as well, or plan mode gains a shell by the back
169
+ * door.
170
+ */
171
+ function withPlanModeGate(tool, getMode) {
172
+ const original = tool.execute;
173
+ return {
174
+ ...tool,
175
+ async execute(id, params, ctx) {
176
+ const mode = getMode();
177
+ if (mode === 'plan' && PLAN_MODE_BLOCKED_TOOLS.has(tool.name)) {
178
+ return errorResult(planRefusal(tool.name));
179
+ }
180
+ // Asymmetric on purpose: `ask_user` is available in BOTH modes, because a
181
+ // clarifying question is never harmful and refusing a tool the model can
182
+ // see just burns a turn. `submit_plan` mutates the session mode and only
183
+ // means something while a plan is being reviewed.
184
+ if (mode !== 'plan' && tool.name === 'submit_plan') {
185
+ return errorResult(submitPlanRefusal());
186
+ }
187
+ return original(id, params, ctx);
188
+ },
189
+ };
190
+ }
124
191
  export function createBuiltinTools(options) {
125
192
  const deps = { getCwd: options.getCwd };
126
193
  let tools = [
@@ -139,19 +206,39 @@ export function createBuiltinTools(options) {
139
206
  if (options.skillTools && options.skillTools.length > 0) {
140
207
  tools = [...tools, ...options.skillTools];
141
208
  }
209
+ if (options.planTools && options.planTools.length > 0) {
210
+ tools = [...tools, ...options.planTools];
211
+ }
142
212
  // No decision provider (`--no-skills`, or a caller that never enabled the
143
- // policy) means the tools go out exactly as they came in.
213
+ // policy) means the ceiling adds nothing.
144
214
  //
145
- // A ceiling that always says "allow" would behave identically, so this early
146
- // return buys nothing at run time — it buys the ability to PROVE the claim.
147
- // AC-G17 asserts object identity on this path; with a wrapper in place, "the
215
+ // A ceiling that always says "allow" would behave identically, so skipping it
216
+ // buys nothing at run time — it buys the ability to PROVE the claim. AC-G17
217
+ // asserts object identity on this path; with a wrapper in place, "the
148
218
  // `--no-skills` path is unchanged from iteration 2" would demote from a fact
149
219
  // anyone can check to an argument someone has to trust.
150
- if (!options.toolPolicy)
151
- return tools;
220
+ //
152
221
  // Outermost on purpose (D-G11). Asking a human "run bash?", waiting for them
153
222
  // to say yes, and only then reporting that policy refused it is the worst
154
- // available ordering. The ceiling needs no I/O, so it goes first.
155
- const getDecision = options.toolPolicy;
156
- return tools.map((t) => withToolPolicy(t, getDecision, options.onToolPolicyEvent));
223
+ // available ordering. The ceiling needs no I/O, so it goes before confirmation.
224
+ if (options.toolPolicy) {
225
+ const getDecision = options.toolPolicy;
226
+ tools = tools.map((t) => withToolPolicy(t, getDecision, options.onToolPolicyEvent));
227
+ }
228
+ // TWO OPTIONS, TWO INDEPENDENT GUARDS, NO SHARED EXIT.
229
+ //
230
+ // This used to be a single `if (!options.toolPolicy) return tools;` early
231
+ // return ending the function. That return sits BEFORE the plan gate, and
232
+ // `--no-skills` / `ARAGON_SKILLS=0` supply no decision provider — so
233
+ // `aragon --no-skills --plan` returned before the gate was applied and ran
234
+ // with write_file / edit_file / bash fully live while the badge said PLAN.
235
+ // If you ever collapse these two guards back into one, that is the bug you
236
+ // are reintroducing, and nothing reports it (AC-P20 / I-P3).
237
+ //
238
+ // The plan gate is applied LAST, which makes it OUTERMOST at call time: it
239
+ // needs no I/O and no state, so it must not sit behind a human wait.
240
+ if (!options.agentMode)
241
+ return tools;
242
+ const getMode = options.agentMode;
243
+ return tools.map((t) => withPlanModeGate(t, getMode));
157
244
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `ask_user` and `submit_plan` — the two tools that only exist because a human
3
+ * is attached (plan-mode §4.1 / §4.2 / §5.3).
4
+ *
5
+ * ASCII ONLY: `tools/` is inside the glyph scanner's scope.
6
+ *
7
+ * THE SCHEMAS DECLARE SHAPE, NOT POLICY (P0-4). `type`, `required` and `default`
8
+ * stay, because a `questions` value that is not an array has nothing for a
9
+ * normalizer to repair and both validator strategies agree on that check.
10
+ * Everything else — how many questions, how many options, how long a header may
11
+ * be — lives in the `description`, which is what the model actually reads, and
12
+ * is enforced by `normalizeQuestions` / `normalizePlan`. Putting `maxItems: 5`
13
+ * in the schema instead had two failure modes at once: with `ajv` present
14
+ * `ToolExecutor` rejected a six-question call outright and the repair rules were
15
+ * dead code; without it (ajv is an OPTIONAL dependency of core) the same call
16
+ * sailed straight through to the repair path. One design, two behaviours,
17
+ * selected by whether an optional install step succeeded.
18
+ */
19
+ import { type AgentTool } from '@aragon-agent/core';
20
+ import { type HumanInputGate } from './human-input.js';
21
+ /** The outcome of trying to spend one `ask_user` round. */
22
+ export interface AskRoundTicket {
23
+ ok: boolean;
24
+ used: number;
25
+ remaining: number;
26
+ max: number;
27
+ }
28
+ export interface PlanToolsOptions {
29
+ gate: HumanInputGate;
30
+ /**
31
+ * Spend one `ask_user` round. The counter is owned by the controller because
32
+ * it resets per user turn, and the controller is the only object that sees a
33
+ * turn begin.
34
+ */
35
+ takeAskRound: () => AskRoundTicket;
36
+ /**
37
+ * Run `fn` with the idle watchdog paused. `try/finally` inside, so a throw in
38
+ * the overlay path cannot leave the watchdog disarmed for the rest of the run.
39
+ */
40
+ withHumanWait: <T>(fn: () => Promise<T>) => Promise<T>;
41
+ /**
42
+ * Approve the plan: flip the session to Build IMMEDIATELY, mid-run.
43
+ *
44
+ * This is the one place a `plan -> build` transition is not deferred to
45
+ * `agent_end`, and the asymmetry is the whole point: the user has just read
46
+ * the plan and authorized exactly this work. A stray `Shift+Tab` has not.
47
+ */
48
+ onPlanApproved: () => void;
49
+ }
50
+ /**
51
+ * Build the plan tools, or `[]` for a channel that can never reach a human.
52
+ *
53
+ * KEYED ON `neverPrompts`, NOT `canPrompt()` (P0-3). This function runs inside
54
+ * `makeController()`, and in the interactive path the bridge handler is still
55
+ * null there — the App attaches it later, in an effect. So `canPrompt()` is
56
+ * false for EVERY session at this moment, and gating registration on it would
57
+ * ship plan mode with no `ask_user` and no `submit_plan`, in every interactive
58
+ * session, with nothing anywhere reporting it.
59
+ *
60
+ * Registration is a one-time structural question ("can this channel ever reach a
61
+ * human?"). `canPrompt()` answers a live one ("is one attached right now?") and
62
+ * is checked again inside each tool, where it belongs.
63
+ */
64
+ export declare function createPlanTools(opts: PlanToolsOptions): AgentTool[];