@kolisachint/hoocode-agent 0.4.105 → 0.4.107
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/CHANGELOG.md +16 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +4 -4
- package/dist/cli/args.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +0 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -2
- package/dist/config.js.map +1 -1
- package/dist/core/agent-session.d.ts +1 -3
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +2 -2
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/bash-executor.d.ts.map +1 -1
- package/dist/core/bash-executor.js +1 -1
- package/dist/core/bash-executor.js.map +1 -1
- package/dist/core/extension-mcp-servers.d.ts +1 -1
- package/dist/core/extension-mcp-servers.d.ts.map +1 -1
- package/dist/core/extension-mcp-servers.js +1 -1
- package/dist/core/extension-mcp-servers.js.map +1 -1
- package/dist/core/extensions/plugins/index.d.ts.map +1 -1
- package/dist/core/extensions/plugins/index.js +1 -1
- package/dist/core/extensions/plugins/index.js.map +1 -1
- package/dist/core/extensions/types.d.ts +1 -3
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/mode-prompts.d.ts +1 -1
- package/dist/core/mode-prompts.d.ts.map +1 -1
- package/dist/core/mode-prompts.js +1 -1
- package/dist/core/mode-prompts.js.map +1 -1
- package/dist/core/package-manager.d.ts.map +1 -1
- package/dist/core/package-manager.js +1 -1
- package/dist/core/package-manager.js.map +1 -1
- package/dist/core/sdk.d.ts.map +1 -1
- package/dist/core/sdk.js +1 -2
- package/dist/core/sdk.js.map +1 -1
- package/dist/core/session-manager.d.ts +2 -97
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +4 -65
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +2 -3
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/telemetry.d.ts.map +1 -1
- package/dist/core/telemetry.js +1 -1
- package/dist/core/telemetry.js.map +1 -1
- package/dist/core/timings.d.ts +1 -1
- package/dist/core/timings.d.ts.map +1 -1
- package/dist/core/timings.js +2 -2
- package/dist/core/timings.js.map +1 -1
- package/dist/core/tools/{browser-continue.d.ts → browser/browser-continue.d.ts} +1 -1
- package/dist/core/tools/browser/browser-continue.d.ts.map +1 -0
- package/dist/core/tools/{browser-continue.js → browser/browser-continue.js} +2 -2
- package/dist/core/tools/browser/browser-continue.js.map +1 -0
- package/dist/core/tools/{browser-run.d.ts → browser/browser-run.d.ts} +2 -2
- package/dist/core/tools/browser/browser-run.d.ts.map +1 -0
- package/dist/core/tools/{browser-run.js → browser/browser-run.js} +2 -2
- package/dist/core/tools/browser/browser-run.js.map +1 -0
- package/dist/core/tools/browser/browsertools-shared.d.ts.map +1 -0
- package/dist/core/tools/{browsertools-shared.js → browser/browsertools-shared.js} +1 -1
- package/dist/core/tools/browser/browsertools-shared.js.map +1 -0
- package/dist/core/tools/browser/index.d.ts +8 -0
- package/dist/core/tools/browser/index.d.ts.map +1 -0
- package/dist/core/tools/browser/index.js +8 -0
- package/dist/core/tools/browser/index.js.map +1 -0
- package/dist/core/tools/{docedit.d.ts → doc/docedit.d.ts} +2 -2
- package/dist/core/tools/doc/docedit.d.ts.map +1 -0
- package/dist/core/tools/{docedit.js → doc/docedit.js} +7 -7
- package/dist/core/tools/doc/docedit.js.map +1 -0
- package/dist/core/tools/{docgrep.d.ts → doc/docgrep.d.ts} +2 -2
- package/dist/core/tools/doc/docgrep.d.ts.map +1 -0
- package/dist/core/tools/{docgrep.js → doc/docgrep.js} +6 -6
- package/dist/core/tools/doc/docgrep.js.map +1 -0
- package/dist/core/tools/{docpeek.d.ts → doc/docpeek.d.ts} +2 -2
- package/dist/core/tools/{docpeek.js.map → doc/docpeek.d.ts.map} +1 -1
- package/dist/core/tools/{docpeek.js → doc/docpeek.js} +6 -6
- package/dist/core/tools/doc/docpeek.js.map +1 -0
- package/dist/core/tools/{docread.d.ts → doc/docread.d.ts} +2 -2
- package/dist/core/tools/doc/docread.d.ts.map +1 -0
- package/dist/core/tools/{docread.js → doc/docread.js} +6 -6
- package/dist/core/tools/doc/docread.js.map +1 -0
- package/dist/core/tools/{docscan.d.ts → doc/docscan.d.ts} +2 -2
- package/dist/core/tools/doc/docscan.d.ts.map +1 -0
- package/dist/core/tools/{docscan.js → doc/docscan.js} +6 -6
- package/dist/core/tools/doc/docscan.js.map +1 -0
- package/dist/core/tools/{docwrite.d.ts → doc/docwrite.d.ts} +1 -1
- package/dist/core/tools/doc/docwrite.d.ts.map +1 -0
- package/dist/core/tools/{docwrite.js → doc/docwrite.js} +6 -6
- package/dist/core/tools/doc/docwrite.js.map +1 -0
- package/dist/core/tools/doc/index.d.ts +11 -0
- package/dist/core/tools/doc/index.d.ts.map +1 -0
- package/dist/core/tools/doc/index.js +11 -0
- package/dist/core/tools/doc/index.js.map +1 -0
- package/dist/core/tools/grep.d.ts.map +1 -1
- package/dist/core/tools/grep.js +1 -1
- package/dist/core/tools/grep.js.map +1 -1
- package/dist/core/tools/index.d.ts +42 -28
- package/dist/core/tools/index.d.ts.map +1 -1
- package/dist/core/tools/index.js +61 -199
- package/dist/core/tools/index.js.map +1 -1
- package/dist/core/tools/output-accumulator.d.ts.map +1 -1
- package/dist/core/tools/output-accumulator.js +1 -1
- package/dist/core/tools/output-accumulator.js.map +1 -1
- package/dist/core/tools/read.d.ts.map +1 -1
- package/dist/core/tools/read.js +1 -1
- package/dist/core/tools/read.js.map +1 -1
- package/dist/extensions/core/ask-options.d.ts +10 -0
- package/dist/extensions/core/ask-options.d.ts.map +1 -0
- package/dist/extensions/core/ask-options.js +83 -0
- package/dist/extensions/core/ask-options.js.map +1 -0
- package/dist/extensions/core/config.d.ts +80 -0
- package/dist/extensions/core/config.d.ts.map +1 -0
- package/dist/extensions/core/config.js +110 -0
- package/dist/extensions/core/config.js.map +1 -0
- package/dist/extensions/core/cost.d.ts +11 -0
- package/dist/extensions/core/cost.d.ts.map +1 -0
- package/dist/extensions/core/cost.js +64 -0
- package/dist/extensions/core/cost.js.map +1 -0
- package/dist/extensions/core/hoo-core.d.ts +16 -179
- package/dist/extensions/core/hoo-core.d.ts.map +1 -1
- package/dist/extensions/core/hoo-core.js +27 -1663
- package/dist/extensions/core/hoo-core.js.map +1 -1
- package/dist/extensions/core/loop.d.ts +15 -0
- package/dist/extensions/core/loop.d.ts.map +1 -0
- package/dist/extensions/core/loop.js +235 -0
- package/dist/extensions/core/loop.js.map +1 -0
- package/dist/extensions/core/marketplace.d.ts +17 -0
- package/dist/extensions/core/marketplace.d.ts.map +1 -0
- package/dist/extensions/core/marketplace.js +167 -0
- package/dist/extensions/core/marketplace.js.map +1 -0
- package/dist/extensions/core/mcp-loader.d.ts +26 -0
- package/dist/extensions/core/mcp-loader.d.ts.map +1 -0
- package/dist/extensions/core/mcp-loader.js +391 -0
- package/dist/extensions/core/mcp-loader.js.map +1 -0
- package/dist/extensions/core/modes.d.ts +56 -0
- package/dist/extensions/core/modes.d.ts.map +1 -0
- package/dist/extensions/core/modes.js +268 -0
- package/dist/extensions/core/modes.js.map +1 -0
- package/dist/extensions/core/permission-gate.d.ts +10 -0
- package/dist/extensions/core/permission-gate.d.ts.map +1 -0
- package/dist/extensions/core/permission-gate.js +167 -0
- package/dist/extensions/core/permission-gate.js.map +1 -0
- package/dist/extensions/core/scaffold.d.ts +9 -0
- package/dist/extensions/core/scaffold.d.ts.map +1 -0
- package/dist/extensions/core/scaffold.js +146 -0
- package/dist/extensions/core/scaffold.js.map +1 -0
- package/dist/extensions/core/thinking-escalation.d.ts +18 -0
- package/dist/extensions/core/thinking-escalation.d.ts.map +1 -0
- package/dist/extensions/core/thinking-escalation.js +70 -0
- package/dist/extensions/core/thinking-escalation.js.map +1 -0
- package/dist/index.d.ts +2 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -4
- package/dist/index.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +2 -2
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/command-executor.d.ts +0 -5
- package/dist/modes/interactive/command-executor.d.ts.map +1 -1
- package/dist/modes/interactive/command-executor.js +0 -7
- package/dist/modes/interactive/command-executor.js.map +1 -1
- package/dist/modes/interactive/components/branch-summary-message.d.ts +1 -1
- package/dist/modes/interactive/components/branch-summary-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/branch-summary-message.js.map +1 -1
- package/dist/modes/interactive/components/compaction-summary-message.d.ts +1 -1
- package/dist/modes/interactive/components/compaction-summary-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/compaction-summary-message.js.map +1 -1
- package/dist/modes/interactive/components/custom-message.d.ts +1 -1
- package/dist/modes/interactive/components/custom-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/custom-message.js.map +1 -1
- package/dist/modes/interactive/components/index.d.ts +0 -2
- package/dist/modes/interactive/components/index.d.ts.map +1 -1
- package/dist/modes/interactive/components/index.js +0 -2
- package/dist/modes/interactive/components/index.js.map +1 -1
- package/dist/modes/interactive/extension-chrome.d.ts +56 -0
- package/dist/modes/interactive/extension-chrome.d.ts.map +1 -0
- package/dist/modes/interactive/extension-chrome.js +172 -0
- package/dist/modes/interactive/extension-chrome.js.map +1 -0
- package/dist/modes/interactive/extension-dialogs.d.ts +82 -0
- package/dist/modes/interactive/extension-dialogs.d.ts.map +1 -0
- package/dist/modes/interactive/extension-dialogs.js +259 -0
- package/dist/modes/interactive/extension-dialogs.js.map +1 -0
- package/dist/modes/interactive/interactive-mode.d.ts +11 -161
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +263 -1571
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/resource-display.d.ts +59 -0
- package/dist/modes/interactive/resource-display.d.ts.map +1 -0
- package/dist/modes/interactive/resource-display.js +499 -0
- package/dist/modes/interactive/resource-display.js.map +1 -0
- package/dist/modes/interactive/startup-checks.d.ts +17 -0
- package/dist/modes/interactive/startup-checks.d.ts.map +1 -0
- package/dist/modes/interactive/startup-checks.js +102 -0
- package/dist/modes/interactive/startup-checks.js.map +1 -0
- package/dist/modes/interactive/team-focus.d.ts +56 -0
- package/dist/modes/interactive/team-focus.d.ts.map +1 -0
- package/dist/modes/interactive/team-focus.js +167 -0
- package/dist/modes/interactive/team-focus.js.map +1 -0
- package/dist/modes/interactive/voice/voice-controller.d.ts +66 -0
- package/dist/modes/interactive/voice/voice-controller.d.ts.map +1 -0
- package/dist/modes/interactive/voice/voice-controller.js +312 -0
- package/dist/modes/interactive/voice/voice-controller.js.map +1 -0
- package/dist/modes/interactive/voice/voice-panel.d.ts.map +1 -0
- package/dist/modes/interactive/voice/voice-panel.js.map +1 -0
- package/dist/modes/interactive/{voice-transcribe.d.ts → voice/voice-transcribe.d.ts} +16 -1
- package/dist/modes/interactive/voice/voice-transcribe.d.ts.map +1 -0
- package/dist/modes/interactive/{voice-transcribe.js → voice/voice-transcribe.js} +31 -4
- package/dist/modes/interactive/voice/voice-transcribe.js.map +1 -0
- package/dist/modes/rpc/rpc-client.d.ts +1 -2
- package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-client.js.map +1 -1
- package/dist/modes/rpc/rpc-types.d.ts +1 -2
- package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-types.js.map +1 -1
- package/dist/resources-cli.d.ts.map +1 -1
- package/dist/resources-cli.js +1 -1
- package/dist/resources-cli.js.map +1 -1
- package/dist/utils/tools-manager.d.ts.map +1 -1
- package/dist/utils/tools-manager.js +1 -1
- package/dist/utils/tools-manager.js.map +1 -1
- package/dist/utils/version-check.d.ts.map +1 -1
- package/dist/utils/version-check.js +2 -2
- package/dist/utils/version-check.js.map +1 -1
- package/docs/project-local-resources.md +1 -1
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
- package/dist/core/compaction/branch-summarization.d.ts +0 -88
- package/dist/core/compaction/branch-summarization.d.ts.map +0 -1
- package/dist/core/compaction/branch-summarization.js +0 -243
- package/dist/core/compaction/branch-summarization.js.map +0 -1
- package/dist/core/compaction/compaction.d.ts +0 -123
- package/dist/core/compaction/compaction.d.ts.map +0 -1
- package/dist/core/compaction/compaction.js +0 -635
- package/dist/core/compaction/compaction.js.map +0 -1
- package/dist/core/compaction/index.d.ts +0 -7
- package/dist/core/compaction/index.d.ts.map +0 -1
- package/dist/core/compaction/index.js +0 -7
- package/dist/core/compaction/index.js.map +0 -1
- package/dist/core/compaction/utils.d.ts +0 -38
- package/dist/core/compaction/utils.d.ts.map +0 -1
- package/dist/core/compaction/utils.js +0 -156
- package/dist/core/compaction/utils.js.map +0 -1
- package/dist/core/messages.d.ts +0 -124
- package/dist/core/messages.d.ts.map +0 -1
- package/dist/core/messages.js +0 -221
- package/dist/core/messages.js.map +0 -1
- package/dist/core/tools/browser-continue.d.ts.map +0 -1
- package/dist/core/tools/browser-continue.js.map +0 -1
- package/dist/core/tools/browser-run.d.ts.map +0 -1
- package/dist/core/tools/browser-run.js.map +0 -1
- package/dist/core/tools/browsertools-shared.d.ts.map +0 -1
- package/dist/core/tools/browsertools-shared.js.map +0 -1
- package/dist/core/tools/docedit.d.ts.map +0 -1
- package/dist/core/tools/docedit.js.map +0 -1
- package/dist/core/tools/docgrep.d.ts.map +0 -1
- package/dist/core/tools/docgrep.js.map +0 -1
- package/dist/core/tools/docpeek.d.ts.map +0 -1
- package/dist/core/tools/docread.d.ts.map +0 -1
- package/dist/core/tools/docread.js.map +0 -1
- package/dist/core/tools/docscan.d.ts.map +0 -1
- package/dist/core/tools/docscan.js.map +0 -1
- package/dist/core/tools/docwrite.d.ts.map +0 -1
- package/dist/core/tools/docwrite.js.map +0 -1
- package/dist/core/tools/output-compression.d.ts +0 -53
- package/dist/core/tools/output-compression.d.ts.map +0 -1
- package/dist/core/tools/output-compression.js +0 -392
- package/dist/core/tools/output-compression.js.map +0 -1
- package/dist/modes/interactive/components/armin.d.ts +0 -34
- package/dist/modes/interactive/components/armin.d.ts.map +0 -1
- package/dist/modes/interactive/components/armin.js +0 -333
- package/dist/modes/interactive/components/armin.js.map +0 -1
- package/dist/modes/interactive/components/daxnuts.d.ts +0 -23
- package/dist/modes/interactive/components/daxnuts.d.ts.map +0 -1
- package/dist/modes/interactive/components/daxnuts.js +0 -140
- package/dist/modes/interactive/components/daxnuts.js.map +0 -1
- package/dist/modes/interactive/components/voice-panel.d.ts.map +0 -1
- package/dist/modes/interactive/components/voice-panel.js.map +0 -1
- package/dist/modes/interactive/voice-transcribe.d.ts.map +0 -1
- package/dist/modes/interactive/voice-transcribe.js.map +0 -1
- /package/dist/core/tools/{browsertools-shared.d.ts → browser/browsertools-shared.d.ts} +0 -0
- /package/dist/modes/interactive/{components → voice}/voice-panel.d.ts +0 -0
- /package/dist/modes/interactive/{components → voice}/voice-panel.js +0 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mode system — resolves the active mode (ask/plan/build/debug), loads the
|
|
3
|
+
* mode's system prompt, filters active tools, and exposes the /mode, /plan,
|
|
4
|
+
* and /approve commands.
|
|
5
|
+
*
|
|
6
|
+
* Mode prompt search order (first hit wins):
|
|
7
|
+
* - `./.hoocode/modes/{mode}/system.md`
|
|
8
|
+
* - `~/.hoocode/modes/{mode}/system.md`
|
|
9
|
+
* - each configured/contributed external dir in declared order
|
|
10
|
+
* - built-in MODE_DEFAULTS for the four known modes
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join, relative } from "node:path";
|
|
14
|
+
import { getHooCodeDir } from "../../config.js";
|
|
15
|
+
import { DEFAULT_MODE, DEFAULT_MODE_PROMPTS as MODE_DEFAULTS } from "../../core/mode-prompts.js";
|
|
16
|
+
import { mergeSearchPaths, readConfig, readMergedConfig, writeConfig } from "./config.js";
|
|
17
|
+
const HOOCODE_DIR = getHooCodeDir();
|
|
18
|
+
/**
|
|
19
|
+
* Per-session plan file path. Keying on sessionId lets concurrent or resumed
|
|
20
|
+
* plan sessions keep distinct plans instead of clobbering each other.
|
|
21
|
+
*/
|
|
22
|
+
function getPlanPath(cwd, sessionId) {
|
|
23
|
+
return join(cwd, ".hoocode", "plans", `${sessionId}.md`);
|
|
24
|
+
}
|
|
25
|
+
/** Legacy single-file plan location, retained as a read-only fallback for /approve. */
|
|
26
|
+
function getLegacyPlanPath(cwd) {
|
|
27
|
+
return join(cwd, ".hoocode", "plan.md");
|
|
28
|
+
}
|
|
29
|
+
function tryReadFile(path) {
|
|
30
|
+
if (!existsSync(path))
|
|
31
|
+
return undefined;
|
|
32
|
+
try {
|
|
33
|
+
const text = readFileSync(path, "utf8").trim();
|
|
34
|
+
return text || undefined;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Walks search dirs in precedence order and returns the first existing
|
|
42
|
+
* `modes/{name}/system.md` content. Order: project → user → externalDirs.
|
|
43
|
+
*/
|
|
44
|
+
function resolveModeFile(name, cwd, externalDirs) {
|
|
45
|
+
const candidates = [
|
|
46
|
+
join(cwd, ".hoocode", "modes", name, "system.md"),
|
|
47
|
+
join(HOOCODE_DIR, "modes", name, "system.md"),
|
|
48
|
+
...externalDirs.map((dir) => join(dir, name, "system.md")),
|
|
49
|
+
];
|
|
50
|
+
for (const candidate of candidates) {
|
|
51
|
+
const content = tryReadFile(candidate);
|
|
52
|
+
if (content !== undefined)
|
|
53
|
+
return content;
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Returns the system prompt for the active mode.
|
|
59
|
+
*
|
|
60
|
+
* Search order (first hit wins):
|
|
61
|
+
* - `./.hoocode/modes/{mode}/system.md`
|
|
62
|
+
* - `~/.hoocode/modes/{mode}/system.md`
|
|
63
|
+
* - each of `externalDirs` in declared order (config + CLI + extension contributions)
|
|
64
|
+
* - built-in MODE_DEFAULTS for the four known modes
|
|
65
|
+
*/
|
|
66
|
+
export function buildSystemPrompt(mode, cwd, options) {
|
|
67
|
+
const modePaths = options?.modePaths ?? [];
|
|
68
|
+
return resolveModeFile(mode, cwd, modePaths) ?? MODE_DEFAULTS[mode];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Parses `.hoocode/plan.md` into named sections.
|
|
72
|
+
*
|
|
73
|
+
* Recognises both ATX headings (`## Goal`) and bold labels (`**Goal**`).
|
|
74
|
+
* Section names matched (case-insensitive): Goal, Files to modify, New files,
|
|
75
|
+
* Tests, Verification.
|
|
76
|
+
*/
|
|
77
|
+
export function parsePlanSections(planContent) {
|
|
78
|
+
const result = { raw: planContent };
|
|
79
|
+
// Match `## Heading text` or `**Heading text**` followed by content until
|
|
80
|
+
// the next heading of the same style.
|
|
81
|
+
const sectionPattern = /^(?:#{1,3}\s+(.+?)|(?:\*\*(.+?)\*\*))\s*\n([\s\S]*?)(?=(?:^#{1,3}\s+|\*\*[^*\n]+\*\*\s*\n)|$)/gm;
|
|
82
|
+
for (const match of planContent.matchAll(sectionPattern)) {
|
|
83
|
+
const heading = (match[1] ?? match[2] ?? "").toLowerCase().trim();
|
|
84
|
+
const content = match[3].trim();
|
|
85
|
+
if (!content)
|
|
86
|
+
continue;
|
|
87
|
+
if (/^goal/.test(heading)) {
|
|
88
|
+
result.goal = content;
|
|
89
|
+
}
|
|
90
|
+
else if (/files?\s+to\s+modif|^modif/.test(heading)) {
|
|
91
|
+
result.filesToModify = content;
|
|
92
|
+
}
|
|
93
|
+
else if (/new\s+files?/.test(heading)) {
|
|
94
|
+
result.newFiles = content;
|
|
95
|
+
}
|
|
96
|
+
else if (/^tests?/.test(heading)) {
|
|
97
|
+
result.tests = content;
|
|
98
|
+
}
|
|
99
|
+
else if (/^verif/.test(heading)) {
|
|
100
|
+
result.verification = content;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Builds the user message sent to the agent when `/approve` is run.
|
|
107
|
+
*
|
|
108
|
+
* If the plan has recognisable sections, each is presented as a numbered step
|
|
109
|
+
* so the agent works through them sequentially. Otherwise the raw plan is used.
|
|
110
|
+
*
|
|
111
|
+
* Execution order:
|
|
112
|
+
* 1. Modify existing files
|
|
113
|
+
* 2. Create new files
|
|
114
|
+
* 3. Update / add tests
|
|
115
|
+
* 4. Run verification commands
|
|
116
|
+
*/
|
|
117
|
+
export function buildApproveMessage(sections) {
|
|
118
|
+
const steps = [];
|
|
119
|
+
if (sections.goal) {
|
|
120
|
+
steps.push(`**Goal:** ${sections.goal}`);
|
|
121
|
+
}
|
|
122
|
+
if (sections.filesToModify) {
|
|
123
|
+
steps.push(`**Step 1 — Modify existing files:**\n${sections.filesToModify}`);
|
|
124
|
+
}
|
|
125
|
+
if (sections.newFiles) {
|
|
126
|
+
steps.push(`**Step 2 — Create new files:**\n${sections.newFiles}`);
|
|
127
|
+
}
|
|
128
|
+
if (sections.tests) {
|
|
129
|
+
steps.push(`**Step 3 — Update tests:**\n${sections.tests}`);
|
|
130
|
+
}
|
|
131
|
+
if (sections.verification) {
|
|
132
|
+
steps.push(`**Step 4 — Verify:**\n${sections.verification}`);
|
|
133
|
+
}
|
|
134
|
+
if (steps.length === 0) {
|
|
135
|
+
return `Execute the following plan:\n\n${sections.raw}`;
|
|
136
|
+
}
|
|
137
|
+
return `Execute this plan step by step. Complete each step fully before moving to the next.\n\n${steps.join("\n\n")}`;
|
|
138
|
+
}
|
|
139
|
+
// ============================================================================
|
|
140
|
+
// setupMode
|
|
141
|
+
// ============================================================================
|
|
142
|
+
export function setupMode(pi) {
|
|
143
|
+
let cachedMode = DEFAULT_MODE;
|
|
144
|
+
let cachedSystemPrompt;
|
|
145
|
+
let cachedPlanPath;
|
|
146
|
+
// ── session_start ─────────────────────────────────────────────────────────
|
|
147
|
+
// Config resolution order:
|
|
148
|
+
// 1. Read global config (~/.hoocode/hoo-config.json)
|
|
149
|
+
// 2. Read project config (./.hoocode/hoo-config.json) if present
|
|
150
|
+
// 3. Merge — project scalars win; arrays are unioned
|
|
151
|
+
// 4. Re-resolve active_mode from the merged result
|
|
152
|
+
pi.on("session_start", (_event, ctx) => {
|
|
153
|
+
// Steps 1–3: merge global + project configs
|
|
154
|
+
const config = readMergedConfig(ctx.cwd);
|
|
155
|
+
// Step 4: resolve mode from the merged config
|
|
156
|
+
cachedMode = config.active_mode ?? DEFAULT_MODE;
|
|
157
|
+
// External search dirs come from two channels:
|
|
158
|
+
// - HooConfig.mode_paths (config-declared)
|
|
159
|
+
// - pi.addModeSearchPath (CLI flags + extension contributions)
|
|
160
|
+
const modePaths = mergeSearchPaths(config.mode_paths, pi.getModeSearchPaths());
|
|
161
|
+
const rawSystemPrompt = buildSystemPrompt(cachedMode, ctx.cwd, { modePaths });
|
|
162
|
+
// Per-session plan path so concurrent sessions don't overwrite each other.
|
|
163
|
+
// The `{{PLAN_PATH}}` token in plan-mode templates is substituted here.
|
|
164
|
+
cachedPlanPath = getPlanPath(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
165
|
+
const relPlanPath = relative(ctx.cwd, cachedPlanPath) || cachedPlanPath;
|
|
166
|
+
cachedSystemPrompt = rawSystemPrompt?.replace(/\{\{PLAN_PATH\}\}/g, relPlanPath);
|
|
167
|
+
// Update footer with active mode
|
|
168
|
+
if (ctx.hasUI) {
|
|
169
|
+
ctx.ui.setMode(cachedMode);
|
|
170
|
+
}
|
|
171
|
+
// Apply tool filter from mode enabled_tools
|
|
172
|
+
const modeCfg = config.modes?.[cachedMode];
|
|
173
|
+
if (modeCfg?.enabled_tools && modeCfg.enabled_tools.length > 0) {
|
|
174
|
+
pi.setActiveTools(modeCfg.enabled_tools);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
// ── before_agent_start ────────────────────────────────────────────────────
|
|
178
|
+
pi.on("before_agent_start", (event) => {
|
|
179
|
+
if (!cachedSystemPrompt)
|
|
180
|
+
return;
|
|
181
|
+
return {
|
|
182
|
+
systemPrompt: `${event.systemPrompt}\n\n<!-- hoo-core: mode=${cachedMode} -->\n${cachedSystemPrompt}`,
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
// ── /mode command ─────────────────────────────────────────────────────────
|
|
186
|
+
const KNOWN_MODES = ["ask", "plan", "build", "debug"];
|
|
187
|
+
pi.registerCommand("mode", {
|
|
188
|
+
description: "Switch active mode. Usage: /mode <ask|plan|build|debug>",
|
|
189
|
+
getArgumentCompletions: (prefix) => KNOWN_MODES.filter((m) => m.startsWith(prefix)).map((m) => ({ value: m, label: m })),
|
|
190
|
+
handler: async (args, ctx) => {
|
|
191
|
+
const name = args.trim();
|
|
192
|
+
if (!name) {
|
|
193
|
+
ctx.ui.notify(`Active mode: ${cachedMode}`, "info");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const config = readConfig();
|
|
197
|
+
config.active_mode = name === DEFAULT_MODE ? undefined : name;
|
|
198
|
+
writeConfig(config);
|
|
199
|
+
ctx.ui.notify(`Mode set to "${name}" — reloading…`, "info");
|
|
200
|
+
await ctx.reload();
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
// ── /plan command (shorthand for /mode plan) ──────────────────────────────
|
|
204
|
+
pi.registerCommand("plan", {
|
|
205
|
+
description: "Switch to plan mode. Shorthand for /mode plan.",
|
|
206
|
+
getArgumentCompletions: () => [],
|
|
207
|
+
handler: async (_args, ctx) => {
|
|
208
|
+
const config = readConfig();
|
|
209
|
+
config.active_mode = "plan";
|
|
210
|
+
writeConfig(config);
|
|
211
|
+
ctx.ui.notify(`Mode set to "plan" — reloading…`, "info");
|
|
212
|
+
await ctx.reload();
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
// ── /approve command ──────────────────────────────────────────────────────
|
|
216
|
+
// Reads .hoocode/plan.md, parses it into named sections (Goal, Files to
|
|
217
|
+
// modify, New files, Tests, Verification), switches to build mode, then
|
|
218
|
+
// injects a step-by-step execution message into the new session.
|
|
219
|
+
pi.registerCommand("approve", {
|
|
220
|
+
description: "Approve the current plan and switch to build mode to execute it.",
|
|
221
|
+
getArgumentCompletions: () => [],
|
|
222
|
+
handler: async (_args, ctx) => {
|
|
223
|
+
if (cachedMode !== "plan") {
|
|
224
|
+
ctx.ui.notify(`/approve is only available in plan mode (current mode: "${cachedMode}")`, "warning");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// Prefer the per-session plan file, fall back to the legacy single file.
|
|
228
|
+
const sessionPlanPath = cachedPlanPath ?? getPlanPath(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
229
|
+
const candidatePaths = [sessionPlanPath, getLegacyPlanPath(ctx.cwd)];
|
|
230
|
+
let approveMessage;
|
|
231
|
+
for (const planPath of candidatePaths) {
|
|
232
|
+
if (!existsSync(planPath))
|
|
233
|
+
continue;
|
|
234
|
+
try {
|
|
235
|
+
const raw = readFileSync(planPath, "utf8").trim();
|
|
236
|
+
if (raw) {
|
|
237
|
+
const sections = parsePlanSections(raw);
|
|
238
|
+
approveMessage = buildApproveMessage(sections);
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
ctx.ui.notify(`Could not read ${relative(ctx.cwd, planPath) || planPath}`, "error");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Switch global config to build mode
|
|
248
|
+
const config = readConfig();
|
|
249
|
+
config.active_mode = "build";
|
|
250
|
+
writeConfig(config);
|
|
251
|
+
if (approveMessage) {
|
|
252
|
+
// Open a new build-mode session and deliver the parsed plan as the
|
|
253
|
+
// first user message so the agent starts executing immediately
|
|
254
|
+
await ctx.newSession({
|
|
255
|
+
withSession: async (replacedCtx) => {
|
|
256
|
+
await replacedCtx.sendUserMessage(approveMessage, { deliverAs: "followUp" });
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
const relPlan = relative(ctx.cwd, sessionPlanPath) || sessionPlanPath;
|
|
262
|
+
ctx.ui.notify(`Switched to build mode. No ${relPlan} found — describe what to build.`, "info");
|
|
263
|
+
await ctx.reload();
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
//# sourceMappingURL=modes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"modes.js","sourceRoot":"","sources":["../../../src/extensions/core/modes.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Mode system — resolves the active mode (ask/plan/build/debug), loads the\n * mode's system prompt, filters active tools, and exposes the /mode, /plan,\n * and /approve commands.\n *\n * Mode prompt search order (first hit wins):\n * - `./.hoocode/modes/{mode}/system.md`\n * - `~/.hoocode/modes/{mode}/system.md`\n * - each configured/contributed external dir in declared order\n * - built-in MODE_DEFAULTS for the four known modes\n */\n\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { join, relative } from \"node:path\";\nimport { getHooCodeDir } from \"../../config.js\";\nimport type {\n\tBeforeAgentStartEvent,\n\tBeforeAgentStartEventResult,\n\tExtensionAPI,\n\tExtensionCommandContext,\n\tExtensionContext,\n\tSessionStartEvent,\n} from \"../../core/extensions/types.js\";\nimport { DEFAULT_MODE, DEFAULT_MODE_PROMPTS as MODE_DEFAULTS } from \"../../core/mode-prompts.js\";\nimport { mergeSearchPaths, readConfig, readMergedConfig, writeConfig } from \"./config.js\";\n\nconst HOOCODE_DIR = getHooCodeDir();\n\n/**\n * Per-session plan file path. Keying on sessionId lets concurrent or resumed\n * plan sessions keep distinct plans instead of clobbering each other.\n */\nfunction getPlanPath(cwd: string, sessionId: string): string {\n\treturn join(cwd, \".hoocode\", \"plans\", `${sessionId}.md`);\n}\n\n/** Legacy single-file plan location, retained as a read-only fallback for /approve. */\nfunction getLegacyPlanPath(cwd: string): string {\n\treturn join(cwd, \".hoocode\", \"plan.md\");\n}\n\nfunction tryReadFile(path: string): string | undefined {\n\tif (!existsSync(path)) return undefined;\n\ttry {\n\t\tconst text = readFileSync(path, \"utf8\").trim();\n\t\treturn text || undefined;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n/**\n * Walks search dirs in precedence order and returns the first existing\n * `modes/{name}/system.md` content. Order: project → user → externalDirs.\n */\nfunction resolveModeFile(name: string, cwd: string, externalDirs: string[]): string | undefined {\n\tconst candidates: string[] = [\n\t\tjoin(cwd, \".hoocode\", \"modes\", name, \"system.md\"),\n\t\tjoin(HOOCODE_DIR, \"modes\", name, \"system.md\"),\n\t\t...externalDirs.map((dir) => join(dir, name, \"system.md\")),\n\t];\n\tfor (const candidate of candidates) {\n\t\tconst content = tryReadFile(candidate);\n\t\tif (content !== undefined) return content;\n\t}\n\treturn undefined;\n}\n\n/**\n * Returns the system prompt for the active mode.\n *\n * Search order (first hit wins):\n * - `./.hoocode/modes/{mode}/system.md`\n * - `~/.hoocode/modes/{mode}/system.md`\n * - each of `externalDirs` in declared order (config + CLI + extension contributions)\n * - built-in MODE_DEFAULTS for the four known modes\n */\nexport function buildSystemPrompt(mode: string, cwd: string, options?: { modePaths?: string[] }): string | undefined {\n\tconst modePaths = options?.modePaths ?? [];\n\treturn resolveModeFile(mode, cwd, modePaths) ?? MODE_DEFAULTS[mode];\n}\n\n// ============================================================================\n// Plan file: section parsing and step-by-step execution message\n// ============================================================================\n\nexport interface PlanSections {\n\tgoal?: string;\n\tfilesToModify?: string;\n\tnewFiles?: string;\n\ttests?: string;\n\tverification?: string;\n\t/** Original full text, used as fallback if no sections parsed */\n\traw: string;\n}\n\n/**\n * Parses `.hoocode/plan.md` into named sections.\n *\n * Recognises both ATX headings (`## Goal`) and bold labels (`**Goal**`).\n * Section names matched (case-insensitive): Goal, Files to modify, New files,\n * Tests, Verification.\n */\nexport function parsePlanSections(planContent: string): PlanSections {\n\tconst result: PlanSections = { raw: planContent };\n\n\t// Match `## Heading text` or `**Heading text**` followed by content until\n\t// the next heading of the same style.\n\tconst sectionPattern =\n\t\t/^(?:#{1,3}\\s+(.+?)|(?:\\*\\*(.+?)\\*\\*))\\s*\\n([\\s\\S]*?)(?=(?:^#{1,3}\\s+|\\*\\*[^*\\n]+\\*\\*\\s*\\n)|$)/gm;\n\n\tfor (const match of planContent.matchAll(sectionPattern)) {\n\t\tconst heading = (match[1] ?? match[2] ?? \"\").toLowerCase().trim();\n\t\tconst content = match[3].trim();\n\t\tif (!content) continue;\n\n\t\tif (/^goal/.test(heading)) {\n\t\t\tresult.goal = content;\n\t\t} else if (/files?\\s+to\\s+modif|^modif/.test(heading)) {\n\t\t\tresult.filesToModify = content;\n\t\t} else if (/new\\s+files?/.test(heading)) {\n\t\t\tresult.newFiles = content;\n\t\t} else if (/^tests?/.test(heading)) {\n\t\t\tresult.tests = content;\n\t\t} else if (/^verif/.test(heading)) {\n\t\t\tresult.verification = content;\n\t\t}\n\t}\n\n\treturn result;\n}\n\n/**\n * Builds the user message sent to the agent when `/approve` is run.\n *\n * If the plan has recognisable sections, each is presented as a numbered step\n * so the agent works through them sequentially. Otherwise the raw plan is used.\n *\n * Execution order:\n * 1. Modify existing files\n * 2. Create new files\n * 3. Update / add tests\n * 4. Run verification commands\n */\nexport function buildApproveMessage(sections: PlanSections): string {\n\tconst steps: string[] = [];\n\n\tif (sections.goal) {\n\t\tsteps.push(`**Goal:** ${sections.goal}`);\n\t}\n\tif (sections.filesToModify) {\n\t\tsteps.push(`**Step 1 — Modify existing files:**\\n${sections.filesToModify}`);\n\t}\n\tif (sections.newFiles) {\n\t\tsteps.push(`**Step 2 — Create new files:**\\n${sections.newFiles}`);\n\t}\n\tif (sections.tests) {\n\t\tsteps.push(`**Step 3 — Update tests:**\\n${sections.tests}`);\n\t}\n\tif (sections.verification) {\n\t\tsteps.push(`**Step 4 — Verify:**\\n${sections.verification}`);\n\t}\n\n\tif (steps.length === 0) {\n\t\treturn `Execute the following plan:\\n\\n${sections.raw}`;\n\t}\n\n\treturn `Execute this plan step by step. Complete each step fully before moving to the next.\\n\\n${steps.join(\"\\n\\n\")}`;\n}\n\n// ============================================================================\n// setupMode\n// ============================================================================\n\nexport function setupMode(pi: ExtensionAPI): void {\n\tlet cachedMode = DEFAULT_MODE;\n\tlet cachedSystemPrompt: string | undefined;\n\tlet cachedPlanPath: string | undefined;\n\n\t// ── session_start ─────────────────────────────────────────────────────────\n\t// Config resolution order:\n\t// 1. Read global config (~/.hoocode/hoo-config.json)\n\t// 2. Read project config (./.hoocode/hoo-config.json) if present\n\t// 3. Merge — project scalars win; arrays are unioned\n\t// 4. Re-resolve active_mode from the merged result\n\n\tpi.on(\"session_start\", (_event: SessionStartEvent, ctx: ExtensionContext) => {\n\t\t// Steps 1–3: merge global + project configs\n\t\tconst config = readMergedConfig(ctx.cwd);\n\n\t\t// Step 4: resolve mode from the merged config\n\t\tcachedMode = config.active_mode ?? DEFAULT_MODE;\n\t\t// External search dirs come from two channels:\n\t\t// - HooConfig.mode_paths (config-declared)\n\t\t// - pi.addModeSearchPath (CLI flags + extension contributions)\n\t\tconst modePaths = mergeSearchPaths(config.mode_paths, pi.getModeSearchPaths());\n\t\tconst rawSystemPrompt = buildSystemPrompt(cachedMode, ctx.cwd, { modePaths });\n\n\t\t// Per-session plan path so concurrent sessions don't overwrite each other.\n\t\t// The `{{PLAN_PATH}}` token in plan-mode templates is substituted here.\n\t\tcachedPlanPath = getPlanPath(ctx.cwd, ctx.sessionManager.getSessionId());\n\t\tconst relPlanPath = relative(ctx.cwd, cachedPlanPath) || cachedPlanPath;\n\t\tcachedSystemPrompt = rawSystemPrompt?.replace(/\\{\\{PLAN_PATH\\}\\}/g, relPlanPath);\n\n\t\t// Update footer with active mode\n\t\tif (ctx.hasUI) {\n\t\t\tctx.ui.setMode(cachedMode);\n\t\t}\n\n\t\t// Apply tool filter from mode enabled_tools\n\t\tconst modeCfg = config.modes?.[cachedMode];\n\t\tif (modeCfg?.enabled_tools && modeCfg.enabled_tools.length > 0) {\n\t\t\tpi.setActiveTools(modeCfg.enabled_tools);\n\t\t}\n\t});\n\n\t// ── before_agent_start ────────────────────────────────────────────────────\n\n\tpi.on(\"before_agent_start\", (event: BeforeAgentStartEvent): BeforeAgentStartEventResult | undefined => {\n\t\tif (!cachedSystemPrompt) return;\n\t\treturn {\n\t\t\tsystemPrompt: `${event.systemPrompt}\\n\\n<!-- hoo-core: mode=${cachedMode} -->\\n${cachedSystemPrompt}`,\n\t\t};\n\t});\n\n\t// ── /mode command ─────────────────────────────────────────────────────────\n\n\tconst KNOWN_MODES = [\"ask\", \"plan\", \"build\", \"debug\"];\n\n\tpi.registerCommand(\"mode\", {\n\t\tdescription: \"Switch active mode. Usage: /mode <ask|plan|build|debug>\",\n\t\tgetArgumentCompletions: (prefix: string) =>\n\t\t\tKNOWN_MODES.filter((m) => m.startsWith(prefix)).map((m) => ({ value: m, label: m })),\n\t\thandler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst name = args.trim();\n\t\t\tif (!name) {\n\t\t\t\tctx.ui.notify(`Active mode: ${cachedMode}`, \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst config = readConfig();\n\t\t\tconfig.active_mode = name === DEFAULT_MODE ? undefined : name;\n\t\t\twriteConfig(config);\n\t\t\tctx.ui.notify(`Mode set to \"${name}\" — reloading…`, \"info\");\n\t\t\tawait ctx.reload();\n\t\t},\n\t});\n\n\t// ── /plan command (shorthand for /mode plan) ──────────────────────────────\n\n\tpi.registerCommand(\"plan\", {\n\t\tdescription: \"Switch to plan mode. Shorthand for /mode plan.\",\n\t\tgetArgumentCompletions: () => [],\n\t\thandler: async (_args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst config = readConfig();\n\t\t\tconfig.active_mode = \"plan\";\n\t\t\twriteConfig(config);\n\t\t\tctx.ui.notify(`Mode set to \"plan\" — reloading…`, \"info\");\n\t\t\tawait ctx.reload();\n\t\t},\n\t});\n\n\t// ── /approve command ──────────────────────────────────────────────────────\n\t// Reads .hoocode/plan.md, parses it into named sections (Goal, Files to\n\t// modify, New files, Tests, Verification), switches to build mode, then\n\t// injects a step-by-step execution message into the new session.\n\n\tpi.registerCommand(\"approve\", {\n\t\tdescription: \"Approve the current plan and switch to build mode to execute it.\",\n\t\tgetArgumentCompletions: () => [],\n\t\thandler: async (_args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tif (cachedMode !== \"plan\") {\n\t\t\t\tctx.ui.notify(`/approve is only available in plan mode (current mode: \"${cachedMode}\")`, \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Prefer the per-session plan file, fall back to the legacy single file.\n\t\t\tconst sessionPlanPath = cachedPlanPath ?? getPlanPath(ctx.cwd, ctx.sessionManager.getSessionId());\n\t\t\tconst candidatePaths = [sessionPlanPath, getLegacyPlanPath(ctx.cwd)];\n\t\t\tlet approveMessage: string | undefined;\n\n\t\t\tfor (const planPath of candidatePaths) {\n\t\t\t\tif (!existsSync(planPath)) continue;\n\t\t\t\ttry {\n\t\t\t\t\tconst raw = readFileSync(planPath, \"utf8\").trim();\n\t\t\t\t\tif (raw) {\n\t\t\t\t\t\tconst sections = parsePlanSections(raw);\n\t\t\t\t\t\tapproveMessage = buildApproveMessage(sections);\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\tctx.ui.notify(`Could not read ${relative(ctx.cwd, planPath) || planPath}`, \"error\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Switch global config to build mode\n\t\t\tconst config = readConfig();\n\t\t\tconfig.active_mode = \"build\";\n\t\t\twriteConfig(config);\n\n\t\t\tif (approveMessage) {\n\t\t\t\t// Open a new build-mode session and deliver the parsed plan as the\n\t\t\t\t// first user message so the agent starts executing immediately\n\t\t\t\tawait ctx.newSession({\n\t\t\t\t\twithSession: async (replacedCtx) => {\n\t\t\t\t\t\tawait replacedCtx.sendUserMessage(approveMessage!, { deliverAs: \"followUp\" });\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tconst relPlan = relative(ctx.cwd, sessionPlanPath) || sessionPlanPath;\n\t\t\t\tctx.ui.notify(`Switched to build mode. No ${relPlan} found — describe what to build.`, \"info\");\n\t\t\t\tawait ctx.reload();\n\t\t\t}\n\t\t},\n\t});\n}\n"]}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission gate — prompts before bash/write/edit/webfetch/websearch; checks
|
|
3
|
+
* modes.{mode}.auto_allow from the merged (global + project) config; persists
|
|
4
|
+
* "always" choices back to the global config. Hard enforcement (denied tools,
|
|
5
|
+
* enabled_tools allowlists, bash command patterns, .webtoolsignore hosts)
|
|
6
|
+
* applies even without a UI.
|
|
7
|
+
*/
|
|
8
|
+
import type { ExtensionAPI } from "../../core/extensions/types.js";
|
|
9
|
+
export declare function setupPermissionGate(pi: ExtensionAPI): void;
|
|
10
|
+
//# sourceMappingURL=permission-gate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"permission-gate.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/permission-gate.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EACX,YAAY,EAIZ,MAAM,gCAAgC,CAAC;AA4DxC,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAwH1D","sourcesContent":["/**\n * Permission gate — prompts before bash/write/edit/webfetch/websearch; checks\n * modes.{mode}.auto_allow from the merged (global + project) config; persists\n * \"always\" choices back to the global config. Hard enforcement (denied tools,\n * enabled_tools allowlists, bash command patterns, .webtoolsignore hosts)\n * applies even without a UI.\n */\n\nimport type {\n\tExtensionAPI,\n\tExtensionContext,\n\tToolCallEvent,\n\tToolCallEventResult,\n} from \"../../core/extensions/types.js\";\nimport { isToolCallEventType } from \"../../core/extensions/types.js\";\nimport { blockedHostForUrl } from \"../../core/tools/webtools-shared.js\";\nimport { readConfig, readMergedConfig, writeConfig } from \"./config.js\";\n\nconst GATED_TOOLS = new Set([\"bash\", \"write\", \"edit\", \"webfetch\", \"websearch\"]);\n\n/**\n * Checks if a file path matches any of the allowed patterns.\n * Supports glob patterns with * and exact paths.\n */\nfunction matchesAllowedPath(filePath: string, allowedPatterns: string[]): boolean {\n\tif (allowedPatterns.length === 0) return true;\n\tfor (const pattern of allowedPatterns) {\n\t\t// Exact match\n\t\tif (pattern === filePath) return true;\n\t\t// Glob pattern matching for *\n\t\tif (pattern.includes(\"*\")) {\n\t\t\tconst regex = new RegExp(`^${pattern.replace(/\\*/g, \".*\")}$`);\n\t\t\tif (regex.test(filePath)) return true;\n\t\t}\n\t}\n\treturn false;\n}\n\n/**\n * Tests a bash command string against a regex pattern string.\n * Returns false (no match) if the pattern is an invalid regex.\n */\nfunction matchesBashPattern(pattern: string, command: string): boolean {\n\ttry {\n\t\treturn new RegExp(pattern).test(command);\n\t} catch {\n\t\treturn false;\n\t}\n}\n\nfunction describeTool(event: ToolCallEvent): string {\n\tif (isToolCallEventType(\"bash\", event)) {\n\t\treturn `$ ${event.input.command.replace(/\\s+/g, \" \").slice(0, 100)}`;\n\t}\n\tif (isToolCallEventType(\"edit\", event)) {\n\t\tconst p = (event.input as { file_path?: string }).file_path ?? \"(unknown)\";\n\t\treturn `edit ${p}`;\n\t}\n\tif (isToolCallEventType(\"write\", event)) {\n\t\tconst p = (event.input as { file_path?: string }).file_path ?? \"(unknown)\";\n\t\treturn `write ${p}`;\n\t}\n\tif (event.toolName === \"webfetch\") {\n\t\tconst url = (event.input as { url?: string }).url ?? \"(unknown)\";\n\t\treturn `webfetch ${url}`;\n\t}\n\tif (event.toolName === \"websearch\") {\n\t\tconst query = (event.input as { query?: string }).query ?? \"(unknown)\";\n\t\treturn `websearch \"${query}\"`;\n\t}\n\treturn event.toolName;\n}\n\nexport function setupPermissionGate(pi: ExtensionAPI): void {\n\tpi.on(\"tool_call\", async (event: ToolCallEvent, ctx: ExtensionContext): Promise<ToolCallEventResult | undefined> => {\n\t\t// Use the merged config so project-local entries are respected\n\t\tconst config = readMergedConfig(ctx.cwd);\n\t\tconst mode = config.active_mode ?? \"build\";\n\t\tconst modeCfg = config.modes?.[mode];\n\n\t\t// ── Hard enforcement (always applies, regardless of UI) ───────────────────\n\n\t\t// Explicitly denied tools are blocked unconditionally\n\t\tif (modeCfg?.denied_tools?.includes(event.toolName)) {\n\t\t\treturn {\n\t\t\t\tblock: true,\n\t\t\t\treason: `Tool \"${event.toolName}\" is denied in mode \"${mode}\".`,\n\t\t\t};\n\t\t}\n\n\t\t// enabled_tools acts as a strict allowlist: only listed tools may execute\n\t\tif (\n\t\t\tmodeCfg?.enabled_tools &&\n\t\t\tmodeCfg.enabled_tools.length > 0 &&\n\t\t\t!modeCfg.enabled_tools.includes(event.toolName)\n\t\t) {\n\t\t\treturn {\n\t\t\t\tblock: true,\n\t\t\t\treason:\n\t\t\t\t\t`Tool \"${event.toolName}\" is not enabled in mode \"${mode}\" ` +\n\t\t\t\t\t`(enabled: ${modeCfg.enabled_tools.join(\", \")}).`,\n\t\t\t};\n\t\t}\n\n\t\t// Bash command-level filtering\n\t\tif (isToolCallEventType(\"bash\", event)) {\n\t\t\tconst command = (event.input as { command?: string }).command ?? \"\";\n\n\t\t\t// denied_bash_commands: block if any pattern matches\n\t\t\tif (modeCfg?.denied_bash_commands?.length) {\n\t\t\t\tfor (const pattern of modeCfg.denied_bash_commands) {\n\t\t\t\t\tif (matchesBashPattern(pattern, command)) {\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tblock: true,\n\t\t\t\t\t\t\treason: `Bash command matches a denied pattern in mode \"${mode}\": ${pattern}`,\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// allowed_bash_commands: block unless at least one pattern matches\n\t\t\tif (modeCfg?.allowed_bash_commands?.length) {\n\t\t\t\tconst permitted = modeCfg.allowed_bash_commands.some((p) => matchesBashPattern(p, command));\n\t\t\t\tif (!permitted) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tblock: true,\n\t\t\t\t\t\treason:\n\t\t\t\t\t\t\t`Bash command is not permitted in mode \"${mode}\". ` +\n\t\t\t\t\t\t\t`Allowed patterns: ${modeCfg.allowed_bash_commands.join(\", \")}`,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// webfetch host policy (.webtoolsignore). Hard enforcement, always applies:\n\t\t// a blocked host is denied even in headless runs. SSRF/private-address\n\t\t// blocking lives in the webtools binary; this is host allow/deny policy only.\n\t\tif (event.toolName === \"webfetch\") {\n\t\t\tconst url = (event.input as { url?: string }).url ?? \"\";\n\t\t\tconst blockedHost = url ? blockedHostForUrl(ctx.cwd, url) : undefined;\n\t\t\tif (blockedHost) {\n\t\t\t\treturn {\n\t\t\t\t\tblock: true,\n\t\t\t\t\treason: `Host \"${blockedHost}\" is blocked by .webtoolsignore policy.`,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// ── UI-based permission prompting (interactive sessions only) ─────────────\n\n\t\tif (!GATED_TOOLS.has(event.toolName) || !ctx.hasUI) return;\n\n\t\tconst autoAllow = modeCfg?.auto_allow ?? [];\n\n\t\t// Check allowed_write_paths for write/edit operations\n\t\tif ((event.toolName === \"write\" || event.toolName === \"edit\") && modeCfg?.allowed_write_paths) {\n\t\t\tconst filePath = (event.input as { file_path?: string }).file_path ?? \"\";\n\t\t\tif (!matchesAllowedPath(filePath, modeCfg.allowed_write_paths)) {\n\t\t\t\treturn {\n\t\t\t\t\tblock: true,\n\t\t\t\t\treason:\n\t\t\t\t\t\t`Mode \"${mode}\" only allows writes to: ${modeCfg.allowed_write_paths.join(\", \")}. ` +\n\t\t\t\t\t\t`Attempted to ${event.toolName}: ${filePath}. ` +\n\t\t\t\t\t\t`Switch to \"/mode build\" to modify source files.`,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\tif (autoAllow.includes(event.toolName)) return;\n\n\t\tconst choice = await ctx.ui.select(`Allow: ${describeTool(event)}`, [\n\t\t\t\"Yes (once)\",\n\t\t\t\"No (block)\",\n\t\t\t\"Always (add to auto-allow for this mode)\",\n\t\t]);\n\n\t\tif (!choice || choice.startsWith(\"No\")) {\n\t\t\treturn { block: true, reason: \"Denied by permission gate\" };\n\t\t}\n\n\t\tif (choice.startsWith(\"Always\")) {\n\t\t\t// Write \"always\" choices to the global config only\n\t\t\tconst latest = readConfig();\n\t\t\tconst currentMode = latest.active_mode ?? \"build\";\n\t\t\tlatest.modes ??= {};\n\t\t\tlatest.modes[currentMode] ??= {};\n\t\t\tlatest.modes[currentMode].auto_allow = Array.from(\n\t\t\t\tnew Set([...(latest.modes[currentMode].auto_allow ?? []), event.toolName]),\n\t\t\t);\n\t\t\twriteConfig(latest);\n\t\t\tctx.ui.notify(`\"${event.toolName}\" added to auto-allow for mode \"${currentMode}\"`, \"info\");\n\t\t}\n\t});\n}\n"]}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission gate — prompts before bash/write/edit/webfetch/websearch; checks
|
|
3
|
+
* modes.{mode}.auto_allow from the merged (global + project) config; persists
|
|
4
|
+
* "always" choices back to the global config. Hard enforcement (denied tools,
|
|
5
|
+
* enabled_tools allowlists, bash command patterns, .webtoolsignore hosts)
|
|
6
|
+
* applies even without a UI.
|
|
7
|
+
*/
|
|
8
|
+
import { isToolCallEventType } from "../../core/extensions/types.js";
|
|
9
|
+
import { blockedHostForUrl } from "../../core/tools/webtools-shared.js";
|
|
10
|
+
import { readConfig, readMergedConfig, writeConfig } from "./config.js";
|
|
11
|
+
const GATED_TOOLS = new Set(["bash", "write", "edit", "webfetch", "websearch"]);
|
|
12
|
+
/**
|
|
13
|
+
* Checks if a file path matches any of the allowed patterns.
|
|
14
|
+
* Supports glob patterns with * and exact paths.
|
|
15
|
+
*/
|
|
16
|
+
function matchesAllowedPath(filePath, allowedPatterns) {
|
|
17
|
+
if (allowedPatterns.length === 0)
|
|
18
|
+
return true;
|
|
19
|
+
for (const pattern of allowedPatterns) {
|
|
20
|
+
// Exact match
|
|
21
|
+
if (pattern === filePath)
|
|
22
|
+
return true;
|
|
23
|
+
// Glob pattern matching for *
|
|
24
|
+
if (pattern.includes("*")) {
|
|
25
|
+
const regex = new RegExp(`^${pattern.replace(/\*/g, ".*")}$`);
|
|
26
|
+
if (regex.test(filePath))
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Tests a bash command string against a regex pattern string.
|
|
34
|
+
* Returns false (no match) if the pattern is an invalid regex.
|
|
35
|
+
*/
|
|
36
|
+
function matchesBashPattern(pattern, command) {
|
|
37
|
+
try {
|
|
38
|
+
return new RegExp(pattern).test(command);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function describeTool(event) {
|
|
45
|
+
if (isToolCallEventType("bash", event)) {
|
|
46
|
+
return `$ ${event.input.command.replace(/\s+/g, " ").slice(0, 100)}`;
|
|
47
|
+
}
|
|
48
|
+
if (isToolCallEventType("edit", event)) {
|
|
49
|
+
const p = event.input.file_path ?? "(unknown)";
|
|
50
|
+
return `edit ${p}`;
|
|
51
|
+
}
|
|
52
|
+
if (isToolCallEventType("write", event)) {
|
|
53
|
+
const p = event.input.file_path ?? "(unknown)";
|
|
54
|
+
return `write ${p}`;
|
|
55
|
+
}
|
|
56
|
+
if (event.toolName === "webfetch") {
|
|
57
|
+
const url = event.input.url ?? "(unknown)";
|
|
58
|
+
return `webfetch ${url}`;
|
|
59
|
+
}
|
|
60
|
+
if (event.toolName === "websearch") {
|
|
61
|
+
const query = event.input.query ?? "(unknown)";
|
|
62
|
+
return `websearch "${query}"`;
|
|
63
|
+
}
|
|
64
|
+
return event.toolName;
|
|
65
|
+
}
|
|
66
|
+
export function setupPermissionGate(pi) {
|
|
67
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
68
|
+
// Use the merged config so project-local entries are respected
|
|
69
|
+
const config = readMergedConfig(ctx.cwd);
|
|
70
|
+
const mode = config.active_mode ?? "build";
|
|
71
|
+
const modeCfg = config.modes?.[mode];
|
|
72
|
+
// ── Hard enforcement (always applies, regardless of UI) ───────────────────
|
|
73
|
+
// Explicitly denied tools are blocked unconditionally
|
|
74
|
+
if (modeCfg?.denied_tools?.includes(event.toolName)) {
|
|
75
|
+
return {
|
|
76
|
+
block: true,
|
|
77
|
+
reason: `Tool "${event.toolName}" is denied in mode "${mode}".`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// enabled_tools acts as a strict allowlist: only listed tools may execute
|
|
81
|
+
if (modeCfg?.enabled_tools &&
|
|
82
|
+
modeCfg.enabled_tools.length > 0 &&
|
|
83
|
+
!modeCfg.enabled_tools.includes(event.toolName)) {
|
|
84
|
+
return {
|
|
85
|
+
block: true,
|
|
86
|
+
reason: `Tool "${event.toolName}" is not enabled in mode "${mode}" ` +
|
|
87
|
+
`(enabled: ${modeCfg.enabled_tools.join(", ")}).`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// Bash command-level filtering
|
|
91
|
+
if (isToolCallEventType("bash", event)) {
|
|
92
|
+
const command = event.input.command ?? "";
|
|
93
|
+
// denied_bash_commands: block if any pattern matches
|
|
94
|
+
if (modeCfg?.denied_bash_commands?.length) {
|
|
95
|
+
for (const pattern of modeCfg.denied_bash_commands) {
|
|
96
|
+
if (matchesBashPattern(pattern, command)) {
|
|
97
|
+
return {
|
|
98
|
+
block: true,
|
|
99
|
+
reason: `Bash command matches a denied pattern in mode "${mode}": ${pattern}`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// allowed_bash_commands: block unless at least one pattern matches
|
|
105
|
+
if (modeCfg?.allowed_bash_commands?.length) {
|
|
106
|
+
const permitted = modeCfg.allowed_bash_commands.some((p) => matchesBashPattern(p, command));
|
|
107
|
+
if (!permitted) {
|
|
108
|
+
return {
|
|
109
|
+
block: true,
|
|
110
|
+
reason: `Bash command is not permitted in mode "${mode}". ` +
|
|
111
|
+
`Allowed patterns: ${modeCfg.allowed_bash_commands.join(", ")}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// webfetch host policy (.webtoolsignore). Hard enforcement, always applies:
|
|
117
|
+
// a blocked host is denied even in headless runs. SSRF/private-address
|
|
118
|
+
// blocking lives in the webtools binary; this is host allow/deny policy only.
|
|
119
|
+
if (event.toolName === "webfetch") {
|
|
120
|
+
const url = event.input.url ?? "";
|
|
121
|
+
const blockedHost = url ? blockedHostForUrl(ctx.cwd, url) : undefined;
|
|
122
|
+
if (blockedHost) {
|
|
123
|
+
return {
|
|
124
|
+
block: true,
|
|
125
|
+
reason: `Host "${blockedHost}" is blocked by .webtoolsignore policy.`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// ── UI-based permission prompting (interactive sessions only) ─────────────
|
|
130
|
+
if (!GATED_TOOLS.has(event.toolName) || !ctx.hasUI)
|
|
131
|
+
return;
|
|
132
|
+
const autoAllow = modeCfg?.auto_allow ?? [];
|
|
133
|
+
// Check allowed_write_paths for write/edit operations
|
|
134
|
+
if ((event.toolName === "write" || event.toolName === "edit") && modeCfg?.allowed_write_paths) {
|
|
135
|
+
const filePath = event.input.file_path ?? "";
|
|
136
|
+
if (!matchesAllowedPath(filePath, modeCfg.allowed_write_paths)) {
|
|
137
|
+
return {
|
|
138
|
+
block: true,
|
|
139
|
+
reason: `Mode "${mode}" only allows writes to: ${modeCfg.allowed_write_paths.join(", ")}. ` +
|
|
140
|
+
`Attempted to ${event.toolName}: ${filePath}. ` +
|
|
141
|
+
`Switch to "/mode build" to modify source files.`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (autoAllow.includes(event.toolName))
|
|
146
|
+
return;
|
|
147
|
+
const choice = await ctx.ui.select(`Allow: ${describeTool(event)}`, [
|
|
148
|
+
"Yes (once)",
|
|
149
|
+
"No (block)",
|
|
150
|
+
"Always (add to auto-allow for this mode)",
|
|
151
|
+
]);
|
|
152
|
+
if (!choice || choice.startsWith("No")) {
|
|
153
|
+
return { block: true, reason: "Denied by permission gate" };
|
|
154
|
+
}
|
|
155
|
+
if (choice.startsWith("Always")) {
|
|
156
|
+
// Write "always" choices to the global config only
|
|
157
|
+
const latest = readConfig();
|
|
158
|
+
const currentMode = latest.active_mode ?? "build";
|
|
159
|
+
latest.modes ??= {};
|
|
160
|
+
latest.modes[currentMode] ??= {};
|
|
161
|
+
latest.modes[currentMode].auto_allow = Array.from(new Set([...(latest.modes[currentMode].auto_allow ?? []), event.toolName]));
|
|
162
|
+
writeConfig(latest);
|
|
163
|
+
ctx.ui.notify(`"${event.toolName}" added to auto-allow for mode "${currentMode}"`, "info");
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
//# sourceMappingURL=permission-gate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"permission-gate.js","sourceRoot":"","sources":["../../../src/extensions/core/permission-gate.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Permission gate — prompts before bash/write/edit/webfetch/websearch; checks\n * modes.{mode}.auto_allow from the merged (global + project) config; persists\n * \"always\" choices back to the global config. Hard enforcement (denied tools,\n * enabled_tools allowlists, bash command patterns, .webtoolsignore hosts)\n * applies even without a UI.\n */\n\nimport type {\n\tExtensionAPI,\n\tExtensionContext,\n\tToolCallEvent,\n\tToolCallEventResult,\n} from \"../../core/extensions/types.js\";\nimport { isToolCallEventType } from \"../../core/extensions/types.js\";\nimport { blockedHostForUrl } from \"../../core/tools/webtools-shared.js\";\nimport { readConfig, readMergedConfig, writeConfig } from \"./config.js\";\n\nconst GATED_TOOLS = new Set([\"bash\", \"write\", \"edit\", \"webfetch\", \"websearch\"]);\n\n/**\n * Checks if a file path matches any of the allowed patterns.\n * Supports glob patterns with * and exact paths.\n */\nfunction matchesAllowedPath(filePath: string, allowedPatterns: string[]): boolean {\n\tif (allowedPatterns.length === 0) return true;\n\tfor (const pattern of allowedPatterns) {\n\t\t// Exact match\n\t\tif (pattern === filePath) return true;\n\t\t// Glob pattern matching for *\n\t\tif (pattern.includes(\"*\")) {\n\t\t\tconst regex = new RegExp(`^${pattern.replace(/\\*/g, \".*\")}$`);\n\t\t\tif (regex.test(filePath)) return true;\n\t\t}\n\t}\n\treturn false;\n}\n\n/**\n * Tests a bash command string against a regex pattern string.\n * Returns false (no match) if the pattern is an invalid regex.\n */\nfunction matchesBashPattern(pattern: string, command: string): boolean {\n\ttry {\n\t\treturn new RegExp(pattern).test(command);\n\t} catch {\n\t\treturn false;\n\t}\n}\n\nfunction describeTool(event: ToolCallEvent): string {\n\tif (isToolCallEventType(\"bash\", event)) {\n\t\treturn `$ ${event.input.command.replace(/\\s+/g, \" \").slice(0, 100)}`;\n\t}\n\tif (isToolCallEventType(\"edit\", event)) {\n\t\tconst p = (event.input as { file_path?: string }).file_path ?? \"(unknown)\";\n\t\treturn `edit ${p}`;\n\t}\n\tif (isToolCallEventType(\"write\", event)) {\n\t\tconst p = (event.input as { file_path?: string }).file_path ?? \"(unknown)\";\n\t\treturn `write ${p}`;\n\t}\n\tif (event.toolName === \"webfetch\") {\n\t\tconst url = (event.input as { url?: string }).url ?? \"(unknown)\";\n\t\treturn `webfetch ${url}`;\n\t}\n\tif (event.toolName === \"websearch\") {\n\t\tconst query = (event.input as { query?: string }).query ?? \"(unknown)\";\n\t\treturn `websearch \"${query}\"`;\n\t}\n\treturn event.toolName;\n}\n\nexport function setupPermissionGate(pi: ExtensionAPI): void {\n\tpi.on(\"tool_call\", async (event: ToolCallEvent, ctx: ExtensionContext): Promise<ToolCallEventResult | undefined> => {\n\t\t// Use the merged config so project-local entries are respected\n\t\tconst config = readMergedConfig(ctx.cwd);\n\t\tconst mode = config.active_mode ?? \"build\";\n\t\tconst modeCfg = config.modes?.[mode];\n\n\t\t// ── Hard enforcement (always applies, regardless of UI) ───────────────────\n\n\t\t// Explicitly denied tools are blocked unconditionally\n\t\tif (modeCfg?.denied_tools?.includes(event.toolName)) {\n\t\t\treturn {\n\t\t\t\tblock: true,\n\t\t\t\treason: `Tool \"${event.toolName}\" is denied in mode \"${mode}\".`,\n\t\t\t};\n\t\t}\n\n\t\t// enabled_tools acts as a strict allowlist: only listed tools may execute\n\t\tif (\n\t\t\tmodeCfg?.enabled_tools &&\n\t\t\tmodeCfg.enabled_tools.length > 0 &&\n\t\t\t!modeCfg.enabled_tools.includes(event.toolName)\n\t\t) {\n\t\t\treturn {\n\t\t\t\tblock: true,\n\t\t\t\treason:\n\t\t\t\t\t`Tool \"${event.toolName}\" is not enabled in mode \"${mode}\" ` +\n\t\t\t\t\t`(enabled: ${modeCfg.enabled_tools.join(\", \")}).`,\n\t\t\t};\n\t\t}\n\n\t\t// Bash command-level filtering\n\t\tif (isToolCallEventType(\"bash\", event)) {\n\t\t\tconst command = (event.input as { command?: string }).command ?? \"\";\n\n\t\t\t// denied_bash_commands: block if any pattern matches\n\t\t\tif (modeCfg?.denied_bash_commands?.length) {\n\t\t\t\tfor (const pattern of modeCfg.denied_bash_commands) {\n\t\t\t\t\tif (matchesBashPattern(pattern, command)) {\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tblock: true,\n\t\t\t\t\t\t\treason: `Bash command matches a denied pattern in mode \"${mode}\": ${pattern}`,\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// allowed_bash_commands: block unless at least one pattern matches\n\t\t\tif (modeCfg?.allowed_bash_commands?.length) {\n\t\t\t\tconst permitted = modeCfg.allowed_bash_commands.some((p) => matchesBashPattern(p, command));\n\t\t\t\tif (!permitted) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tblock: true,\n\t\t\t\t\t\treason:\n\t\t\t\t\t\t\t`Bash command is not permitted in mode \"${mode}\". ` +\n\t\t\t\t\t\t\t`Allowed patterns: ${modeCfg.allowed_bash_commands.join(\", \")}`,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// webfetch host policy (.webtoolsignore). Hard enforcement, always applies:\n\t\t// a blocked host is denied even in headless runs. SSRF/private-address\n\t\t// blocking lives in the webtools binary; this is host allow/deny policy only.\n\t\tif (event.toolName === \"webfetch\") {\n\t\t\tconst url = (event.input as { url?: string }).url ?? \"\";\n\t\t\tconst blockedHost = url ? blockedHostForUrl(ctx.cwd, url) : undefined;\n\t\t\tif (blockedHost) {\n\t\t\t\treturn {\n\t\t\t\t\tblock: true,\n\t\t\t\t\treason: `Host \"${blockedHost}\" is blocked by .webtoolsignore policy.`,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// ── UI-based permission prompting (interactive sessions only) ─────────────\n\n\t\tif (!GATED_TOOLS.has(event.toolName) || !ctx.hasUI) return;\n\n\t\tconst autoAllow = modeCfg?.auto_allow ?? [];\n\n\t\t// Check allowed_write_paths for write/edit operations\n\t\tif ((event.toolName === \"write\" || event.toolName === \"edit\") && modeCfg?.allowed_write_paths) {\n\t\t\tconst filePath = (event.input as { file_path?: string }).file_path ?? \"\";\n\t\t\tif (!matchesAllowedPath(filePath, modeCfg.allowed_write_paths)) {\n\t\t\t\treturn {\n\t\t\t\t\tblock: true,\n\t\t\t\t\treason:\n\t\t\t\t\t\t`Mode \"${mode}\" only allows writes to: ${modeCfg.allowed_write_paths.join(\", \")}. ` +\n\t\t\t\t\t\t`Attempted to ${event.toolName}: ${filePath}. ` +\n\t\t\t\t\t\t`Switch to \"/mode build\" to modify source files.`,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\tif (autoAllow.includes(event.toolName)) return;\n\n\t\tconst choice = await ctx.ui.select(`Allow: ${describeTool(event)}`, [\n\t\t\t\"Yes (once)\",\n\t\t\t\"No (block)\",\n\t\t\t\"Always (add to auto-allow for this mode)\",\n\t\t]);\n\n\t\tif (!choice || choice.startsWith(\"No\")) {\n\t\t\treturn { block: true, reason: \"Denied by permission gate\" };\n\t\t}\n\n\t\tif (choice.startsWith(\"Always\")) {\n\t\t\t// Write \"always\" choices to the global config only\n\t\t\tconst latest = readConfig();\n\t\t\tconst currentMode = latest.active_mode ?? \"build\";\n\t\t\tlatest.modes ??= {};\n\t\t\tlatest.modes[currentMode] ??= {};\n\t\t\tlatest.modes[currentMode].auto_allow = Array.from(\n\t\t\t\tnew Set([...(latest.modes[currentMode].auto_allow ?? []), event.toolName]),\n\t\t\t);\n\t\t\twriteConfig(latest);\n\t\t\tctx.ui.notify(`\"${event.toolName}\" added to auto-allow for mode \"${currentMode}\"`, \"info\");\n\t\t}\n\t});\n}\n"]}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffold commands — /new-skill, /new-agent, and /new-command.
|
|
3
|
+
*
|
|
4
|
+
* Each creates a ready-to-edit resource file under `.hoocode/` with valid
|
|
5
|
+
* frontmatter, picked up on the next /reload.
|
|
6
|
+
*/
|
|
7
|
+
import type { ExtensionAPI } from "../../core/extensions/types.js";
|
|
8
|
+
export declare function setupScaffold(pi: ExtensionAPI): void;
|
|
9
|
+
//# sourceMappingURL=scaffold.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scaffold.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/scaffold.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,KAAK,EAAE,YAAY,EAA2B,MAAM,gCAAgC,CAAC;AAW5F,wBAAgB,aAAa,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAkKpD","sourcesContent":["/**\n * Scaffold commands — /new-skill, /new-agent, and /new-command.\n *\n * Each creates a ready-to-edit resource file under `.hoocode/` with valid\n * frontmatter, picked up on the next /reload.\n */\n\nimport { existsSync, mkdirSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { ExtensionAPI, ExtensionCommandContext } from \"../../core/extensions/types.js\";\n\n/** Validates a resource name: lowercase a-z, 0-9, hyphens, no leading/trailing/double hyphens. */\nfunction validateResourceName(name: string): string | null {\n\tif (!name) return \"name is required\";\n\tif (!/^[a-z0-9-]+$/.test(name)) return \"name must be lowercase a-z, 0-9, and hyphens only\";\n\tif (name.startsWith(\"-\") || name.endsWith(\"-\")) return \"name must not start or end with a hyphen\";\n\tif (name.includes(\"--\")) return \"name must not contain consecutive hyphens\";\n\treturn null;\n}\n\nexport function setupScaffold(pi: ExtensionAPI): void {\n\t// ── /new-skill <name> ─────────────────────────────────────────────────────\n\t// Creates .hoocode/skills/<name>/SKILL.md with a valid Agent Skills frontmatter\n\t// template so the file is ready to edit and will be picked up on next reload.\n\n\tpi.registerCommand(\"new-skill\", {\n\t\tdescription: \"Scaffold a new skill. Usage: /new-skill <name>\",\n\t\tgetArgumentCompletions: () => [],\n\t\thandler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst name = args.trim();\n\t\t\tconst error = validateResourceName(name);\n\t\t\tif (error) {\n\t\t\t\tctx.ui.notify(`/new-skill: ${error}. Usage: /new-skill <name>`, \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst skillDir = join(ctx.cwd, \".hoocode\", \"skills\", name);\n\t\t\tconst skillFile = join(skillDir, \"SKILL.md\");\n\n\t\t\tif (existsSync(skillFile)) {\n\t\t\t\tctx.ui.notify(`/new-skill: ${skillFile} already exists`, \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tmkdirSync(skillDir, { recursive: true });\n\t\t\twriteFileSync(\n\t\t\t\tskillFile,\n\t\t\t\t[\n\t\t\t\t\t\"---\",\n\t\t\t\t\t`name: ${name}`,\n\t\t\t\t\t\"description: |\",\n\t\t\t\t\t\" TODO: describe when to use this skill — one clear sentence per bullet.\",\n\t\t\t\t\t\" The model reads this to decide whether to load the skill.\",\n\t\t\t\t\t\"allowed-tools: read, bash\",\n\t\t\t\t\t\"---\",\n\t\t\t\t\t\"\",\n\t\t\t\t\t`# ${name}`,\n\t\t\t\t\t\"\",\n\t\t\t\t\t\"TODO: write the skill instructions here.\",\n\t\t\t\t\t\"\",\n\t\t\t\t\t\"When relative paths appear below, they are resolved from this file's directory.\",\n\t\t\t\t\t\"\",\n\t\t\t\t].join(\"\\n\"),\n\t\t\t\t\"utf8\",\n\t\t\t);\n\n\t\t\tctx.ui.notify(\n\t\t\t\t`Skill created: ${join(\".hoocode\", \"skills\", name, \"SKILL.md\")}\\nEdit the file, then run /reload to activate it.`,\n\t\t\t\t\"info\",\n\t\t\t);\n\t\t},\n\t});\n\n\t// ── /new-agent <name> ─────────────────────────────────────────────────────\n\t// Creates .hoocode/agents/<name>.md following the Claude Code subagent standard\n\t// (name, description, tools comma-string, model alias, optional background).\n\n\tpi.registerCommand(\"new-agent\", {\n\t\tdescription: \"Scaffold a new subagent. Usage: /new-agent <name>\",\n\t\tgetArgumentCompletions: () => [],\n\t\thandler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst name = args.trim();\n\t\t\tconst error = validateResourceName(name);\n\t\t\tif (error) {\n\t\t\t\tctx.ui.notify(`/new-agent: ${error}. Usage: /new-agent <name>`, \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst agentsDir = join(ctx.cwd, \".hoocode\", \"agents\");\n\t\t\tconst agentFile = join(agentsDir, `${name}.md`);\n\n\t\t\tif (existsSync(agentFile)) {\n\t\t\t\tctx.ui.notify(`/new-agent: ${agentFile} already exists`, \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tmkdirSync(agentsDir, { recursive: true });\n\t\t\twriteFileSync(\n\t\t\t\tagentFile,\n\t\t\t\t[\n\t\t\t\t\t\"---\",\n\t\t\t\t\t`name: ${name}`,\n\t\t\t\t\t\"description: |\",\n\t\t\t\t\t\" Use this subagent ONLY when:\",\n\t\t\t\t\t\" - TODO: describe the task(s) to delegate here\",\n\t\t\t\t\t\"\",\n\t\t\t\t\t\" DO NOT use for:\",\n\t\t\t\t\t\" - TODO: describe what this agent should NOT handle\",\n\t\t\t\t\t\"tools: read, bash\",\n\t\t\t\t\t\"model: sonnet\",\n\t\t\t\t\t\"---\",\n\t\t\t\t\t`You are a ${name} subagent running inside hoocode.`,\n\t\t\t\t\t\"You run in an isolated context and cannot see the parent conversation.\",\n\t\t\t\t\t\"\",\n\t\t\t\t\t\"TODO: write the system prompt here.\",\n\t\t\t\t\t\"\",\n\t\t\t\t\t\"Your final message must contain ONLY your answer — it is the only output\",\n\t\t\t\t\t\"the caller receives. Do not include intermediate reasoning or tool logs.\",\n\t\t\t\t\t\"\",\n\t\t\t\t].join(\"\\n\"),\n\t\t\t\t\"utf8\",\n\t\t\t);\n\n\t\t\tctx.ui.notify(\n\t\t\t\t`Agent created: ${join(\".hoocode\", \"agents\", `${name}.md`)}\\nEdit the file, then run /reload to activate it.`,\n\t\t\t\t\"info\",\n\t\t\t);\n\t\t},\n\t});\n\n\t// ── /new-command <name> ───────────────────────────────────────────────────\n\t// Creates .hoocode/commands/<name>.md with a slash-command prompt-template\n\t// frontmatter (name, description, argument-hint) so it is ready to edit and\n\t// picked up on next reload. Body supports $1, $@, $ARGUMENTS placeholders.\n\n\tpi.registerCommand(\"new-command\", {\n\t\tdescription: \"Scaffold a new slash command. Usage: /new-command <name>\",\n\t\tgetArgumentCompletions: () => [],\n\t\thandler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst name = args.trim();\n\t\t\tconst error = validateResourceName(name);\n\t\t\tif (error) {\n\t\t\t\tctx.ui.notify(`/new-command: ${error}. Usage: /new-command <name>`, \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst commandsDir = join(ctx.cwd, \".hoocode\", \"commands\");\n\t\t\tconst commandFile = join(commandsDir, `${name}.md`);\n\n\t\t\tif (existsSync(commandFile)) {\n\t\t\t\tctx.ui.notify(`/new-command: ${commandFile} already exists`, \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tmkdirSync(commandsDir, { recursive: true });\n\t\t\twriteFileSync(\n\t\t\t\tcommandFile,\n\t\t\t\t[\n\t\t\t\t\t\"---\",\n\t\t\t\t\t`name: ${name}`,\n\t\t\t\t\t\"description: |\",\n\t\t\t\t\t` TODO: describe what /${name} does and when to use it.`,\n\t\t\t\t\t` Usage: /${name} <args>`,\n\t\t\t\t\t\"argument-hint: <args>\",\n\t\t\t\t\t\"---\",\n\t\t\t\t\t`Run the /${name} command with arguments: **$ARGUMENTS**.`,\n\t\t\t\t\t\"\",\n\t\t\t\t\t\"TODO: write the instructions here. Placeholders you can use:\",\n\t\t\t\t\t\"- $1, $2, ... for positional arguments\",\n\t\t\t\t\t\"- $@ or $ARGUMENTS for all arguments\",\n\t\t\t\t\t`- $${\"{\"}@:N} / $${\"{\"}@:N:L} for bash-style slices`,\n\t\t\t\t\t\"\",\n\t\t\t\t].join(\"\\n\"),\n\t\t\t\t\"utf8\",\n\t\t\t);\n\n\t\t\tctx.ui.notify(\n\t\t\t\t`Command created: ${join(\".hoocode\", \"commands\", `${name}.md`)}\\nEdit the file, then run /reload to activate it.`,\n\t\t\t\t\"info\",\n\t\t\t);\n\t\t},\n\t});\n}\n"]}
|