@kolisachint/hoocode-agent 0.5.52 → 0.5.54
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 +91 -0
- package/dist/cli/args.d.ts +2 -2
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +9 -8
- package/dist/cli/args.js.map +1 -1
- package/dist/core/agent-frontmatter.d.ts +8 -2
- package/dist/core/agent-frontmatter.d.ts.map +1 -1
- package/dist/core/agent-frontmatter.js +13 -7
- package/dist/core/agent-frontmatter.js.map +1 -1
- package/dist/core/agent-session-services.d.ts +1 -1
- package/dist/core/agent-session-services.d.ts.map +1 -1
- package/dist/core/agent-session-services.js +1 -1
- package/dist/core/agent-session-services.js.map +1 -1
- 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/embsearch/embsearch-service.d.ts +1 -1
- package/dist/core/embsearch/embsearch-service.d.ts.map +1 -1
- package/dist/core/embsearch/embsearch-service.js +1 -1
- package/dist/core/embsearch/embsearch-service.js.map +1 -1
- package/dist/core/extensions/plugins/authoring.d.ts.map +1 -1
- package/dist/core/extensions/plugins/authoring.js +1 -1
- package/dist/core/extensions/plugins/authoring.js.map +1 -1
- package/dist/core/extensions/plugins/formats/shared.d.ts +1 -1
- package/dist/core/extensions/plugins/formats/shared.d.ts.map +1 -1
- package/dist/core/extensions/plugins/formats/shared.js +1 -1
- package/dist/core/extensions/plugins/formats/shared.js.map +1 -1
- package/dist/core/extensions/plugins/formats/types.d.ts +1 -1
- package/dist/core/extensions/plugins/formats/types.d.ts.map +1 -1
- package/dist/core/extensions/plugins/formats/types.js.map +1 -1
- package/dist/core/external-tools.d.ts +1 -1
- package/dist/core/external-tools.d.ts.map +1 -1
- package/dist/core/external-tools.js +2 -2
- package/dist/core/external-tools.js.map +1 -1
- package/dist/core/sdk.d.ts +2 -2
- package/dist/core/sdk.d.ts.map +1 -1
- package/dist/core/sdk.js +3 -3
- package/dist/core/sdk.js.map +1 -1
- package/dist/core/self-docs.d.ts +1 -1
- package/dist/core/self-docs.d.ts.map +1 -1
- package/dist/core/self-docs.js +1 -1
- package/dist/core/self-docs.js.map +1 -1
- package/dist/core/settings-defaults.d.ts +1 -1
- package/dist/core/settings-defaults.d.ts.map +1 -1
- package/dist/core/settings-defaults.js +1 -1
- package/dist/core/settings-defaults.js.map +1 -1
- package/dist/core/settings-manager.d.ts +2 -2
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +13 -5
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/settings-types.d.ts +1 -1
- package/dist/core/settings-types.d.ts.map +1 -1
- package/dist/core/settings-types.js.map +1 -1
- package/dist/core/system-prompt.d.ts.map +1 -1
- package/dist/core/system-prompt.js +14 -15
- package/dist/core/system-prompt.js.map +1 -1
- package/dist/core/tools/index.d.ts +2 -2
- package/dist/core/tools/index.d.ts.map +1 -1
- package/dist/core/tools/index.js +3 -3
- package/dist/core/tools/index.js.map +1 -1
- package/dist/core/tools/plugins.d.ts.map +1 -1
- package/dist/core/tools/plugins.js +2 -2
- package/dist/core/tools/plugins.js.map +1 -1
- package/dist/core/tools/propose-plugin.d.ts.map +1 -1
- package/dist/core/tools/propose-plugin.js +1 -1
- package/dist/core/tools/propose-plugin.js.map +1 -1
- package/dist/core/tools/search.d.ts +1 -1
- package/dist/core/tools/search.d.ts.map +1 -1
- package/dist/core/tools/search.js +5 -5
- package/dist/core/tools/search.js.map +1 -1
- package/dist/core/tools/todo.d.ts.map +1 -1
- package/dist/core/tools/todo.js +9 -7
- package/dist/core/tools/todo.js.map +1 -1
- package/dist/extensions/core/ask-options.d.ts.map +1 -1
- package/dist/extensions/core/ask-options.js +1 -0
- package/dist/extensions/core/ask-options.js.map +1 -1
- package/dist/extensions/core/modes.d.ts.map +1 -1
- package/dist/extensions/core/modes.js +9 -0
- package/dist/extensions/core/modes.js.map +1 -1
- package/dist/extensions/core/permission-gate.d.ts.map +1 -1
- package/dist/extensions/core/permission-gate.js +40 -10
- package/dist/extensions/core/permission-gate.js.map +1 -1
- package/dist/init-templates.generated.d.ts.map +1 -1
- package/dist/init-templates.generated.js +14 -14
- package/dist/init-templates.generated.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +7 -7
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/components/tool-chain-summary.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-chain-summary.js +1 -1
- package/dist/modes/interactive/components/tool-chain-summary.js.map +1 -1
- package/dist/modes/interactive/components/tool-signal.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-signal.js +7 -7
- package/dist/modes/interactive/components/tool-signal.js.map +1 -1
- package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/tree-selector.js +2 -2
- package/dist/modes/interactive/components/tree-selector.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +2 -2
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/docs/extensions.md +1 -1
- package/docs/modes.md +33 -7
- package/docs/quickstart.md +1 -1
- package/docs/sdk.md +2 -2
- package/docs/settings.md +3 -3
- package/docs/usage.md +4 -4
- package/examples/agents/fork-reviewer.md +1 -1
- package/examples/agents/orchestrator.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/plan-mode/index.ts +1 -1
- package/examples/extensions/preset.ts +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/truncated-tool.ts +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/examples/sdk/05-tools.ts +3 -3
- package/package.json +4 -4
- package/templates/agents/code-review.md +1 -1
- package/templates/agents/explore.md +2 -2
- package/templates/agents/general-purpose.md +2 -2
- package/templates/agents/plan.md +2 -2
- package/templates/agents/security-review.md +1 -1
- package/templates/default-config.json +6 -3
- package/templates/modes/ask/system.md +4 -7
- package/templates/modes/build/system.md +6 -8
- package/templates/modes/debug/system.md +7 -8
- package/templates/modes/plan/system.md +7 -13
- package/templates/prompts/task-main.md +5 -12
- package/templates/skills/artifact-design/SKILL.md +1 -1
- package/templates/skills/canvas-design/SKILL.md +1 -1
- package/templates/skills/plugin-authoring/SKILL.md +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The unified `
|
|
2
|
+
* The unified `SearchCodebase` tool: ranked code discovery in lexical, semantic, or
|
|
3
3
|
* hybrid (rank-fused) mode. Replaces the old `semantic_search` tool — see
|
|
4
4
|
* docs/hybrid-retrieval-design.md, Decision 1. It is the only dedicated
|
|
5
5
|
* discovery tool: this tool answers "find where X lives", and exact
|
|
@@ -31,7 +31,7 @@ const searchSchema = Type.Object({
|
|
|
31
31
|
function formatSearchCall(args, theme) {
|
|
32
32
|
const query = str(args?.query);
|
|
33
33
|
const queryDisplay = query === null ? invalidArgText(theme) : `"${query}"`;
|
|
34
|
-
let text = theme.fg("toolTitle", theme.bold("
|
|
34
|
+
let text = theme.fg("toolTitle", theme.bold("SearchCodebase ")) + theme.fg("accent", queryDisplay);
|
|
35
35
|
const extras = [];
|
|
36
36
|
if (args?.mode && args.mode !== "auto")
|
|
37
37
|
extras.push(args.mode);
|
|
@@ -58,8 +58,8 @@ function formatSearchResult(result, options, theme, showImages) {
|
|
|
58
58
|
}
|
|
59
59
|
export function createSearchToolDefinition(cwd, options) {
|
|
60
60
|
return {
|
|
61
|
-
name: "
|
|
62
|
-
label: "
|
|
61
|
+
name: "SearchCodebase",
|
|
62
|
+
label: "SearchCodebase",
|
|
63
63
|
// The search-vs-bash split is stated once, by buildSystemPrompt, whenever both
|
|
64
64
|
// are registered — so it is deliberately absent here. What stays is what only
|
|
65
65
|
// this tool knows: that the query is not a regex, and that it degrades to
|
|
@@ -67,7 +67,7 @@ export function createSearchToolDefinition(cwd, options) {
|
|
|
67
67
|
description: "Find where code lives: ranked file:line-range results, fusing keyword and semantic retrieval over a local index with exact-text search of files the index has not read yet. The query is plain text, not a regex — regex metacharacters are matched literally. Falls back to exact-text retrieval automatically when the index is unavailable, and still finds code written moments ago that no index has seen.",
|
|
68
68
|
promptSnippet: "Ranked code search (keyword + semantic, rank-fused)",
|
|
69
69
|
promptGuidelines: [
|
|
70
|
-
"
|
|
70
|
+
"SearchCodebase defaults to mode=auto, which is almost always right. Use limit=3 for targeted lookups, 10–20 when exploring a broad topic — past ~15 results the deeper ones arrive as ranked file:line-range headers without a snippet, which is still enough to choose what to read.",
|
|
71
71
|
],
|
|
72
72
|
parameters: searchSchema,
|
|
73
73
|
async execute(_toolCallId, { query, mode, glob, limit }, signal) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search.js","sourceRoot":"","sources":["../../../src/core/tools/search.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The unified `search` tool: ranked code discovery in lexical, semantic, or\n * hybrid (rank-fused) mode. Replaces the old `semantic_search` tool — see\n * docs/hybrid-retrieval-design.md, Decision 1. It is the only dedicated\n * discovery tool: this tool answers \"find where X lives\", and exact\n * line-level mechanics are a shell job (rg/find/ls via bash).\n */\n\nimport { Text } from \"@kolisachint/hoocode-tui\";\nimport { type Static, Type } from \"typebox\";\nimport { keyHint } from \"../../modes/interactive/components/keybinding-hints.js\";\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport type { ToolDefinition, ToolRenderResultOptions } from \"../extensions/types.js\";\nimport { runSearch } from \"../search/hybrid-search.js\";\nimport type { ResolvedSearchMode } from \"../search/types.js\";\nimport { getTextOutput, invalidArgText, str } from \"./render-utils.js\";\n\n// Default of 5 balances recall against context cost. Measured on the 62-query\n// eval: limit=5 costs ~1000 tokens and puts a gold span in the results 68% of\n// the time; limit=10 costs ~1600 tokens for 74%. The extra 6 points are real\n// but not worth 60% more context on every call, and the model can ask for more.\nconst DEFAULT_RESULTS = 5;\nconst MAX_RESULTS = 30;\n\nconst searchSchema = Type.Object({\n\tquery: Type.String({\n\t\tdescription:\n\t\t\t\"What to find: an identifier, error text, or a natural-language description of the code, e.g. 'where sessions are persisted to disk'\",\n\t}),\n\tmode: Type.Optional(\n\t\tType.Union([Type.Literal(\"auto\"), Type.Literal(\"lexical\"), Type.Literal(\"semantic\"), Type.Literal(\"hybrid\")], {\n\t\t\tdescription:\n\t\t\t\t\"Retrieval mode (default: auto, which is almost always right). auto = hybrid when the index is available, else lexical; hybrid = keyword and meaning over the index plus exact text for anything indexed later; semantic = index only, skipping exact text; lexical = exact text only, the one mode that works with no index.\",\n\t\t}),\n\t),\n\tglob: Type.Optional(\n\t\tType.String({\n\t\t\tdescription:\n\t\t\t\t\"Optional glob filter applied to file paths. Only file paths matching the glob are searched. Supports both slashless patterns (match base name anywhere) and slash patterns (match full path).\",\n\t\t}),\n\t),\n\tlimit: Type.Optional(\n\t\tType.Number({ description: `Maximum number of results (default: ${DEFAULT_RESULTS}, max: ${MAX_RESULTS})` }),\n\t),\n});\n\ntype SearchToolInput = Static<typeof searchSchema>;\n\nexport interface SearchToolDetails {\n\tresultCount?: number;\n\tresolvedMode?: ResolvedSearchMode;\n\tindexing?: { done: number; total: number };\n}\n\nexport interface SearchToolOptions {\n\t/**\n\t * Provider for the per-session embsearch service. Resolved lazily at call\n\t * time because the service is created (and its index built) after tool\n\t * registration. When absent/unavailable the tool degrades to lexical mode.\n\t */\n\tgetService?: () => EmbsearchService | undefined;\n}\n\nfunction formatSearchCall(\n\targs: { query?: string; mode?: string; limit?: number } | undefined,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n): string {\n\tconst query = str(args?.query);\n\tconst queryDisplay = query === null ? invalidArgText(theme) : `\"${query}\"`;\n\tlet text = theme.fg(\"toolTitle\", theme.bold(\"search \")) + theme.fg(\"accent\", queryDisplay);\n\tconst extras: string[] = [];\n\tif (args?.mode && args.mode !== \"auto\") extras.push(args.mode);\n\tif (args?.limit !== undefined) extras.push(String(args.limit));\n\tif (extras.length > 0) text += theme.fg(\"muted\", ` (${extras.join(\", \")})`);\n\treturn text;\n}\n\nfunction formatSearchResult(\n\tresult: { content: Array<{ type: string; text?: string }>; details?: SearchToolDetails },\n\toptions: ToolRenderResultOptions,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n\tshowImages: boolean,\n): string {\n\tconst output = getTextOutput(result as any, showImages).trim();\n\tlet text = \"\";\n\tif (output) {\n\t\tconst lines = output.split(\"\\n\");\n\t\tconst maxLines = options.expanded ? lines.length : 15;\n\t\tconst displayLines = lines.slice(0, maxLines);\n\t\tconst remaining = lines.length - maxLines;\n\t\ttext += `\\n${displayLines.map((line) => theme.fg(\"toolOutput\", line)).join(\"\\n\")}`;\n\t\tif (remaining > 0) {\n\t\t\ttext += `${theme.fg(\"muted\", `\\n... (${remaining} more lines,`)} ${keyHint(\"app.tools.expand\", \"to expand\")})`;\n\t\t}\n\t}\n\treturn text;\n}\n\nexport function createSearchToolDefinition(\n\tcwd: string,\n\toptions?: SearchToolOptions,\n): ToolDefinition<typeof searchSchema, SearchToolDetails | undefined> {\n\treturn {\n\t\tname: \"search\",\n\t\tlabel: \"search\",\n\t\t// The search-vs-bash split is stated once, by buildSystemPrompt, whenever both\n\t\t// are registered — so it is deliberately absent here. What stays is what only\n\t\t// this tool knows: that the query is not a regex, and that it degrades to\n\t\t// exact-text when the index is missing.\n\t\tdescription:\n\t\t\t\"Find where code lives: ranked file:line-range results, fusing keyword and semantic retrieval over a local index with exact-text search of files the index has not read yet. The query is plain text, not a regex — regex metacharacters are matched literally. Falls back to exact-text retrieval automatically when the index is unavailable, and still finds code written moments ago that no index has seen.\",\n\t\tpromptSnippet: \"Ranked code search (keyword + semantic, rank-fused)\",\n\t\tpromptGuidelines: [\n\t\t\t\"search defaults to mode=auto, which is almost always right. Use limit=3 for targeted lookups, 10–20 when exploring a broad topic — past ~15 results the deeper ones arrive as ranked file:line-range headers without a snippet, which is still enough to choose what to read.\",\n\t\t],\n\t\tparameters: searchSchema,\n\t\tasync execute(_toolCallId, { query, mode, glob, limit }: SearchToolInput, signal?: AbortSignal) {\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\tconst result = await runSearch({\n\t\t\t\tcwd,\n\t\t\t\tquery,\n\t\t\t\tmode,\n\t\t\t\tglob,\n\t\t\t\tlimit: Math.min(MAX_RESULTS, Math.max(1, limit ?? DEFAULT_RESULTS)),\n\t\t\t\tservice: options?.getService?.(),\n\t\t\t\tsignal,\n\t\t\t});\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\tconst details: SearchToolDetails = {\n\t\t\t\tresultCount: result.resultCount,\n\t\t\t\tresolvedMode: result.resolvedMode,\n\t\t\t\tindexing: result.indexing,\n\t\t\t};\n\n\t\t\tconst notices: string[] = [];\n\t\t\tif (result.degradedReason) notices.push(result.degradedReason);\n\t\t\tif (result.indexing) {\n\t\t\t\tnotices.push(\n\t\t\t\t\t`index still building: ${result.indexing.done}/${result.indexing.total} chunks embedded — results may be incomplete`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst notice = notices.length > 0 ? `\\n\\n[${notices.join(\". \")}]` : \"\";\n\n\t\t\tif (result.resultCount === 0) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{ type: \"text\" as const, text: `No results for \"${query}\" (${result.resolvedMode})${notice}` },\n\t\t\t\t\t],\n\t\t\t\t\tdetails,\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text: result.text + notice }],\n\t\t\t\tdetails,\n\t\t\t};\n\t\t},\n\t\trenderCall(args, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchCall(args, theme));\n\t\t\treturn text;\n\t\t},\n\t\trenderResult(result, options, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchResult(result as any, options, theme, context.showImages));\n\t\t\treturn text;\n\t\t},\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"search.js","sourceRoot":"","sources":["../../../src/core/tools/search.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The unified `SearchCodebase` tool: ranked code discovery in lexical, semantic, or\n * hybrid (rank-fused) mode. Replaces the old `semantic_search` tool — see\n * docs/hybrid-retrieval-design.md, Decision 1. It is the only dedicated\n * discovery tool: this tool answers \"find where X lives\", and exact\n * line-level mechanics are a shell job (rg/find/ls via bash).\n */\n\nimport { Text } from \"@kolisachint/hoocode-tui\";\nimport { type Static, Type } from \"typebox\";\nimport { keyHint } from \"../../modes/interactive/components/keybinding-hints.js\";\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport type { ToolDefinition, ToolRenderResultOptions } from \"../extensions/types.js\";\nimport { runSearch } from \"../search/hybrid-search.js\";\nimport type { ResolvedSearchMode } from \"../search/types.js\";\nimport { getTextOutput, invalidArgText, str } from \"./render-utils.js\";\n\n// Default of 5 balances recall against context cost. Measured on the 62-query\n// eval: limit=5 costs ~1000 tokens and puts a gold span in the results 68% of\n// the time; limit=10 costs ~1600 tokens for 74%. The extra 6 points are real\n// but not worth 60% more context on every call, and the model can ask for more.\nconst DEFAULT_RESULTS = 5;\nconst MAX_RESULTS = 30;\n\nconst searchSchema = Type.Object({\n\tquery: Type.String({\n\t\tdescription:\n\t\t\t\"What to find: an identifier, error text, or a natural-language description of the code, e.g. 'where sessions are persisted to disk'\",\n\t}),\n\tmode: Type.Optional(\n\t\tType.Union([Type.Literal(\"auto\"), Type.Literal(\"lexical\"), Type.Literal(\"semantic\"), Type.Literal(\"hybrid\")], {\n\t\t\tdescription:\n\t\t\t\t\"Retrieval mode (default: auto, which is almost always right). auto = hybrid when the index is available, else lexical; hybrid = keyword and meaning over the index plus exact text for anything indexed later; semantic = index only, skipping exact text; lexical = exact text only, the one mode that works with no index.\",\n\t\t}),\n\t),\n\tglob: Type.Optional(\n\t\tType.String({\n\t\t\tdescription:\n\t\t\t\t\"Optional glob filter applied to file paths. Only file paths matching the glob are searched. Supports both slashless patterns (match base name anywhere) and slash patterns (match full path).\",\n\t\t}),\n\t),\n\tlimit: Type.Optional(\n\t\tType.Number({ description: `Maximum number of results (default: ${DEFAULT_RESULTS}, max: ${MAX_RESULTS})` }),\n\t),\n});\n\ntype SearchToolInput = Static<typeof searchSchema>;\n\nexport interface SearchToolDetails {\n\tresultCount?: number;\n\tresolvedMode?: ResolvedSearchMode;\n\tindexing?: { done: number; total: number };\n}\n\nexport interface SearchToolOptions {\n\t/**\n\t * Provider for the per-session embsearch service. Resolved lazily at call\n\t * time because the service is created (and its index built) after tool\n\t * registration. When absent/unavailable the tool degrades to lexical mode.\n\t */\n\tgetService?: () => EmbsearchService | undefined;\n}\n\nfunction formatSearchCall(\n\targs: { query?: string; mode?: string; limit?: number } | undefined,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n): string {\n\tconst query = str(args?.query);\n\tconst queryDisplay = query === null ? invalidArgText(theme) : `\"${query}\"`;\n\tlet text = theme.fg(\"toolTitle\", theme.bold(\"SearchCodebase \")) + theme.fg(\"accent\", queryDisplay);\n\tconst extras: string[] = [];\n\tif (args?.mode && args.mode !== \"auto\") extras.push(args.mode);\n\tif (args?.limit !== undefined) extras.push(String(args.limit));\n\tif (extras.length > 0) text += theme.fg(\"muted\", ` (${extras.join(\", \")})`);\n\treturn text;\n}\n\nfunction formatSearchResult(\n\tresult: { content: Array<{ type: string; text?: string }>; details?: SearchToolDetails },\n\toptions: ToolRenderResultOptions,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n\tshowImages: boolean,\n): string {\n\tconst output = getTextOutput(result as any, showImages).trim();\n\tlet text = \"\";\n\tif (output) {\n\t\tconst lines = output.split(\"\\n\");\n\t\tconst maxLines = options.expanded ? lines.length : 15;\n\t\tconst displayLines = lines.slice(0, maxLines);\n\t\tconst remaining = lines.length - maxLines;\n\t\ttext += `\\n${displayLines.map((line) => theme.fg(\"toolOutput\", line)).join(\"\\n\")}`;\n\t\tif (remaining > 0) {\n\t\t\ttext += `${theme.fg(\"muted\", `\\n... (${remaining} more lines,`)} ${keyHint(\"app.tools.expand\", \"to expand\")})`;\n\t\t}\n\t}\n\treturn text;\n}\n\nexport function createSearchToolDefinition(\n\tcwd: string,\n\toptions?: SearchToolOptions,\n): ToolDefinition<typeof searchSchema, SearchToolDetails | undefined> {\n\treturn {\n\t\tname: \"SearchCodebase\",\n\t\tlabel: \"SearchCodebase\",\n\t\t// The search-vs-bash split is stated once, by buildSystemPrompt, whenever both\n\t\t// are registered — so it is deliberately absent here. What stays is what only\n\t\t// this tool knows: that the query is not a regex, and that it degrades to\n\t\t// exact-text when the index is missing.\n\t\tdescription:\n\t\t\t\"Find where code lives: ranked file:line-range results, fusing keyword and semantic retrieval over a local index with exact-text search of files the index has not read yet. The query is plain text, not a regex — regex metacharacters are matched literally. Falls back to exact-text retrieval automatically when the index is unavailable, and still finds code written moments ago that no index has seen.\",\n\t\tpromptSnippet: \"Ranked code search (keyword + semantic, rank-fused)\",\n\t\tpromptGuidelines: [\n\t\t\t\"SearchCodebase defaults to mode=auto, which is almost always right. Use limit=3 for targeted lookups, 10–20 when exploring a broad topic — past ~15 results the deeper ones arrive as ranked file:line-range headers without a snippet, which is still enough to choose what to read.\",\n\t\t],\n\t\tparameters: searchSchema,\n\t\tasync execute(_toolCallId, { query, mode, glob, limit }: SearchToolInput, signal?: AbortSignal) {\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\tconst result = await runSearch({\n\t\t\t\tcwd,\n\t\t\t\tquery,\n\t\t\t\tmode,\n\t\t\t\tglob,\n\t\t\t\tlimit: Math.min(MAX_RESULTS, Math.max(1, limit ?? DEFAULT_RESULTS)),\n\t\t\t\tservice: options?.getService?.(),\n\t\t\t\tsignal,\n\t\t\t});\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\tconst details: SearchToolDetails = {\n\t\t\t\tresultCount: result.resultCount,\n\t\t\t\tresolvedMode: result.resolvedMode,\n\t\t\t\tindexing: result.indexing,\n\t\t\t};\n\n\t\t\tconst notices: string[] = [];\n\t\t\tif (result.degradedReason) notices.push(result.degradedReason);\n\t\t\tif (result.indexing) {\n\t\t\t\tnotices.push(\n\t\t\t\t\t`index still building: ${result.indexing.done}/${result.indexing.total} chunks embedded — results may be incomplete`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst notice = notices.length > 0 ? `\\n\\n[${notices.join(\". \")}]` : \"\";\n\n\t\t\tif (result.resultCount === 0) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{ type: \"text\" as const, text: `No results for \"${query}\" (${result.resolvedMode})${notice}` },\n\t\t\t\t\t],\n\t\t\t\t\tdetails,\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text: result.text + notice }],\n\t\t\t\tdetails,\n\t\t\t};\n\t\t},\n\t\trenderCall(args, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchCall(args, theme));\n\t\t\treturn text;\n\t\t},\n\t\trenderResult(result, options, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchResult(result as any, options, theme, context.showImages));\n\t\t\treturn text;\n\t\t},\n\t};\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"todo.d.ts","sourceRoot":"","sources":["../../../src/core/tools/todo.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAC;AACzE,OAAO,EAAa,KAAK,UAAU,EAAa,MAAM,kBAAkB,CAAC;AAmCzE,MAAM,WAAW,gBAAgB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CAClB;AA2CD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,WAAW,CAAC,GAAG,MAAM,CAWlG;AAED,qFAAqF;AACrF,wBAAgB,6BAA6B,IAAI,cAAc,CAuG9D","sourcesContent":["/**\n * TodoWrite tool: let the main agent maintain a visible todo list for the\n * current task.\n *\n * hoocode already has all the infrastructure this needs — the task store models\n * `{ title, status }` items with a per-turn lifecycle, and the TUI task panel\n * renders them. The only missing piece was a tool the model can call; this is\n * that thin adapter over `taskStore`.\n *\n * Semantics mirror Claude Code's TodoWrite: each call sends the FULL list and\n * REPLACES the previous one. Because the store is incremental (numeric ids), we\n * reconcile the incoming list against the existing main-agent tasks by position:\n * update items that are still there, create new ones, and drop the tail that was\n * removed. Reconciling (rather than clear-and-recreate) keeps ids stable so the\n * panel does not flicker and in-progress rows stay put.\n *\n * It is an optional, opt-in tool (enabled via the `enableTodoWrite` setting) and\n * is never registered inside a spawned subagent, so a subagent's todos cannot\n * leak into the parent's \"main\" task group.\n */\n\nimport { type Static, Type } from \"typebox\";\nimport { TODO_WRITE_TOOL_NAME } from \"../agent-frontmatter.js\";\nimport { defineTool, type ToolDefinition } from \"../extensions/types.js\";\nimport { type Task, type TaskStatus, taskStore } from \"../task-store.js\";\n\nconst todoStatusSchema = Type.Union([Type.Literal(\"pending\"), Type.Literal(\"in_progress\"), Type.Literal(\"completed\")], {\n\tdescription: \"pending = not started, in_progress = actively being worked on, completed = finished.\",\n});\n\nconst todoItemSchema = Type.Object(\n\t{\n\t\tcontent: Type.String({\n\t\t\tdescription: \"The task, in imperative form (e.g. 'Add tests for the parser').\",\n\t\t}),\n\t\tstatus: todoStatusSchema,\n\t\tactiveForm: Type.Optional(\n\t\t\tType.String({\n\t\t\t\tdescription:\n\t\t\t\t\t\"Optional present-tense form shown while the item is in_progress (e.g. 'Adding tests for the parser').\",\n\t\t\t}),\n\t\t),\n\t},\n\t{ additionalProperties: false },\n);\n\nconst todoWriteParams = Type.Object(\n\t{\n\t\ttodos: Type.Array(todoItemSchema, {\n\t\t\tdescription:\n\t\t\t\t\"The complete todo list. This REPLACES the previous list on every call, so always send every item with its current status — omitting an item removes it.\",\n\t\t}),\n\t},\n\t{ additionalProperties: false },\n);\n\ntype TodoWriteParams = Static<typeof todoWriteParams>;\ntype IncomingStatus = TodoWriteParams[\"todos\"][number][\"status\"];\n\nexport interface TodoWriteDetails {\n\ttotal: number;\n\tpending: number;\n\tinProgress: number;\n\tcompleted: number;\n}\n\n/** Map the model-facing status vocabulary onto the task store's. */\nfunction toTaskStatus(status: IncomingStatus): TaskStatus {\n\treturn status === \"completed\" ? \"done\" : status;\n}\n\nconst STATUS_GLYPH: Record<TaskStatus, string> = {\n\tpending: \"[ ]\",\n\tin_progress: \"[~]\",\n\tdone: \"[x]\",\n\tfailed: \"[!]\",\n\t// TodoWrite never produces cancelled items; present for Record exhaustiveness.\n\tcancelled: \"[-]\",\n};\n\n/** Title to display: the active-form while in progress, otherwise the content. */\nfunction displayTitle(item: TodoWriteParams[\"todos\"][number]): string {\n\tif (item.status === \"in_progress\" && item.activeForm?.trim()) return item.activeForm.trim();\n\treturn item.content.trim();\n}\n\n/**\n * A root task the main agent itself owns, i.e. a TodoWrite plan item: no\n * `source` (excludes \"subagent\"/MCP rows), no `agent` (excludes delegated rows),\n * and no `parentTaskId` (excludes merged child trees). `taskOwnerId()` would\n * fold MCP-sourced and delegated rows under \"main\", so reconciling against it\n * could overwrite or drop those rows when the TodoWrite list is shorter than the\n * combined count.\n */\nfunction isMainPlanTask(task: Task): boolean {\n\treturn task.source === undefined && task.agent === undefined && task.parentTaskId === undefined;\n}\n\n/** Current main-agent plan tasks, in stable creation order. */\nfunction mainTasks(): Task[] {\n\treturn taskStore.list().filter(isMainPlanTask);\n}\n\nfunction isActive(task: Task): boolean {\n\treturn task.status === \"pending\" || task.status === \"in_progress\";\n}\n\n/**\n * Settle plan items the model left pinned at `in_progress` when a request ends.\n *\n * TodoWrite is bookkeeping the model performs by hand, and even strong models\n * routinely drop the final call that flips the last item to completed. Nothing\n * else writes main-plan rows, so without this the panel would keep claiming the\n * work is in flight until the next user message triggers `taskStore.reset()`.\n * The request is over, so the row is wrong either way — settle it to the honest\n * outcome instead of leaving it lying.\n *\n * Scope is deliberately narrow:\n * - Only `in_progress` main-plan items. `pending` rows are left alone: \"never\n * started\" is already an accurate reading of an item the model skipped.\n * - Only main-plan items. Subagent- and MCP-sourced rows settle through their\n * own lifecycles (subagent.ts, mcp-loader.ts) and must not be second-guessed\n * here.\n * - Nothing settles while any delegated task is still pending/in_progress: a\n * subagent outliving the parent's agent_end is still working the plan, so its\n * plan item is genuinely in progress.\n *\n * Returns the number of tasks settled.\n */\nexport function settleDanglingMainTasks(outcome: Extract<TaskStatus, \"done\" | \"cancelled\">): number {\n\tconst all = taskStore.list();\n\tif (all.some((t) => !isMainPlanTask(t) && isActive(t))) return 0;\n\tconst dangling = all.filter((t) => isMainPlanTask(t) && t.status === \"in_progress\");\n\tif (dangling.length === 0) return 0;\n\ttaskStore.batch(() => {\n\t\tfor (const task of dangling) {\n\t\t\ttaskStore.update(task.id, { status: outcome });\n\t\t}\n\t});\n\treturn dangling.length;\n}\n\n/** Create the TodoWrite tool definition. Registered as a customTool when enabled. */\nexport function createTodoWriteToolDefinition(): ToolDefinition {\n\treturn defineTool<typeof todoWriteParams, TodoWriteDetails>({\n\t\tname: TODO_WRITE_TOOL_NAME,\n\t\tlabel: TODO_WRITE_TOOL_NAME,\n\t\tdescription: [\n\t\t\t\"Maintain a structured todo list for the current task, shown live in the task panel.\",\n\t\t\t\"Use it PROACTIVELY: at the start of any multi-step or non-trivial task, write the full plan as todos before you begin, then keep it current as you work.\",\n\t\t\t\"Mark exactly ONE item in_progress at a time, and flip an item to completed immediately after finishing it — do not batch completions or leave finished work marked in_progress.\",\n\t\t\t\"Each call sends the FULL list and REPLACES the previous one — always include every item with its current status; omitting an item removes it.\",\n\t\t\t\"Skip it only for trivial, single-step tasks where a list adds no value. When in doubt on multi-step work, use it — it keeps you from losing track of steps.\",\n\t\t].join(\"\\n\"),\n\t\tpromptSnippet:\n\t\t\t\"Plan and track multi-step work as a live todo list (use proactively; replaces the whole list each call)\",\n\t\tpromptGuidelines: [\n\t\t\t\"Use TodoWrite proactively for any multi-step or non-trivial task: write the plan as todos up front, keep exactly one item in_progress, and mark items completed immediately as you finish them.\",\n\t\t\t\"TodoWrite replaces the entire list each call — always send all items with their current status.\",\n\t\t\t\"Skip TodoWrite for trivial single-step tasks where a checklist adds no value.\",\n\t\t],\n\t\tparameters: todoWriteParams,\n\t\tasync execute(_toolCallId, params: TodoWriteParams) {\n\t\t\tconst todos = params.todos ?? [];\n\t\t\tconst existing = mainTasks();\n\n\t\t\t// Reconcile by item identity first, position second, batched so the panel\n\t\t\t// renders once. Each task stores its item's canonical `content`\n\t\t\t// (todoContent) — the display title flips between content and activeForm\n\t\t\t// with status, so it can't identify an item. Matching by content keeps a\n\t\t\t// task's id pinned to the same plan item when the list is reordered or\n\t\t\t// shrunk; a purely positional reconcile re-labeled the surviving slots,\n\t\t\t// which silently re-pointed the subagent runs linked to those ids\n\t\t\t// (linkedTaskId) at the wrong plan items. Unmatched incoming items then\n\t\t\t// consume the leftover slots in order (a rename keeps its id and its\n\t\t\t// linked runs); any remaining leftovers were removed from the plan.\n\t\t\ttaskStore.batch(() => {\n\t\t\t\tconst content = (item: TodoWriteParams[\"todos\"][number]) => item.content.trim();\n\t\t\t\tconst matchedExisting = new Set<number>();\n\t\t\t\tconst assigned = new Array<Task | undefined>(todos.length);\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tconst idx = existing.findIndex(\n\t\t\t\t\t\t(t, j) => !matchedExisting.has(j) && (t.todoContent ?? t.title) === content(todos[i]!),\n\t\t\t\t\t);\n\t\t\t\t\tif (idx !== -1) {\n\t\t\t\t\t\tmatchedExisting.add(idx);\n\t\t\t\t\t\tassigned[i] = existing[idx];\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst leftovers = existing.filter((_, j) => !matchedExisting.has(j));\n\t\t\t\tlet nextLeftover = 0;\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tif (!assigned[i]) assigned[i] = leftovers[nextLeftover++];\n\t\t\t\t}\n\n\t\t\t\tconst finalIds: number[] = [];\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tconst item = todos[i]!;\n\t\t\t\t\tconst status = toTaskStatus(item.status);\n\t\t\t\t\tconst title = displayTitle(item);\n\t\t\t\t\tconst current = assigned[i];\n\t\t\t\t\tif (current) {\n\t\t\t\t\t\ttaskStore.update(current.id, { title, status, todoContent: content(item) });\n\t\t\t\t\t\tfinalIds.push(current.id);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst created = taskStore.create(title);\n\t\t\t\t\t\ttaskStore.update(created.id, { status, todoContent: content(item) });\n\t\t\t\t\t\tfinalIds.push(created.id);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tfor (let j = nextLeftover; j < leftovers.length; j++) {\n\t\t\t\t\ttaskStore.remove(leftovers[j]!.id);\n\t\t\t\t}\n\t\t\t\t// Identity matching keeps ids, but the panel must still show the plan\n\t\t\t\t// in the list's order — permute the plan tasks into it.\n\t\t\t\ttaskStore.arrange(finalIds);\n\t\t\t});\n\n\t\t\tconst counts = todos.reduce(\n\t\t\t\t(acc, t) => {\n\t\t\t\t\tif (t.status === \"in_progress\") acc.inProgress++;\n\t\t\t\t\telse if (t.status === \"completed\") acc.completed++;\n\t\t\t\t\telse acc.pending++;\n\t\t\t\t\treturn acc;\n\t\t\t\t},\n\t\t\t\t{ pending: 0, inProgress: 0, completed: 0 },\n\t\t\t);\n\n\t\t\tconst lines = todos.map((t) => `${STATUS_GLYPH[toTaskStatus(t.status)]} ${displayTitle(t)}`);\n\t\t\tconst header =\n\t\t\t\ttodos.length === 0\n\t\t\t\t\t? \"Todo list cleared.\"\n\t\t\t\t\t: `Todos updated (${counts.inProgress} in progress, ${counts.pending} pending, ${counts.completed} completed):`;\n\t\t\tconst text = todos.length === 0 ? header : `${header}\\n${lines.join(\"\\n\")}`;\n\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text }],\n\t\t\t\tdetails: {\n\t\t\t\t\ttotal: todos.length,\n\t\t\t\t\tpending: counts.pending,\n\t\t\t\t\tinProgress: counts.inProgress,\n\t\t\t\t\tcompleted: counts.completed,\n\t\t\t\t},\n\t\t\t};\n\t\t},\n\t});\n}\n"]}
|
|
1
|
+
{"version":3,"file":"todo.d.ts","sourceRoot":"","sources":["../../../src/core/tools/todo.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAC;AACzE,OAAO,EAAa,KAAK,UAAU,EAAa,MAAM,kBAAkB,CAAC;AAmCzE,MAAM,WAAW,gBAAgB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CAClB;AA2CD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,WAAW,CAAC,GAAG,MAAM,CAWlG;AAED,qFAAqF;AACrF,wBAAgB,6BAA6B,IAAI,cAAc,CAyG9D","sourcesContent":["/**\n * TodoWrite tool: let the main agent maintain a visible todo list for the\n * current task.\n *\n * hoocode already has all the infrastructure this needs — the task store models\n * `{ title, status }` items with a per-turn lifecycle, and the TUI task panel\n * renders them. The only missing piece was a tool the model can call; this is\n * that thin adapter over `taskStore`.\n *\n * Semantics mirror Claude Code's TodoWrite: each call sends the FULL list and\n * REPLACES the previous one. Because the store is incremental (numeric ids), we\n * reconcile the incoming list against the existing main-agent tasks by position:\n * update items that are still there, create new ones, and drop the tail that was\n * removed. Reconciling (rather than clear-and-recreate) keeps ids stable so the\n * panel does not flicker and in-progress rows stay put.\n *\n * It is an optional, opt-in tool (enabled via the `enableTodoWrite` setting) and\n * is never registered inside a spawned subagent, so a subagent's todos cannot\n * leak into the parent's \"main\" task group.\n */\n\nimport { type Static, Type } from \"typebox\";\nimport { TODO_WRITE_TOOL_NAME } from \"../agent-frontmatter.js\";\nimport { defineTool, type ToolDefinition } from \"../extensions/types.js\";\nimport { type Task, type TaskStatus, taskStore } from \"../task-store.js\";\n\nconst todoStatusSchema = Type.Union([Type.Literal(\"pending\"), Type.Literal(\"in_progress\"), Type.Literal(\"completed\")], {\n\tdescription: \"pending = not started, in_progress = actively being worked on, completed = finished.\",\n});\n\nconst todoItemSchema = Type.Object(\n\t{\n\t\tcontent: Type.String({\n\t\t\tdescription: \"The task, in imperative form (e.g. 'Add tests for the parser').\",\n\t\t}),\n\t\tstatus: todoStatusSchema,\n\t\tactiveForm: Type.Optional(\n\t\t\tType.String({\n\t\t\t\tdescription:\n\t\t\t\t\t\"Optional present-tense form shown while the item is in_progress (e.g. 'Adding tests for the parser').\",\n\t\t\t}),\n\t\t),\n\t},\n\t{ additionalProperties: false },\n);\n\nconst todoWriteParams = Type.Object(\n\t{\n\t\ttodos: Type.Array(todoItemSchema, {\n\t\t\tdescription:\n\t\t\t\t\"The complete todo list. This REPLACES the previous list on every call, so always send every item with its current status — omitting an item removes it.\",\n\t\t}),\n\t},\n\t{ additionalProperties: false },\n);\n\ntype TodoWriteParams = Static<typeof todoWriteParams>;\ntype IncomingStatus = TodoWriteParams[\"todos\"][number][\"status\"];\n\nexport interface TodoWriteDetails {\n\ttotal: number;\n\tpending: number;\n\tinProgress: number;\n\tcompleted: number;\n}\n\n/** Map the model-facing status vocabulary onto the task store's. */\nfunction toTaskStatus(status: IncomingStatus): TaskStatus {\n\treturn status === \"completed\" ? \"done\" : status;\n}\n\nconst STATUS_GLYPH: Record<TaskStatus, string> = {\n\tpending: \"[ ]\",\n\tin_progress: \"[~]\",\n\tdone: \"[x]\",\n\tfailed: \"[!]\",\n\t// TodoWrite never produces cancelled items; present for Record exhaustiveness.\n\tcancelled: \"[-]\",\n};\n\n/** Title to display: the active-form while in progress, otherwise the content. */\nfunction displayTitle(item: TodoWriteParams[\"todos\"][number]): string {\n\tif (item.status === \"in_progress\" && item.activeForm?.trim()) return item.activeForm.trim();\n\treturn item.content.trim();\n}\n\n/**\n * A root task the main agent itself owns, i.e. a TodoWrite plan item: no\n * `source` (excludes \"subagent\"/MCP rows), no `agent` (excludes delegated rows),\n * and no `parentTaskId` (excludes merged child trees). `taskOwnerId()` would\n * fold MCP-sourced and delegated rows under \"main\", so reconciling against it\n * could overwrite or drop those rows when the TodoWrite list is shorter than the\n * combined count.\n */\nfunction isMainPlanTask(task: Task): boolean {\n\treturn task.source === undefined && task.agent === undefined && task.parentTaskId === undefined;\n}\n\n/** Current main-agent plan tasks, in stable creation order. */\nfunction mainTasks(): Task[] {\n\treturn taskStore.list().filter(isMainPlanTask);\n}\n\nfunction isActive(task: Task): boolean {\n\treturn task.status === \"pending\" || task.status === \"in_progress\";\n}\n\n/**\n * Settle plan items the model left pinned at `in_progress` when a request ends.\n *\n * TodoWrite is bookkeeping the model performs by hand, and even strong models\n * routinely drop the final call that flips the last item to completed. Nothing\n * else writes main-plan rows, so without this the panel would keep claiming the\n * work is in flight until the next user message triggers `taskStore.reset()`.\n * The request is over, so the row is wrong either way — settle it to the honest\n * outcome instead of leaving it lying.\n *\n * Scope is deliberately narrow:\n * - Only `in_progress` main-plan items. `pending` rows are left alone: \"never\n * started\" is already an accurate reading of an item the model skipped.\n * - Only main-plan items. Subagent- and MCP-sourced rows settle through their\n * own lifecycles (subagent.ts, mcp-loader.ts) and must not be second-guessed\n * here.\n * - Nothing settles while any delegated task is still pending/in_progress: a\n * subagent outliving the parent's agent_end is still working the plan, so its\n * plan item is genuinely in progress.\n *\n * Returns the number of tasks settled.\n */\nexport function settleDanglingMainTasks(outcome: Extract<TaskStatus, \"done\" | \"cancelled\">): number {\n\tconst all = taskStore.list();\n\tif (all.some((t) => !isMainPlanTask(t) && isActive(t))) return 0;\n\tconst dangling = all.filter((t) => isMainPlanTask(t) && t.status === \"in_progress\");\n\tif (dangling.length === 0) return 0;\n\ttaskStore.batch(() => {\n\t\tfor (const task of dangling) {\n\t\t\ttaskStore.update(task.id, { status: outcome });\n\t\t}\n\t});\n\treturn dangling.length;\n}\n\n/** Create the TodoWrite tool definition. Registered as a customTool when enabled. */\nexport function createTodoWriteToolDefinition(): ToolDefinition {\n\treturn defineTool<typeof todoWriteParams, TodoWriteDetails>({\n\t\tname: TODO_WRITE_TOOL_NAME,\n\t\tlabel: TODO_WRITE_TOOL_NAME,\n\t\tdescription: [\n\t\t\t\"Maintain a structured todo list for the current task, shown live in the task panel.\",\n\t\t\t\"Write the full plan as todos before starting multi-step or non-trivial work, and keep it current; skip only trivial single-step tasks.\",\n\t\t\t\"Mark exactly ONE item in_progress at a time, and flip an item to completed immediately after finishing it.\",\n\t\t\t\"Each call sends the FULL list and REPLACES the previous one — include every item with its current status; omitting an item removes it.\",\n\t\t].join(\"\\n\"),\n\t\tpromptSnippet:\n\t\t\t\"Plan and track multi-step work as a live todo list (use proactively; replaces the whole list each call)\",\n\t\t// Only the \"reach for it at all\" cue lives here — that is the system-prompt's\n\t\t// job. The calling contract (one in_progress, full-list replacement, when to\n\t\t// skip) is already stated in `description` and in the `todos` schema, both of\n\t\t// which ship on every turn alongside this. Same rule three times spent the\n\t\t// tokens three times.\n\t\tpromptGuidelines: [\n\t\t\t\"Use TodoWrite proactively for multi-step or non-trivial work; skip trivial single-step tasks.\",\n\t\t],\n\t\tparameters: todoWriteParams,\n\t\tasync execute(_toolCallId, params: TodoWriteParams) {\n\t\t\tconst todos = params.todos ?? [];\n\t\t\tconst existing = mainTasks();\n\n\t\t\t// Reconcile by item identity first, position second, batched so the panel\n\t\t\t// renders once. Each task stores its item's canonical `content`\n\t\t\t// (todoContent) — the display title flips between content and activeForm\n\t\t\t// with status, so it can't identify an item. Matching by content keeps a\n\t\t\t// task's id pinned to the same plan item when the list is reordered or\n\t\t\t// shrunk; a purely positional reconcile re-labeled the surviving slots,\n\t\t\t// which silently re-pointed the subagent runs linked to those ids\n\t\t\t// (linkedTaskId) at the wrong plan items. Unmatched incoming items then\n\t\t\t// consume the leftover slots in order (a rename keeps its id and its\n\t\t\t// linked runs); any remaining leftovers were removed from the plan.\n\t\t\ttaskStore.batch(() => {\n\t\t\t\tconst content = (item: TodoWriteParams[\"todos\"][number]) => item.content.trim();\n\t\t\t\tconst matchedExisting = new Set<number>();\n\t\t\t\tconst assigned = new Array<Task | undefined>(todos.length);\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tconst idx = existing.findIndex(\n\t\t\t\t\t\t(t, j) => !matchedExisting.has(j) && (t.todoContent ?? t.title) === content(todos[i]!),\n\t\t\t\t\t);\n\t\t\t\t\tif (idx !== -1) {\n\t\t\t\t\t\tmatchedExisting.add(idx);\n\t\t\t\t\t\tassigned[i] = existing[idx];\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst leftovers = existing.filter((_, j) => !matchedExisting.has(j));\n\t\t\t\tlet nextLeftover = 0;\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tif (!assigned[i]) assigned[i] = leftovers[nextLeftover++];\n\t\t\t\t}\n\n\t\t\t\tconst finalIds: number[] = [];\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tconst item = todos[i]!;\n\t\t\t\t\tconst status = toTaskStatus(item.status);\n\t\t\t\t\tconst title = displayTitle(item);\n\t\t\t\t\tconst current = assigned[i];\n\t\t\t\t\tif (current) {\n\t\t\t\t\t\ttaskStore.update(current.id, { title, status, todoContent: content(item) });\n\t\t\t\t\t\tfinalIds.push(current.id);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst created = taskStore.create(title);\n\t\t\t\t\t\ttaskStore.update(created.id, { status, todoContent: content(item) });\n\t\t\t\t\t\tfinalIds.push(created.id);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tfor (let j = nextLeftover; j < leftovers.length; j++) {\n\t\t\t\t\ttaskStore.remove(leftovers[j]!.id);\n\t\t\t\t}\n\t\t\t\t// Identity matching keeps ids, but the panel must still show the plan\n\t\t\t\t// in the list's order — permute the plan tasks into it.\n\t\t\t\ttaskStore.arrange(finalIds);\n\t\t\t});\n\n\t\t\tconst counts = todos.reduce(\n\t\t\t\t(acc, t) => {\n\t\t\t\t\tif (t.status === \"in_progress\") acc.inProgress++;\n\t\t\t\t\telse if (t.status === \"completed\") acc.completed++;\n\t\t\t\t\telse acc.pending++;\n\t\t\t\t\treturn acc;\n\t\t\t\t},\n\t\t\t\t{ pending: 0, inProgress: 0, completed: 0 },\n\t\t\t);\n\n\t\t\tconst lines = todos.map((t) => `${STATUS_GLYPH[toTaskStatus(t.status)]} ${displayTitle(t)}`);\n\t\t\tconst header =\n\t\t\t\ttodos.length === 0\n\t\t\t\t\t? \"Todo list cleared.\"\n\t\t\t\t\t: `Todos updated (${counts.inProgress} in progress, ${counts.pending} pending, ${counts.completed} completed):`;\n\t\t\tconst text = todos.length === 0 ? header : `${header}\\n${lines.join(\"\\n\")}`;\n\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text }],\n\t\t\t\tdetails: {\n\t\t\t\t\ttotal: todos.length,\n\t\t\t\t\tpending: counts.pending,\n\t\t\t\t\tinProgress: counts.inProgress,\n\t\t\t\t\tcompleted: counts.completed,\n\t\t\t\t},\n\t\t\t};\n\t\t},\n\t});\n}\n"]}
|
package/dist/core/tools/todo.js
CHANGED
|
@@ -118,16 +118,18 @@ export function createTodoWriteToolDefinition() {
|
|
|
118
118
|
label: TODO_WRITE_TOOL_NAME,
|
|
119
119
|
description: [
|
|
120
120
|
"Maintain a structured todo list for the current task, shown live in the task panel.",
|
|
121
|
-
"
|
|
122
|
-
"Mark exactly ONE item in_progress at a time, and flip an item to completed immediately after finishing it
|
|
123
|
-
"Each call sends the FULL list and REPLACES the previous one —
|
|
124
|
-
"Skip it only for trivial, single-step tasks where a list adds no value. When in doubt on multi-step work, use it — it keeps you from losing track of steps.",
|
|
121
|
+
"Write the full plan as todos before starting multi-step or non-trivial work, and keep it current; skip only trivial single-step tasks.",
|
|
122
|
+
"Mark exactly ONE item in_progress at a time, and flip an item to completed immediately after finishing it.",
|
|
123
|
+
"Each call sends the FULL list and REPLACES the previous one — include every item with its current status; omitting an item removes it.",
|
|
125
124
|
].join("\n"),
|
|
126
125
|
promptSnippet: "Plan and track multi-step work as a live todo list (use proactively; replaces the whole list each call)",
|
|
126
|
+
// Only the "reach for it at all" cue lives here — that is the system-prompt's
|
|
127
|
+
// job. The calling contract (one in_progress, full-list replacement, when to
|
|
128
|
+
// skip) is already stated in `description` and in the `todos` schema, both of
|
|
129
|
+
// which ship on every turn alongside this. Same rule three times spent the
|
|
130
|
+
// tokens three times.
|
|
127
131
|
promptGuidelines: [
|
|
128
|
-
"Use TodoWrite proactively for
|
|
129
|
-
"TodoWrite replaces the entire list each call — always send all items with their current status.",
|
|
130
|
-
"Skip TodoWrite for trivial single-step tasks where a checklist adds no value.",
|
|
132
|
+
"Use TodoWrite proactively for multi-step or non-trivial work; skip trivial single-step tasks.",
|
|
131
133
|
],
|
|
132
134
|
parameters: todoWriteParams,
|
|
133
135
|
async execute(_toolCallId, params) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"todo.js","sourceRoot":"","sources":["../../../src/core/tools/todo.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * TodoWrite tool: let the main agent maintain a visible todo list for the\n * current task.\n *\n * hoocode already has all the infrastructure this needs — the task store models\n * `{ title, status }` items with a per-turn lifecycle, and the TUI task panel\n * renders them. The only missing piece was a tool the model can call; this is\n * that thin adapter over `taskStore`.\n *\n * Semantics mirror Claude Code's TodoWrite: each call sends the FULL list and\n * REPLACES the previous one. Because the store is incremental (numeric ids), we\n * reconcile the incoming list against the existing main-agent tasks by position:\n * update items that are still there, create new ones, and drop the tail that was\n * removed. Reconciling (rather than clear-and-recreate) keeps ids stable so the\n * panel does not flicker and in-progress rows stay put.\n *\n * It is an optional, opt-in tool (enabled via the `enableTodoWrite` setting) and\n * is never registered inside a spawned subagent, so a subagent's todos cannot\n * leak into the parent's \"main\" task group.\n */\n\nimport { type Static, Type } from \"typebox\";\nimport { TODO_WRITE_TOOL_NAME } from \"../agent-frontmatter.js\";\nimport { defineTool, type ToolDefinition } from \"../extensions/types.js\";\nimport { type Task, type TaskStatus, taskStore } from \"../task-store.js\";\n\nconst todoStatusSchema = Type.Union([Type.Literal(\"pending\"), Type.Literal(\"in_progress\"), Type.Literal(\"completed\")], {\n\tdescription: \"pending = not started, in_progress = actively being worked on, completed = finished.\",\n});\n\nconst todoItemSchema = Type.Object(\n\t{\n\t\tcontent: Type.String({\n\t\t\tdescription: \"The task, in imperative form (e.g. 'Add tests for the parser').\",\n\t\t}),\n\t\tstatus: todoStatusSchema,\n\t\tactiveForm: Type.Optional(\n\t\t\tType.String({\n\t\t\t\tdescription:\n\t\t\t\t\t\"Optional present-tense form shown while the item is in_progress (e.g. 'Adding tests for the parser').\",\n\t\t\t}),\n\t\t),\n\t},\n\t{ additionalProperties: false },\n);\n\nconst todoWriteParams = Type.Object(\n\t{\n\t\ttodos: Type.Array(todoItemSchema, {\n\t\t\tdescription:\n\t\t\t\t\"The complete todo list. This REPLACES the previous list on every call, so always send every item with its current status — omitting an item removes it.\",\n\t\t}),\n\t},\n\t{ additionalProperties: false },\n);\n\ntype TodoWriteParams = Static<typeof todoWriteParams>;\ntype IncomingStatus = TodoWriteParams[\"todos\"][number][\"status\"];\n\nexport interface TodoWriteDetails {\n\ttotal: number;\n\tpending: number;\n\tinProgress: number;\n\tcompleted: number;\n}\n\n/** Map the model-facing status vocabulary onto the task store's. */\nfunction toTaskStatus(status: IncomingStatus): TaskStatus {\n\treturn status === \"completed\" ? \"done\" : status;\n}\n\nconst STATUS_GLYPH: Record<TaskStatus, string> = {\n\tpending: \"[ ]\",\n\tin_progress: \"[~]\",\n\tdone: \"[x]\",\n\tfailed: \"[!]\",\n\t// TodoWrite never produces cancelled items; present for Record exhaustiveness.\n\tcancelled: \"[-]\",\n};\n\n/** Title to display: the active-form while in progress, otherwise the content. */\nfunction displayTitle(item: TodoWriteParams[\"todos\"][number]): string {\n\tif (item.status === \"in_progress\" && item.activeForm?.trim()) return item.activeForm.trim();\n\treturn item.content.trim();\n}\n\n/**\n * A root task the main agent itself owns, i.e. a TodoWrite plan item: no\n * `source` (excludes \"subagent\"/MCP rows), no `agent` (excludes delegated rows),\n * and no `parentTaskId` (excludes merged child trees). `taskOwnerId()` would\n * fold MCP-sourced and delegated rows under \"main\", so reconciling against it\n * could overwrite or drop those rows when the TodoWrite list is shorter than the\n * combined count.\n */\nfunction isMainPlanTask(task: Task): boolean {\n\treturn task.source === undefined && task.agent === undefined && task.parentTaskId === undefined;\n}\n\n/** Current main-agent plan tasks, in stable creation order. */\nfunction mainTasks(): Task[] {\n\treturn taskStore.list().filter(isMainPlanTask);\n}\n\nfunction isActive(task: Task): boolean {\n\treturn task.status === \"pending\" || task.status === \"in_progress\";\n}\n\n/**\n * Settle plan items the model left pinned at `in_progress` when a request ends.\n *\n * TodoWrite is bookkeeping the model performs by hand, and even strong models\n * routinely drop the final call that flips the last item to completed. Nothing\n * else writes main-plan rows, so without this the panel would keep claiming the\n * work is in flight until the next user message triggers `taskStore.reset()`.\n * The request is over, so the row is wrong either way — settle it to the honest\n * outcome instead of leaving it lying.\n *\n * Scope is deliberately narrow:\n * - Only `in_progress` main-plan items. `pending` rows are left alone: \"never\n * started\" is already an accurate reading of an item the model skipped.\n * - Only main-plan items. Subagent- and MCP-sourced rows settle through their\n * own lifecycles (subagent.ts, mcp-loader.ts) and must not be second-guessed\n * here.\n * - Nothing settles while any delegated task is still pending/in_progress: a\n * subagent outliving the parent's agent_end is still working the plan, so its\n * plan item is genuinely in progress.\n *\n * Returns the number of tasks settled.\n */\nexport function settleDanglingMainTasks(outcome: Extract<TaskStatus, \"done\" | \"cancelled\">): number {\n\tconst all = taskStore.list();\n\tif (all.some((t) => !isMainPlanTask(t) && isActive(t))) return 0;\n\tconst dangling = all.filter((t) => isMainPlanTask(t) && t.status === \"in_progress\");\n\tif (dangling.length === 0) return 0;\n\ttaskStore.batch(() => {\n\t\tfor (const task of dangling) {\n\t\t\ttaskStore.update(task.id, { status: outcome });\n\t\t}\n\t});\n\treturn dangling.length;\n}\n\n/** Create the TodoWrite tool definition. Registered as a customTool when enabled. */\nexport function createTodoWriteToolDefinition(): ToolDefinition {\n\treturn defineTool<typeof todoWriteParams, TodoWriteDetails>({\n\t\tname: TODO_WRITE_TOOL_NAME,\n\t\tlabel: TODO_WRITE_TOOL_NAME,\n\t\tdescription: [\n\t\t\t\"Maintain a structured todo list for the current task, shown live in the task panel.\",\n\t\t\t\"Use it PROACTIVELY: at the start of any multi-step or non-trivial task, write the full plan as todos before you begin, then keep it current as you work.\",\n\t\t\t\"Mark exactly ONE item in_progress at a time, and flip an item to completed immediately after finishing it — do not batch completions or leave finished work marked in_progress.\",\n\t\t\t\"Each call sends the FULL list and REPLACES the previous one — always include every item with its current status; omitting an item removes it.\",\n\t\t\t\"Skip it only for trivial, single-step tasks where a list adds no value. When in doubt on multi-step work, use it — it keeps you from losing track of steps.\",\n\t\t].join(\"\\n\"),\n\t\tpromptSnippet:\n\t\t\t\"Plan and track multi-step work as a live todo list (use proactively; replaces the whole list each call)\",\n\t\tpromptGuidelines: [\n\t\t\t\"Use TodoWrite proactively for any multi-step or non-trivial task: write the plan as todos up front, keep exactly one item in_progress, and mark items completed immediately as you finish them.\",\n\t\t\t\"TodoWrite replaces the entire list each call — always send all items with their current status.\",\n\t\t\t\"Skip TodoWrite for trivial single-step tasks where a checklist adds no value.\",\n\t\t],\n\t\tparameters: todoWriteParams,\n\t\tasync execute(_toolCallId, params: TodoWriteParams) {\n\t\t\tconst todos = params.todos ?? [];\n\t\t\tconst existing = mainTasks();\n\n\t\t\t// Reconcile by item identity first, position second, batched so the panel\n\t\t\t// renders once. Each task stores its item's canonical `content`\n\t\t\t// (todoContent) — the display title flips between content and activeForm\n\t\t\t// with status, so it can't identify an item. Matching by content keeps a\n\t\t\t// task's id pinned to the same plan item when the list is reordered or\n\t\t\t// shrunk; a purely positional reconcile re-labeled the surviving slots,\n\t\t\t// which silently re-pointed the subagent runs linked to those ids\n\t\t\t// (linkedTaskId) at the wrong plan items. Unmatched incoming items then\n\t\t\t// consume the leftover slots in order (a rename keeps its id and its\n\t\t\t// linked runs); any remaining leftovers were removed from the plan.\n\t\t\ttaskStore.batch(() => {\n\t\t\t\tconst content = (item: TodoWriteParams[\"todos\"][number]) => item.content.trim();\n\t\t\t\tconst matchedExisting = new Set<number>();\n\t\t\t\tconst assigned = new Array<Task | undefined>(todos.length);\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tconst idx = existing.findIndex(\n\t\t\t\t\t\t(t, j) => !matchedExisting.has(j) && (t.todoContent ?? t.title) === content(todos[i]!),\n\t\t\t\t\t);\n\t\t\t\t\tif (idx !== -1) {\n\t\t\t\t\t\tmatchedExisting.add(idx);\n\t\t\t\t\t\tassigned[i] = existing[idx];\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst leftovers = existing.filter((_, j) => !matchedExisting.has(j));\n\t\t\t\tlet nextLeftover = 0;\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tif (!assigned[i]) assigned[i] = leftovers[nextLeftover++];\n\t\t\t\t}\n\n\t\t\t\tconst finalIds: number[] = [];\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tconst item = todos[i]!;\n\t\t\t\t\tconst status = toTaskStatus(item.status);\n\t\t\t\t\tconst title = displayTitle(item);\n\t\t\t\t\tconst current = assigned[i];\n\t\t\t\t\tif (current) {\n\t\t\t\t\t\ttaskStore.update(current.id, { title, status, todoContent: content(item) });\n\t\t\t\t\t\tfinalIds.push(current.id);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst created = taskStore.create(title);\n\t\t\t\t\t\ttaskStore.update(created.id, { status, todoContent: content(item) });\n\t\t\t\t\t\tfinalIds.push(created.id);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tfor (let j = nextLeftover; j < leftovers.length; j++) {\n\t\t\t\t\ttaskStore.remove(leftovers[j]!.id);\n\t\t\t\t}\n\t\t\t\t// Identity matching keeps ids, but the panel must still show the plan\n\t\t\t\t// in the list's order — permute the plan tasks into it.\n\t\t\t\ttaskStore.arrange(finalIds);\n\t\t\t});\n\n\t\t\tconst counts = todos.reduce(\n\t\t\t\t(acc, t) => {\n\t\t\t\t\tif (t.status === \"in_progress\") acc.inProgress++;\n\t\t\t\t\telse if (t.status === \"completed\") acc.completed++;\n\t\t\t\t\telse acc.pending++;\n\t\t\t\t\treturn acc;\n\t\t\t\t},\n\t\t\t\t{ pending: 0, inProgress: 0, completed: 0 },\n\t\t\t);\n\n\t\t\tconst lines = todos.map((t) => `${STATUS_GLYPH[toTaskStatus(t.status)]} ${displayTitle(t)}`);\n\t\t\tconst header =\n\t\t\t\ttodos.length === 0\n\t\t\t\t\t? \"Todo list cleared.\"\n\t\t\t\t\t: `Todos updated (${counts.inProgress} in progress, ${counts.pending} pending, ${counts.completed} completed):`;\n\t\t\tconst text = todos.length === 0 ? header : `${header}\\n${lines.join(\"\\n\")}`;\n\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text }],\n\t\t\t\tdetails: {\n\t\t\t\t\ttotal: todos.length,\n\t\t\t\t\tpending: counts.pending,\n\t\t\t\t\tinProgress: counts.inProgress,\n\t\t\t\t\tcompleted: counts.completed,\n\t\t\t\t},\n\t\t\t};\n\t\t},\n\t});\n}\n"]}
|
|
1
|
+
{"version":3,"file":"todo.js","sourceRoot":"","sources":["../../../src/core/tools/todo.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAe,IAAI,EAAE,MAAM,SAAS,CAAC;AAC5C,OAAO,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAuB,MAAM,wBAAwB,CAAC;AACzE,OAAO,EAA8B,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAEzE,MAAM,gBAAgB,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC,EAAE;IACtH,WAAW,EAAE,sFAAsF;CACnG,CAAC,CAAC;AAEH,MAAM,cAAc,GAAG,IAAI,CAAC,MAAM,CACjC;IACC,OAAO,EAAE,IAAI,CAAC,MAAM,CAAC;QACpB,WAAW,EAAE,iEAAiE;KAC9E,CAAC;IACF,MAAM,EAAE,gBAAgB;IACxB,UAAU,EAAE,IAAI,CAAC,QAAQ,CACxB,IAAI,CAAC,MAAM,CAAC;QACX,WAAW,EACV,uGAAuG;KACxG,CAAC,CACF;CACD,EACD,EAAE,oBAAoB,EAAE,KAAK,EAAE,CAC/B,CAAC;AAEF,MAAM,eAAe,GAAG,IAAI,CAAC,MAAM,CAClC;IACC,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,cAAc,EAAE;QACjC,WAAW,EACV,2JAAyJ;KAC1J,CAAC;CACF,EACD,EAAE,oBAAoB,EAAE,KAAK,EAAE,CAC/B,CAAC;AAYF,oEAAoE;AACpE,SAAS,YAAY,CAAC,MAAsB,EAAc;IACzD,OAAO,MAAM,KAAK,WAAW,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC;AAAA,CAChD;AAED,MAAM,YAAY,GAA+B;IAChD,OAAO,EAAE,KAAK;IACd,WAAW,EAAE,KAAK;IAClB,IAAI,EAAE,KAAK;IACX,MAAM,EAAE,KAAK;IACb,+EAA+E;IAC/E,SAAS,EAAE,KAAK;CAChB,CAAC;AAEF,kFAAkF;AAClF,SAAS,YAAY,CAAC,IAAsC,EAAU;IACrE,IAAI,IAAI,CAAC,MAAM,KAAK,aAAa,IAAI,IAAI,CAAC,UAAU,EAAE,IAAI,EAAE;QAAE,OAAO,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC;IAC5F,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC;AAAA,CAC3B;AAED;;;;;;;GAOG;AACH,SAAS,cAAc,CAAC,IAAU,EAAW;IAC5C,OAAO,IAAI,CAAC,MAAM,KAAK,SAAS,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS,IAAI,IAAI,CAAC,YAAY,KAAK,SAAS,CAAC;AAAA,CAChG;AAED,+DAA+D;AAC/D,SAAS,SAAS,GAAW;IAC5B,OAAO,SAAS,CAAC,IAAI,EAAE,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC;AAAA,CAC/C;AAED,SAAS,QAAQ,CAAC,IAAU,EAAW;IACtC,OAAO,IAAI,CAAC,MAAM,KAAK,SAAS,IAAI,IAAI,CAAC,MAAM,KAAK,aAAa,CAAC;AAAA,CAClE;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,uBAAuB,CAAC,OAAkD,EAAU;IACnG,MAAM,GAAG,GAAG,SAAS,CAAC,IAAI,EAAE,CAAC;IAC7B,IAAI,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,IAAI,QAAQ,CAAC,CAAC,CAAC,CAAC;QAAE,OAAO,CAAC,CAAC;IACjE,MAAM,QAAQ,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,cAAc,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,KAAK,aAAa,CAAC,CAAC;IACpF,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC;IACpC,SAAS,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC;QACrB,KAAK,MAAM,IAAI,IAAI,QAAQ,EAAE,CAAC;YAC7B,SAAS,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;QAChD,CAAC;IAAA,CACD,CAAC,CAAC;IACH,OAAO,QAAQ,CAAC,MAAM,CAAC;AAAA,CACvB;AAED,qFAAqF;AACrF,MAAM,UAAU,6BAA6B,GAAmB;IAC/D,OAAO,UAAU,CAA2C;QAC3D,IAAI,EAAE,oBAAoB;QAC1B,KAAK,EAAE,oBAAoB;QAC3B,WAAW,EAAE;YACZ,qFAAqF;YACrF,wIAAwI;YACxI,4GAA4G;YAC5G,0IAAwI;SACxI,CAAC,IAAI,CAAC,IAAI,CAAC;QACZ,aAAa,EACZ,yGAAyG;QAC1G,gFAA8E;QAC9E,6EAA6E;QAC7E,8EAA8E;QAC9E,2EAA2E;QAC3E,sBAAsB;QACtB,gBAAgB,EAAE;YACjB,+FAA+F;SAC/F;QACD,UAAU,EAAE,eAAe;QAC3B,KAAK,CAAC,OAAO,CAAC,WAAW,EAAE,MAAuB,EAAE;YACnD,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC;YACjC,MAAM,QAAQ,GAAG,SAAS,EAAE,CAAC;YAE7B,0EAA0E;YAC1E,gEAAgE;YAChE,2EAAyE;YACzE,yEAAyE;YACzE,uEAAuE;YACvE,wEAAwE;YACxE,kEAAkE;YAClE,wEAAwE;YACxE,qEAAqE;YACrE,oEAAoE;YACpE,SAAS,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC;gBACrB,MAAM,OAAO,GAAG,CAAC,IAAsC,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC;gBAChF,MAAM,eAAe,GAAG,IAAI,GAAG,EAAU,CAAC;gBAC1C,MAAM,QAAQ,GAAG,IAAI,KAAK,CAAmB,KAAK,CAAC,MAAM,CAAC,CAAC;gBAC3D,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;oBACvC,MAAM,GAAG,GAAG,QAAQ,CAAC,SAAS,CAC7B,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,WAAW,IAAI,CAAC,CAAC,KAAK,CAAC,KAAK,OAAO,CAAC,KAAK,CAAC,CAAC,CAAE,CAAC,CACtF,CAAC;oBACF,IAAI,GAAG,KAAK,CAAC,CAAC,EAAE,CAAC;wBAChB,eAAe,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;wBACzB,QAAQ,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC;oBAC7B,CAAC;gBACF,CAAC;gBACD,MAAM,SAAS,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;gBACrE,IAAI,YAAY,GAAG,CAAC,CAAC;gBACrB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;oBACvC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC;wBAAE,QAAQ,CAAC,CAAC,CAAC,GAAG,SAAS,CAAC,YAAY,EAAE,CAAC,CAAC;gBAC3D,CAAC;gBAED,MAAM,QAAQ,GAAa,EAAE,CAAC;gBAC9B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;oBACvC,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAE,CAAC;oBACvB,MAAM,MAAM,GAAG,YAAY,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;oBACzC,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC;oBACjC,MAAM,OAAO,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC;oBAC5B,IAAI,OAAO,EAAE,CAAC;wBACb,SAAS,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;wBAC5E,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;oBAC3B,CAAC;yBAAM,CAAC;wBACP,MAAM,OAAO,GAAG,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;wBACxC,SAAS,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;wBACrE,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;oBAC3B,CAAC;gBACF,CAAC;gBACD,KAAK,IAAI,CAAC,GAAG,YAAY,EAAE,CAAC,GAAG,SAAS,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;oBACtD,SAAS,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAE,CAAC,EAAE,CAAC,CAAC;gBACpC,CAAC;gBACD,sEAAsE;gBACtE,0DAAwD;gBACxD,SAAS,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;YAAA,CAC5B,CAAC,CAAC;YAEH,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,CAC1B,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;gBACX,IAAI,CAAC,CAAC,MAAM,KAAK,aAAa;oBAAE,GAAG,CAAC,UAAU,EAAE,CAAC;qBAC5C,IAAI,CAAC,CAAC,MAAM,KAAK,WAAW;oBAAE,GAAG,CAAC,SAAS,EAAE,CAAC;;oBAC9C,GAAG,CAAC,OAAO,EAAE,CAAC;gBACnB,OAAO,GAAG,CAAC;YAAA,CACX,EACD,EAAE,OAAO,EAAE,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,CAC3C,CAAC;YAEF,MAAM,KAAK,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,YAAY,CAAC,YAAY,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,YAAY,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;YAC7F,MAAM,MAAM,GACX,KAAK,CAAC,MAAM,KAAK,CAAC;gBACjB,CAAC,CAAC,oBAAoB;gBACtB,CAAC,CAAC,kBAAkB,MAAM,CAAC,UAAU,iBAAiB,MAAM,CAAC,OAAO,aAAa,MAAM,CAAC,SAAS,cAAc,CAAC;YAClH,MAAM,IAAI,GAAG,KAAK,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,KAAK,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;YAE5E,OAAO;gBACN,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAe,EAAE,IAAI,EAAE,CAAC;gBAC1C,OAAO,EAAE;oBACR,KAAK,EAAE,KAAK,CAAC,MAAM;oBACnB,OAAO,EAAE,MAAM,CAAC,OAAO;oBACvB,UAAU,EAAE,MAAM,CAAC,UAAU;oBAC7B,SAAS,EAAE,MAAM,CAAC,SAAS;iBAC3B;aACD,CAAC;QAAA,CACF;KACD,CAAC,CAAC;AAAA,CACH","sourcesContent":["/**\n * TodoWrite tool: let the main agent maintain a visible todo list for the\n * current task.\n *\n * hoocode already has all the infrastructure this needs — the task store models\n * `{ title, status }` items with a per-turn lifecycle, and the TUI task panel\n * renders them. The only missing piece was a tool the model can call; this is\n * that thin adapter over `taskStore`.\n *\n * Semantics mirror Claude Code's TodoWrite: each call sends the FULL list and\n * REPLACES the previous one. Because the store is incremental (numeric ids), we\n * reconcile the incoming list against the existing main-agent tasks by position:\n * update items that are still there, create new ones, and drop the tail that was\n * removed. Reconciling (rather than clear-and-recreate) keeps ids stable so the\n * panel does not flicker and in-progress rows stay put.\n *\n * It is an optional, opt-in tool (enabled via the `enableTodoWrite` setting) and\n * is never registered inside a spawned subagent, so a subagent's todos cannot\n * leak into the parent's \"main\" task group.\n */\n\nimport { type Static, Type } from \"typebox\";\nimport { TODO_WRITE_TOOL_NAME } from \"../agent-frontmatter.js\";\nimport { defineTool, type ToolDefinition } from \"../extensions/types.js\";\nimport { type Task, type TaskStatus, taskStore } from \"../task-store.js\";\n\nconst todoStatusSchema = Type.Union([Type.Literal(\"pending\"), Type.Literal(\"in_progress\"), Type.Literal(\"completed\")], {\n\tdescription: \"pending = not started, in_progress = actively being worked on, completed = finished.\",\n});\n\nconst todoItemSchema = Type.Object(\n\t{\n\t\tcontent: Type.String({\n\t\t\tdescription: \"The task, in imperative form (e.g. 'Add tests for the parser').\",\n\t\t}),\n\t\tstatus: todoStatusSchema,\n\t\tactiveForm: Type.Optional(\n\t\t\tType.String({\n\t\t\t\tdescription:\n\t\t\t\t\t\"Optional present-tense form shown while the item is in_progress (e.g. 'Adding tests for the parser').\",\n\t\t\t}),\n\t\t),\n\t},\n\t{ additionalProperties: false },\n);\n\nconst todoWriteParams = Type.Object(\n\t{\n\t\ttodos: Type.Array(todoItemSchema, {\n\t\t\tdescription:\n\t\t\t\t\"The complete todo list. This REPLACES the previous list on every call, so always send every item with its current status — omitting an item removes it.\",\n\t\t}),\n\t},\n\t{ additionalProperties: false },\n);\n\ntype TodoWriteParams = Static<typeof todoWriteParams>;\ntype IncomingStatus = TodoWriteParams[\"todos\"][number][\"status\"];\n\nexport interface TodoWriteDetails {\n\ttotal: number;\n\tpending: number;\n\tinProgress: number;\n\tcompleted: number;\n}\n\n/** Map the model-facing status vocabulary onto the task store's. */\nfunction toTaskStatus(status: IncomingStatus): TaskStatus {\n\treturn status === \"completed\" ? \"done\" : status;\n}\n\nconst STATUS_GLYPH: Record<TaskStatus, string> = {\n\tpending: \"[ ]\",\n\tin_progress: \"[~]\",\n\tdone: \"[x]\",\n\tfailed: \"[!]\",\n\t// TodoWrite never produces cancelled items; present for Record exhaustiveness.\n\tcancelled: \"[-]\",\n};\n\n/** Title to display: the active-form while in progress, otherwise the content. */\nfunction displayTitle(item: TodoWriteParams[\"todos\"][number]): string {\n\tif (item.status === \"in_progress\" && item.activeForm?.trim()) return item.activeForm.trim();\n\treturn item.content.trim();\n}\n\n/**\n * A root task the main agent itself owns, i.e. a TodoWrite plan item: no\n * `source` (excludes \"subagent\"/MCP rows), no `agent` (excludes delegated rows),\n * and no `parentTaskId` (excludes merged child trees). `taskOwnerId()` would\n * fold MCP-sourced and delegated rows under \"main\", so reconciling against it\n * could overwrite or drop those rows when the TodoWrite list is shorter than the\n * combined count.\n */\nfunction isMainPlanTask(task: Task): boolean {\n\treturn task.source === undefined && task.agent === undefined && task.parentTaskId === undefined;\n}\n\n/** Current main-agent plan tasks, in stable creation order. */\nfunction mainTasks(): Task[] {\n\treturn taskStore.list().filter(isMainPlanTask);\n}\n\nfunction isActive(task: Task): boolean {\n\treturn task.status === \"pending\" || task.status === \"in_progress\";\n}\n\n/**\n * Settle plan items the model left pinned at `in_progress` when a request ends.\n *\n * TodoWrite is bookkeeping the model performs by hand, and even strong models\n * routinely drop the final call that flips the last item to completed. Nothing\n * else writes main-plan rows, so without this the panel would keep claiming the\n * work is in flight until the next user message triggers `taskStore.reset()`.\n * The request is over, so the row is wrong either way — settle it to the honest\n * outcome instead of leaving it lying.\n *\n * Scope is deliberately narrow:\n * - Only `in_progress` main-plan items. `pending` rows are left alone: \"never\n * started\" is already an accurate reading of an item the model skipped.\n * - Only main-plan items. Subagent- and MCP-sourced rows settle through their\n * own lifecycles (subagent.ts, mcp-loader.ts) and must not be second-guessed\n * here.\n * - Nothing settles while any delegated task is still pending/in_progress: a\n * subagent outliving the parent's agent_end is still working the plan, so its\n * plan item is genuinely in progress.\n *\n * Returns the number of tasks settled.\n */\nexport function settleDanglingMainTasks(outcome: Extract<TaskStatus, \"done\" | \"cancelled\">): number {\n\tconst all = taskStore.list();\n\tif (all.some((t) => !isMainPlanTask(t) && isActive(t))) return 0;\n\tconst dangling = all.filter((t) => isMainPlanTask(t) && t.status === \"in_progress\");\n\tif (dangling.length === 0) return 0;\n\ttaskStore.batch(() => {\n\t\tfor (const task of dangling) {\n\t\t\ttaskStore.update(task.id, { status: outcome });\n\t\t}\n\t});\n\treturn dangling.length;\n}\n\n/** Create the TodoWrite tool definition. Registered as a customTool when enabled. */\nexport function createTodoWriteToolDefinition(): ToolDefinition {\n\treturn defineTool<typeof todoWriteParams, TodoWriteDetails>({\n\t\tname: TODO_WRITE_TOOL_NAME,\n\t\tlabel: TODO_WRITE_TOOL_NAME,\n\t\tdescription: [\n\t\t\t\"Maintain a structured todo list for the current task, shown live in the task panel.\",\n\t\t\t\"Write the full plan as todos before starting multi-step or non-trivial work, and keep it current; skip only trivial single-step tasks.\",\n\t\t\t\"Mark exactly ONE item in_progress at a time, and flip an item to completed immediately after finishing it.\",\n\t\t\t\"Each call sends the FULL list and REPLACES the previous one — include every item with its current status; omitting an item removes it.\",\n\t\t].join(\"\\n\"),\n\t\tpromptSnippet:\n\t\t\t\"Plan and track multi-step work as a live todo list (use proactively; replaces the whole list each call)\",\n\t\t// Only the \"reach for it at all\" cue lives here — that is the system-prompt's\n\t\t// job. The calling contract (one in_progress, full-list replacement, when to\n\t\t// skip) is already stated in `description` and in the `todos` schema, both of\n\t\t// which ship on every turn alongside this. Same rule three times spent the\n\t\t// tokens three times.\n\t\tpromptGuidelines: [\n\t\t\t\"Use TodoWrite proactively for multi-step or non-trivial work; skip trivial single-step tasks.\",\n\t\t],\n\t\tparameters: todoWriteParams,\n\t\tasync execute(_toolCallId, params: TodoWriteParams) {\n\t\t\tconst todos = params.todos ?? [];\n\t\t\tconst existing = mainTasks();\n\n\t\t\t// Reconcile by item identity first, position second, batched so the panel\n\t\t\t// renders once. Each task stores its item's canonical `content`\n\t\t\t// (todoContent) — the display title flips between content and activeForm\n\t\t\t// with status, so it can't identify an item. Matching by content keeps a\n\t\t\t// task's id pinned to the same plan item when the list is reordered or\n\t\t\t// shrunk; a purely positional reconcile re-labeled the surviving slots,\n\t\t\t// which silently re-pointed the subagent runs linked to those ids\n\t\t\t// (linkedTaskId) at the wrong plan items. Unmatched incoming items then\n\t\t\t// consume the leftover slots in order (a rename keeps its id and its\n\t\t\t// linked runs); any remaining leftovers were removed from the plan.\n\t\t\ttaskStore.batch(() => {\n\t\t\t\tconst content = (item: TodoWriteParams[\"todos\"][number]) => item.content.trim();\n\t\t\t\tconst matchedExisting = new Set<number>();\n\t\t\t\tconst assigned = new Array<Task | undefined>(todos.length);\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tconst idx = existing.findIndex(\n\t\t\t\t\t\t(t, j) => !matchedExisting.has(j) && (t.todoContent ?? t.title) === content(todos[i]!),\n\t\t\t\t\t);\n\t\t\t\t\tif (idx !== -1) {\n\t\t\t\t\t\tmatchedExisting.add(idx);\n\t\t\t\t\t\tassigned[i] = existing[idx];\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst leftovers = existing.filter((_, j) => !matchedExisting.has(j));\n\t\t\t\tlet nextLeftover = 0;\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tif (!assigned[i]) assigned[i] = leftovers[nextLeftover++];\n\t\t\t\t}\n\n\t\t\t\tconst finalIds: number[] = [];\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tconst item = todos[i]!;\n\t\t\t\t\tconst status = toTaskStatus(item.status);\n\t\t\t\t\tconst title = displayTitle(item);\n\t\t\t\t\tconst current = assigned[i];\n\t\t\t\t\tif (current) {\n\t\t\t\t\t\ttaskStore.update(current.id, { title, status, todoContent: content(item) });\n\t\t\t\t\t\tfinalIds.push(current.id);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst created = taskStore.create(title);\n\t\t\t\t\t\ttaskStore.update(created.id, { status, todoContent: content(item) });\n\t\t\t\t\t\tfinalIds.push(created.id);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tfor (let j = nextLeftover; j < leftovers.length; j++) {\n\t\t\t\t\ttaskStore.remove(leftovers[j]!.id);\n\t\t\t\t}\n\t\t\t\t// Identity matching keeps ids, but the panel must still show the plan\n\t\t\t\t// in the list's order — permute the plan tasks into it.\n\t\t\t\ttaskStore.arrange(finalIds);\n\t\t\t});\n\n\t\t\tconst counts = todos.reduce(\n\t\t\t\t(acc, t) => {\n\t\t\t\t\tif (t.status === \"in_progress\") acc.inProgress++;\n\t\t\t\t\telse if (t.status === \"completed\") acc.completed++;\n\t\t\t\t\telse acc.pending++;\n\t\t\t\t\treturn acc;\n\t\t\t\t},\n\t\t\t\t{ pending: 0, inProgress: 0, completed: 0 },\n\t\t\t);\n\n\t\t\tconst lines = todos.map((t) => `${STATUS_GLYPH[toTaskStatus(t.status)]} ${displayTitle(t)}`);\n\t\t\tconst header =\n\t\t\t\ttodos.length === 0\n\t\t\t\t\t? \"Todo list cleared.\"\n\t\t\t\t\t: `Todos updated (${counts.inProgress} in progress, ${counts.pending} pending, ${counts.completed} completed):`;\n\t\t\tconst text = todos.length === 0 ? header : `${header}\\n${lines.join(\"\\n\")}`;\n\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text }],\n\t\t\t\tdetails: {\n\t\t\t\t\ttotal: todos.length,\n\t\t\t\t\tpending: counts.pending,\n\t\t\t\t\tinProgress: counts.inProgress,\n\t\t\t\t\tcompleted: counts.completed,\n\t\t\t\t},\n\t\t\t};\n\t\t},\n\t});\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ask-options.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/ask-options.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,KAAK,EAAe,YAAY,EAAuC,MAAM,gCAAgC,CAAC;AAgCrH,wBAAgB,eAAe,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"ask-options.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/ask-options.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,KAAK,EAAe,YAAY,EAAuC,MAAM,gCAAgC,CAAC;AAgCrH,wBAAgB,eAAe,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAkHtD","sourcesContent":["/**\n * Options pane — the ask_options tool.\n *\n * The model calls this tool when it needs the user to make a decision before\n * continuing. Each question is shown in an inline options pane where the user\n * moves with up/down, advances with right, and may type a custom answer.\n */\n\nimport type { AgentToolResult, AgentToolUpdateCallback } from \"@kolisachint/hoocode-agent-core\";\nimport { type Static, Type } from \"typebox\";\nimport type { AskQuestion, ExtensionAPI, ExtensionContext, SessionStartEvent } from \"../../core/extensions/types.js\";\nimport { LOOP_AUTO_CHANGED, LOOP_HALT } from \"./loop.js\";\n\nconst askOptionsSchema = Type.Object({\n\tquestions: Type.Array(\n\t\tType.Object({\n\t\t\tquestion: Type.String({ description: \"The question to ask the user.\" }),\n\t\t\tdetail: Type.Optional(Type.String({ description: \"Optional clarifying sub-text shown under the question.\" })),\n\t\t\toptions: Type.Array(\n\t\t\t\tType.Object({\n\t\t\t\t\tlabel: Type.String({ description: \"The option text; returned verbatim when chosen.\" }),\n\t\t\t\t\tdescription: Type.Optional(\n\t\t\t\t\t\tType.String({ description: \"Optional short description shown next to the option.\" }),\n\t\t\t\t\t),\n\t\t\t\t\trecommended: Type.Optional(\n\t\t\t\t\t\tType.Boolean({\n\t\t\t\t\t\t\tdescription: \"When true, the option is marked '(recommended)' to help the user choose.\",\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t\t}),\n\t\t\t\t{ description: \"The options the user can choose from.\" },\n\t\t\t),\n\t\t\tallow_custom: Type.Optional(\n\t\t\t\tType.Boolean({\n\t\t\t\t\tdescription: \"When true, the user can type a free-form answer instead of choosing an option.\",\n\t\t\t\t}),\n\t\t\t),\n\t\t}),\n\t\t{ description: \"One or more decisions to ask the user, in order.\" },\n\t),\n});\n\nexport function setupAskOptions(pi: ExtensionAPI): void {\n\t// Capture the latest context so the tool can reach the interactive UI.\n\tlet activeCtx: ExtensionContext | undefined;\n\tpi.on(\"session_start\", (_event: SessionStartEvent, ctx: ExtensionContext) => {\n\t\tactiveCtx = ctx;\n\t});\n\n\t// Track whether an autonomous /loop is running so we never block on a human\n\t// who isn't there. The loop extension broadcasts this on the shared bus.\n\tlet autoLoopActive = false;\n\tpi.events.on(LOOP_AUTO_CHANGED, (data) => {\n\t\tautoLoopActive = !!(data as { active?: boolean })?.active;\n\t});\n\n\tpi.registerTool({\n\t\tname: \"ask_options\",\n\t\tlabel: \"Ask the user\",\n\t\tdescription:\n\t\t\t\"Ask the user to make one or more decisions before continuing. Each question is presented \" +\n\t\t\t\"in an interactive options pane where the user selects an option (or types a custom answer). \" +\n\t\t\t\"Use this when you genuinely need input to proceed and cannot reasonably decide yourself. \" +\n\t\t\t\"Returns the user's answer for each question; if the user skips, no answers are returned.\",\n\t\tpromptSnippet: \"Put a decision to the user as selectable options\",\n\t\tparameters: askOptionsSchema,\n\t\tasync execute(\n\t\t\t_toolCallId: string,\n\t\t\tparams: Static<typeof askOptionsSchema>,\n\t\t\tsignal: AbortSignal,\n\t\t\t_onUpdate: AgentToolUpdateCallback,\n\t\t): Promise<AgentToolResult<undefined>> {\n\t\t\tif (!activeCtx || !activeCtx.hasUI) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\ttext: \"Cannot ask the user: no interactive UI is available in this session. Proceed using your best judgement.\",\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tif (!params.questions.length) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: \"No questions were provided.\" }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Autonomous loop: no human is watching, so never block on the pane.\n\t\t\t// Decide any question that carries a recommended default and proceed;\n\t\t\t// if any question lacks one, there is no safe default — halt the loop\n\t\t\t// and let the model report the blocker instead of guessing.\n\t\t\tif (autoLoopActive) {\n\t\t\t\tconst blockers = params.questions.filter((q) => !q.options.some((o) => o.recommended));\n\t\t\t\tif (blockers.length) {\n\t\t\t\t\tconst list = blockers.map((q) => ` • ${q.question}`).join(\"\\n\");\n\t\t\t\t\tpi.events.emit(LOOP_HALT, {\n\t\t\t\t\t\treason: `ask_options had ${blockers.length} question(s) with no recommended default.`,\n\t\t\t\t\t});\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\t\ttext:\n\t\t\t\t\t\t\t\t\t`Autonomous loop: no user is available to answer, and ${blockers.length} question(s) have ` +\n\t\t\t\t\t\t\t\t\t`no recommended default to fall back on:\\n${list}\\n\\n` +\n\t\t\t\t\t\t\t\t\t`The loop has been stopped. Do not guess — stop and report this blocker to the user, ` +\n\t\t\t\t\t\t\t\t\t`explaining what decision is needed and the options you were weighing.`,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t],\n\t\t\t\t\t\tdetails: undefined,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\tconst text = params.questions\n\t\t\t\t\t.map((q) => {\n\t\t\t\t\t\tconst rec = q.options.find((o) => o.recommended);\n\t\t\t\t\t\treturn `${q.question}\\n → ${rec?.label} (auto-selected recommended default; autonomous loop, no user present)`;\n\t\t\t\t\t})\n\t\t\t\t\t.join(\"\\n\\n\");\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tconst questions: AskQuestion[] = params.questions.map((q) => ({\n\t\t\t\tquestion: q.question,\n\t\t\t\tdetail: q.detail,\n\t\t\t\toptions: q.options.map((o) => ({ label: o.label, description: o.description, recommended: o.recommended })),\n\t\t\t\tallowCustom: q.allow_custom,\n\t\t\t}));\n\n\t\t\tconst answers = await activeCtx.ui.askOptions(questions, { signal });\n\n\t\t\tif (!answers) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\ttext: \"The user skipped the question(s) without answering. Ask how they would like to proceed.\",\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tconst text = questions.map((q, i) => `${q.question}\\n → ${answers[i] ?? \"(no answer)\"}`).join(\"\\n\\n\");\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\", text }],\n\t\t\t\tdetails: undefined,\n\t\t\t};\n\t\t},\n\t});\n}\n"]}
|
|
@@ -42,6 +42,7 @@ export function setupAskOptions(pi) {
|
|
|
42
42
|
"in an interactive options pane where the user selects an option (or types a custom answer). " +
|
|
43
43
|
"Use this when you genuinely need input to proceed and cannot reasonably decide yourself. " +
|
|
44
44
|
"Returns the user's answer for each question; if the user skips, no answers are returned.",
|
|
45
|
+
promptSnippet: "Put a decision to the user as selectable options",
|
|
45
46
|
parameters: askOptionsSchema,
|
|
46
47
|
async execute(_toolCallId, params, signal, _onUpdate) {
|
|
47
48
|
if (!activeCtx || !activeCtx.hasUI) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ask-options.js","sourceRoot":"","sources":["../../../src/extensions/core/ask-options.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Options pane — the ask_options tool.\n *\n * The model calls this tool when it needs the user to make a decision before\n * continuing. Each question is shown in an inline options pane where the user\n * moves with up/down, advances with right, and may type a custom answer.\n */\n\nimport type { AgentToolResult, AgentToolUpdateCallback } from \"@kolisachint/hoocode-agent-core\";\nimport { type Static, Type } from \"typebox\";\nimport type { AskQuestion, ExtensionAPI, ExtensionContext, SessionStartEvent } from \"../../core/extensions/types.js\";\nimport { LOOP_AUTO_CHANGED, LOOP_HALT } from \"./loop.js\";\n\nconst askOptionsSchema = Type.Object({\n\tquestions: Type.Array(\n\t\tType.Object({\n\t\t\tquestion: Type.String({ description: \"The question to ask the user.\" }),\n\t\t\tdetail: Type.Optional(Type.String({ description: \"Optional clarifying sub-text shown under the question.\" })),\n\t\t\toptions: Type.Array(\n\t\t\t\tType.Object({\n\t\t\t\t\tlabel: Type.String({ description: \"The option text; returned verbatim when chosen.\" }),\n\t\t\t\t\tdescription: Type.Optional(\n\t\t\t\t\t\tType.String({ description: \"Optional short description shown next to the option.\" }),\n\t\t\t\t\t),\n\t\t\t\t\trecommended: Type.Optional(\n\t\t\t\t\t\tType.Boolean({\n\t\t\t\t\t\t\tdescription: \"When true, the option is marked '(recommended)' to help the user choose.\",\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t\t}),\n\t\t\t\t{ description: \"The options the user can choose from.\" },\n\t\t\t),\n\t\t\tallow_custom: Type.Optional(\n\t\t\t\tType.Boolean({\n\t\t\t\t\tdescription: \"When true, the user can type a free-form answer instead of choosing an option.\",\n\t\t\t\t}),\n\t\t\t),\n\t\t}),\n\t\t{ description: \"One or more decisions to ask the user, in order.\" },\n\t),\n});\n\nexport function setupAskOptions(pi: ExtensionAPI): void {\n\t// Capture the latest context so the tool can reach the interactive UI.\n\tlet activeCtx: ExtensionContext | undefined;\n\tpi.on(\"session_start\", (_event: SessionStartEvent, ctx: ExtensionContext) => {\n\t\tactiveCtx = ctx;\n\t});\n\n\t// Track whether an autonomous /loop is running so we never block on a human\n\t// who isn't there. The loop extension broadcasts this on the shared bus.\n\tlet autoLoopActive = false;\n\tpi.events.on(LOOP_AUTO_CHANGED, (data) => {\n\t\tautoLoopActive = !!(data as { active?: boolean })?.active;\n\t});\n\n\tpi.registerTool({\n\t\tname: \"ask_options\",\n\t\tlabel: \"Ask the user\",\n\t\tdescription:\n\t\t\t\"Ask the user to make one or more decisions before continuing. Each question is presented \" +\n\t\t\t\"in an interactive options pane where the user selects an option (or types a custom answer). \" +\n\t\t\t\"Use this when you genuinely need input to proceed and cannot reasonably decide yourself. \" +\n\t\t\t\"Returns the user's answer for each question; if the user skips, no answers are returned.\",\n\t\tparameters: askOptionsSchema,\n\t\tasync execute(\n\t\t\t_toolCallId: string,\n\t\t\tparams: Static<typeof askOptionsSchema>,\n\t\t\tsignal: AbortSignal,\n\t\t\t_onUpdate: AgentToolUpdateCallback,\n\t\t): Promise<AgentToolResult<undefined>> {\n\t\t\tif (!activeCtx || !activeCtx.hasUI) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\ttext: \"Cannot ask the user: no interactive UI is available in this session. Proceed using your best judgement.\",\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tif (!params.questions.length) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: \"No questions were provided.\" }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Autonomous loop: no human is watching, so never block on the pane.\n\t\t\t// Decide any question that carries a recommended default and proceed;\n\t\t\t// if any question lacks one, there is no safe default — halt the loop\n\t\t\t// and let the model report the blocker instead of guessing.\n\t\t\tif (autoLoopActive) {\n\t\t\t\tconst blockers = params.questions.filter((q) => !q.options.some((o) => o.recommended));\n\t\t\t\tif (blockers.length) {\n\t\t\t\t\tconst list = blockers.map((q) => ` • ${q.question}`).join(\"\\n\");\n\t\t\t\t\tpi.events.emit(LOOP_HALT, {\n\t\t\t\t\t\treason: `ask_options had ${blockers.length} question(s) with no recommended default.`,\n\t\t\t\t\t});\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\t\ttext:\n\t\t\t\t\t\t\t\t\t`Autonomous loop: no user is available to answer, and ${blockers.length} question(s) have ` +\n\t\t\t\t\t\t\t\t\t`no recommended default to fall back on:\\n${list}\\n\\n` +\n\t\t\t\t\t\t\t\t\t`The loop has been stopped. Do not guess — stop and report this blocker to the user, ` +\n\t\t\t\t\t\t\t\t\t`explaining what decision is needed and the options you were weighing.`,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t],\n\t\t\t\t\t\tdetails: undefined,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\tconst text = params.questions\n\t\t\t\t\t.map((q) => {\n\t\t\t\t\t\tconst rec = q.options.find((o) => o.recommended);\n\t\t\t\t\t\treturn `${q.question}\\n → ${rec?.label} (auto-selected recommended default; autonomous loop, no user present)`;\n\t\t\t\t\t})\n\t\t\t\t\t.join(\"\\n\\n\");\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tconst questions: AskQuestion[] = params.questions.map((q) => ({\n\t\t\t\tquestion: q.question,\n\t\t\t\tdetail: q.detail,\n\t\t\t\toptions: q.options.map((o) => ({ label: o.label, description: o.description, recommended: o.recommended })),\n\t\t\t\tallowCustom: q.allow_custom,\n\t\t\t}));\n\n\t\t\tconst answers = await activeCtx.ui.askOptions(questions, { signal });\n\n\t\t\tif (!answers) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\ttext: \"The user skipped the question(s) without answering. Ask how they would like to proceed.\",\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tconst text = questions.map((q, i) => `${q.question}\\n → ${answers[i] ?? \"(no answer)\"}`).join(\"\\n\\n\");\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\", text }],\n\t\t\t\tdetails: undefined,\n\t\t\t};\n\t\t},\n\t});\n}\n"]}
|
|
1
|
+
{"version":3,"file":"ask-options.js","sourceRoot":"","sources":["../../../src/extensions/core/ask-options.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Options pane — the ask_options tool.\n *\n * The model calls this tool when it needs the user to make a decision before\n * continuing. Each question is shown in an inline options pane where the user\n * moves with up/down, advances with right, and may type a custom answer.\n */\n\nimport type { AgentToolResult, AgentToolUpdateCallback } from \"@kolisachint/hoocode-agent-core\";\nimport { type Static, Type } from \"typebox\";\nimport type { AskQuestion, ExtensionAPI, ExtensionContext, SessionStartEvent } from \"../../core/extensions/types.js\";\nimport { LOOP_AUTO_CHANGED, LOOP_HALT } from \"./loop.js\";\n\nconst askOptionsSchema = Type.Object({\n\tquestions: Type.Array(\n\t\tType.Object({\n\t\t\tquestion: Type.String({ description: \"The question to ask the user.\" }),\n\t\t\tdetail: Type.Optional(Type.String({ description: \"Optional clarifying sub-text shown under the question.\" })),\n\t\t\toptions: Type.Array(\n\t\t\t\tType.Object({\n\t\t\t\t\tlabel: Type.String({ description: \"The option text; returned verbatim when chosen.\" }),\n\t\t\t\t\tdescription: Type.Optional(\n\t\t\t\t\t\tType.String({ description: \"Optional short description shown next to the option.\" }),\n\t\t\t\t\t),\n\t\t\t\t\trecommended: Type.Optional(\n\t\t\t\t\t\tType.Boolean({\n\t\t\t\t\t\t\tdescription: \"When true, the option is marked '(recommended)' to help the user choose.\",\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t\t}),\n\t\t\t\t{ description: \"The options the user can choose from.\" },\n\t\t\t),\n\t\t\tallow_custom: Type.Optional(\n\t\t\t\tType.Boolean({\n\t\t\t\t\tdescription: \"When true, the user can type a free-form answer instead of choosing an option.\",\n\t\t\t\t}),\n\t\t\t),\n\t\t}),\n\t\t{ description: \"One or more decisions to ask the user, in order.\" },\n\t),\n});\n\nexport function setupAskOptions(pi: ExtensionAPI): void {\n\t// Capture the latest context so the tool can reach the interactive UI.\n\tlet activeCtx: ExtensionContext | undefined;\n\tpi.on(\"session_start\", (_event: SessionStartEvent, ctx: ExtensionContext) => {\n\t\tactiveCtx = ctx;\n\t});\n\n\t// Track whether an autonomous /loop is running so we never block on a human\n\t// who isn't there. The loop extension broadcasts this on the shared bus.\n\tlet autoLoopActive = false;\n\tpi.events.on(LOOP_AUTO_CHANGED, (data) => {\n\t\tautoLoopActive = !!(data as { active?: boolean })?.active;\n\t});\n\n\tpi.registerTool({\n\t\tname: \"ask_options\",\n\t\tlabel: \"Ask the user\",\n\t\tdescription:\n\t\t\t\"Ask the user to make one or more decisions before continuing. Each question is presented \" +\n\t\t\t\"in an interactive options pane where the user selects an option (or types a custom answer). \" +\n\t\t\t\"Use this when you genuinely need input to proceed and cannot reasonably decide yourself. \" +\n\t\t\t\"Returns the user's answer for each question; if the user skips, no answers are returned.\",\n\t\tpromptSnippet: \"Put a decision to the user as selectable options\",\n\t\tparameters: askOptionsSchema,\n\t\tasync execute(\n\t\t\t_toolCallId: string,\n\t\t\tparams: Static<typeof askOptionsSchema>,\n\t\t\tsignal: AbortSignal,\n\t\t\t_onUpdate: AgentToolUpdateCallback,\n\t\t): Promise<AgentToolResult<undefined>> {\n\t\t\tif (!activeCtx || !activeCtx.hasUI) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\ttext: \"Cannot ask the user: no interactive UI is available in this session. Proceed using your best judgement.\",\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tif (!params.questions.length) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: \"No questions were provided.\" }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Autonomous loop: no human is watching, so never block on the pane.\n\t\t\t// Decide any question that carries a recommended default and proceed;\n\t\t\t// if any question lacks one, there is no safe default — halt the loop\n\t\t\t// and let the model report the blocker instead of guessing.\n\t\t\tif (autoLoopActive) {\n\t\t\t\tconst blockers = params.questions.filter((q) => !q.options.some((o) => o.recommended));\n\t\t\t\tif (blockers.length) {\n\t\t\t\t\tconst list = blockers.map((q) => ` • ${q.question}`).join(\"\\n\");\n\t\t\t\t\tpi.events.emit(LOOP_HALT, {\n\t\t\t\t\t\treason: `ask_options had ${blockers.length} question(s) with no recommended default.`,\n\t\t\t\t\t});\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\t\ttext:\n\t\t\t\t\t\t\t\t\t`Autonomous loop: no user is available to answer, and ${blockers.length} question(s) have ` +\n\t\t\t\t\t\t\t\t\t`no recommended default to fall back on:\\n${list}\\n\\n` +\n\t\t\t\t\t\t\t\t\t`The loop has been stopped. Do not guess — stop and report this blocker to the user, ` +\n\t\t\t\t\t\t\t\t\t`explaining what decision is needed and the options you were weighing.`,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t],\n\t\t\t\t\t\tdetails: undefined,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\tconst text = params.questions\n\t\t\t\t\t.map((q) => {\n\t\t\t\t\t\tconst rec = q.options.find((o) => o.recommended);\n\t\t\t\t\t\treturn `${q.question}\\n → ${rec?.label} (auto-selected recommended default; autonomous loop, no user present)`;\n\t\t\t\t\t})\n\t\t\t\t\t.join(\"\\n\\n\");\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tconst questions: AskQuestion[] = params.questions.map((q) => ({\n\t\t\t\tquestion: q.question,\n\t\t\t\tdetail: q.detail,\n\t\t\t\toptions: q.options.map((o) => ({ label: o.label, description: o.description, recommended: o.recommended })),\n\t\t\t\tallowCustom: q.allow_custom,\n\t\t\t}));\n\n\t\t\tconst answers = await activeCtx.ui.askOptions(questions, { signal });\n\n\t\t\tif (!answers) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\ttext: \"The user skipped the question(s) without answering. Ask how they would like to proceed.\",\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tconst text = questions.map((q, i) => `${q.question}\\n → ${answers[i] ?? \"(no answer)\"}`).join(\"\\n\\n\");\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\", text }],\n\t\t\t\tdetails: undefined,\n\t\t\t};\n\t\t},\n\t});\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"modes.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/modes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAKH,OAAO,KAAK,EAGX,YAAY,EAIZ,MAAM,gCAAgC,CAAC;AAyExC;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,MAAM,GAAG,SAAS,CAGnH;AAMD,MAAM,WAAW,YAAY;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iEAAiE;IACjE,GAAG,EAAE,MAAM,CAAC;CACZ;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,YAAY,CA2BnE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,YAAY,GAAG,MAAM,CAwBlE;AAMD,qDAAqD;AACrD,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,IAAI,GAAG,MAAM,CAAC;AAEjD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS,CAKtE;AAiCD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,YAAY,EAAE,MAAM,EAAE,WAAW,GAAG,MAAM,CAOrF;AAMD,mCAAmC;AACnC,MAAM,WAAW,cAAc;IAC9B,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yDAAyD;IACzD,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS,CAatE;AAED,6DAA6D;AAC7D,MAAM,WAAW,YAAY;IAC5B,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,+DAA+D;IAC/D,cAAc,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,YAAY,CAWxF;AAMD,wBAAgB,SAAS,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CA6OhD","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 * /grill, /goal, 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 { isLightModeEnv } from \"../../core/light.js\";\nimport { DEFAULT_MODE, DEFAULT_MODE_PROMPTS as MODE_DEFAULTS } from \"../../core/mode-prompts.js\";\nimport { EMBEDDED_PROMPTS } from \"../../init-templates.generated.js\";\nimport { mergeSearchPaths, readConfig, readMergedConfig, writeConfig } from \"./config.js\";\nimport { AUTO_LOOP_DONE_TOKEN, LOOP_AUTO_CHANGED, LOOP_AUTO_START, type LoopAutoStartPayload } from \"./loop.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/** Outcome of reading the first usable plan file out of a candidate list. */\ntype PlanLoadResult =\n\t| { status: \"loaded\"; sections: PlanSections }\n\t| { status: \"missing\" }\n\t| { status: \"error\"; path: string };\n\n/**\n * Reads the first candidate plan file that exists and is non-empty, parsed into\n * sections. Missing and blank files fall through to the next candidate; an\n * unreadable one stops the walk so the caller can report the specific path.\n */\nfunction loadPlanSections(candidatePaths: string[]): PlanLoadResult {\n\tfor (const planPath of candidatePaths) {\n\t\tif (!existsSync(planPath)) continue;\n\t\ttry {\n\t\t\tconst raw = readFileSync(planPath, \"utf8\").trim();\n\t\t\tif (raw) return { status: \"loaded\", sections: parsePlanSections(raw) };\n\t\t} catch {\n\t\t\treturn { status: \"error\", path: planPath };\n\t\t}\n\t}\n\treturn { status: \"missing\" };\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// Grill: plan interrogation\n// ============================================================================\n\n/** Which half (or both) of the grill flow to run. */\nexport type GrillTarget = \"both\" | \"me\" | \"plan\";\n\n/**\n * Parses the `/grill` argument into a target.\n *\n * Bare `/grill` runs both phases, `/grill me` only questions the user, and\n * `/grill plan` only critiques the plan. Anything else returns undefined so the\n * caller shows usage rather than guessing at intent.\n */\nexport function parseGrillTarget(args: string): GrillTarget | undefined {\n\tconst arg = args.trim().toLowerCase();\n\tif (!arg) return \"both\";\n\tif (arg === \"me\" || arg === \"plan\") return arg;\n\treturn undefined;\n}\n\n/**\n * Renders the parsed plan back into the message so the agent reviews a concrete\n * artifact rather than whatever it remembers writing. Falls back to the raw plan\n * text when no sections were recognised.\n */\nfunction renderPlanForReview(sections: PlanSections): string {\n\tconst parts: string[] = [];\n\tif (sections.goal) parts.push(`**Goal**\\n${sections.goal}`);\n\tif (sections.filesToModify) parts.push(`**Files to modify**\\n${sections.filesToModify}`);\n\tif (sections.newFiles) parts.push(`**New files**\\n${sections.newFiles}`);\n\tif (sections.tests) parts.push(`**Tests**\\n${sections.tests}`);\n\tif (sections.verification) parts.push(`**Verification**\\n${sections.verification}`);\n\treturn parts.length > 0 ? parts.join(\"\\n\\n\") : sections.raw;\n}\n\n/**\n * The `/grill` phases, read from the shipped prompt templates.\n *\n * These were two ~200-word string constants in this file. They are prose the\n * runtime injects verbatim - no interpolation, no branching, nothing a compiler\n * checks - so a `.md` under `templates/prompts/` is their right home, and the\n * existing build-time embed carries them in. Editing a prompt is now editing\n * prose rather than editing TypeScript that happens to contain prose.\n *\n * Each is trimmed: the files end with a newline and the phases are joined with\n * explicit separators below.\n */\nfunction grillPrompt(name: \"grill-me\" | \"grill-plan\" | \"grill-bridge\"): string {\n\treturn (EMBEDDED_PROMPTS[name] ?? \"\").trim();\n}\n\n/**\n * Builds the follow-up message injected by `/grill`.\n *\n * The two phases are ordered rather than alternative: underspecification sits\n * upstream of plan weakness, so critiquing a plan built on a misread request\n * produces a well-reviewed plan for the wrong job. Bare `/grill` therefore asks\n * first and folds the answers into the critique.\n */\nexport function buildGrillMessage(sections: PlanSections, target: GrillTarget): string {\n\tconst plan = renderPlanForReview(sections);\n\tconst me = grillPrompt(\"grill-me\");\n\tconst critique = grillPrompt(\"grill-plan\");\n\tif (target === \"me\") return `${me}\\n\\n---\\n\\n${plan}`;\n\tif (target === \"plan\") return `${critique}\\n\\n---\\n\\n${plan}`;\n\treturn `${me}\\n\\n${grillPrompt(\"grill-bridge\")}\\n\\n${critique}\\n\\n---\\n\\n${plan}`;\n}\n\n// ============================================================================\n// Goal: autonomous execution toward a completion condition\n// ============================================================================\n\n/** A parsed `/goal` invocation. */\nexport interface GoalInvocation {\n\t/** Explicit objective; falls back to the plan's Goal section when absent. */\n\tobjective?: string;\n\t/** Turn budget passed through to the autonomous loop. */\n\tmaxTurns?: number;\n}\n\n/**\n * Parses `/goal [--max-turns N] [objective]`.\n *\n * Returns undefined when `--max-turns` is present but not followed by a\n * non-negative integer, so the caller shows usage instead of silently running\n * with the default budget.\n */\nexport function parseGoalArgs(args: string): GoalInvocation | undefined {\n\tlet rest = args.trim();\n\tlet maxTurns: number | undefined;\n\n\tconst flag = /^--max-turns(?:\\s+(\\S+))?([\\s\\S]*)$/.exec(rest);\n\tif (flag) {\n\t\tconst raw = flag[1];\n\t\tif (!raw || !/^\\d+$/.test(raw)) return undefined;\n\t\tmaxTurns = Number(raw);\n\t\trest = flag[2].trim();\n\t}\n\n\treturn { objective: rest || undefined, maxTurns };\n}\n\n/** The two messages `/goal` hands to the autonomous loop. */\nexport interface GoalMessages {\n\t/** Delivered once, to start the run. */\n\ttask: string;\n\t/** Re-sent on each continuation to keep the target in view. */\n\tcontinuePrompt: string;\n}\n\n/**\n * Builds the messages for a `/goal` run.\n *\n * When the plan carries a Verification section it becomes the completion\n * condition, which is the difference between a loop that stops when it feels\n * finished and one that stops when something it can run says so. Both messages\n * restate the objective, because the loop's continuation prompt is all that\n * holds the target in place as the transcript grows.\n */\nexport function buildGoalMessages(objective: string, verification?: string): GoalMessages {\n\tconst condition = verification\n\t\t? `\\n\\nThe goal counts as complete only once this verification passes. Run it, and if it fails, fix what it reports and run it again:\\n\\n${verification}`\n\t\t: \"\";\n\n\treturn {\n\t\ttask: `Work autonomously toward this goal:\\n\\n${objective}${condition}`,\n\t\tcontinuePrompt:\n\t\t\t`Keep working toward the goal:\\n\\n${objective}${condition}\\n\\n` +\n\t\t\t`Reply with ${AUTO_LOOP_DONE_TOKEN} only when it is genuinely complete — not when it is merely close.`,\n\t};\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// `/grill me` asks the user a question via ask_options, which an unattended\n\t// `/loop auto` run has nobody to answer. Track the loop's broadcast state so\n\t// the command can drop its interrogation phase instead of stalling the run.\n\tlet autoLoopActive = false;\n\tpi.events.on(LOOP_AUTO_CHANGED, (data) => {\n\t\tautoLoopActive = (data as { active?: boolean } | undefined)?.active === true;\n\t});\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// Light mode strips every fixed prompt appendix. Leaving\n\t\t// cachedSystemPrompt unset makes before_agent_start a no-op, so no\n\t\t// `<!-- hoo-core: mode= -->` block is appended.\n\t\tif (isLightModeEnv()) {\n\t\t\treturn;\n\t\t}\n\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// ── /grill command ────────────────────────────────────────────────────────\n\t// Stress-tests the current plan in two phases: `me` surfaces the request's\n\t// unconfirmed assumptions as ask_options questions, `plan` critiques the plan\n\t// file itself. Unlike /approve this only injects a follow-up message — no\n\t// session switch, no mode change, no config write.\n\n\tpi.registerCommand(\"grill\", {\n\t\tdescription: \"Stress-test the current plan. Usage: /grill [me|plan]\",\n\t\tgetArgumentCompletions: (prefix: string) =>\n\t\t\t[\"me\", \"plan\"].filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s })),\n\t\thandler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst requested = parseGrillTarget(args);\n\t\t\tif (!requested) {\n\t\t\t\tctx.ui.notify(\"Usage: /grill [me|plan]\", \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Same lookup as /approve: per-session plan first, legacy file second.\n\t\t\tconst sessionPlanPath = cachedPlanPath ?? getPlanPath(ctx.cwd, ctx.sessionManager.getSessionId());\n\t\t\tconst loaded = loadPlanSections([sessionPlanPath, getLegacyPlanPath(ctx.cwd)]);\n\t\t\tif (loaded.status === \"error\") {\n\t\t\t\tctx.ui.notify(`Could not read ${relative(ctx.cwd, loaded.path) || loaded.path}`, \"error\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (loaded.status === \"missing\") {\n\t\t\t\tconst relPlan = relative(ctx.cwd, sessionPlanPath) || sessionPlanPath;\n\t\t\t\tctx.ui.notify(`No plan to grill — ${relPlan} not found. Run /plan first.`, \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Nobody is present to answer during an autonomous loop, so keep the\n\t\t\t// half of the flow that still works rather than blocking on a question.\n\t\t\tlet target = requested;\n\t\t\tif (autoLoopActive && target !== \"plan\") {\n\t\t\t\ttarget = \"plan\";\n\t\t\t\tctx.ui.notify(\"Autonomous loop active — grilling the plan only, skipping questions.\", \"info\");\n\t\t\t}\n\n\t\t\tpi.sendUserMessage(buildGrillMessage(loaded.sections, target), { deliverAs: \"followUp\" });\n\t\t},\n\t});\n\n\t// ── /goal command ─────────────────────────────────────────────────────────\n\t// Works autonomously toward a completion condition by driving the loop in\n\t// loop.ts over the LOOP_AUTO_START channel. Linear by design: a single thread\n\t// of execution that iterates until it reports done or exhausts its budget —\n\t// there is no task graph here, and no subtask fan-out.\n\t//\n\t// The run inherits whatever the active mode already permits (enabled_tools,\n\t// allowed_bash_commands, allowed_write_paths, auto_allow), so how much rope\n\t// an unattended goal gets is a mode-config decision, not one made here.\n\n\tpi.registerCommand(\"goal\", {\n\t\tdescription: \"Work autonomously toward a goal. Usage: /goal [--max-turns N] [objective]\",\n\t\tgetArgumentCompletions: () => [],\n\t\thandler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst parsed = parseGoalArgs(args);\n\t\t\tif (!parsed) {\n\t\t\t\tctx.ui.notify(\"Usage: /goal [--max-turns N] [objective]\", \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// The plan supplies the objective when none was typed, and its\n\t\t\t// verification section becomes the completion condition either way.\n\t\t\tconst sessionPlanPath = cachedPlanPath ?? getPlanPath(ctx.cwd, ctx.sessionManager.getSessionId());\n\t\t\tconst loaded = loadPlanSections([sessionPlanPath, getLegacyPlanPath(ctx.cwd)]);\n\t\t\tif (loaded.status === \"error\") {\n\t\t\t\tctx.ui.notify(`Could not read ${relative(ctx.cwd, loaded.path) || loaded.path}`, \"error\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst sections = loaded.status === \"loaded\" ? loaded.sections : undefined;\n\n\t\t\tconst objective = parsed.objective ?? sections?.goal;\n\t\t\tif (!objective) {\n\t\t\t\tconst relPlan = relative(ctx.cwd, sessionPlanPath) || sessionPlanPath;\n\t\t\t\tctx.ui.notify(\n\t\t\t\t\t`No objective — pass one as /goal <objective>, or write a Goal section in ${relPlan}.`,\n\t\t\t\t\t\"warning\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst { task, continuePrompt } = buildGoalMessages(objective, sections?.verification);\n\t\t\t// The loop announces itself on start, so nothing to notify here.\n\t\t\tpi.events.emit(LOOP_AUTO_START, {\n\t\t\t\ttask,\n\t\t\t\tmaxTurns: parsed.maxTurns,\n\t\t\t\tcontinuePrompt,\n\t\t\t} satisfies LoopAutoStartPayload);\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 loaded = loadPlanSections([sessionPlanPath, getLegacyPlanPath(ctx.cwd)]);\n\t\t\tif (loaded.status === \"error\") {\n\t\t\t\tctx.ui.notify(`Could not read ${relative(ctx.cwd, loaded.path) || loaded.path}`, \"error\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst approveMessage = loaded.status === \"loaded\" ? buildApproveMessage(loaded.sections) : undefined;\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"]}
|
|
1
|
+
{"version":3,"file":"modes.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/modes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAKH,OAAO,KAAK,EAGX,YAAY,EAIZ,MAAM,gCAAgC,CAAC;AA0ExC;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,MAAM,GAAG,SAAS,CAGnH;AAMD,MAAM,WAAW,YAAY;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iEAAiE;IACjE,GAAG,EAAE,MAAM,CAAC;CACZ;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,YAAY,CA2BnE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,YAAY,GAAG,MAAM,CAwBlE;AAMD,qDAAqD;AACrD,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,IAAI,GAAG,MAAM,CAAC;AAEjD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS,CAKtE;AAiCD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,YAAY,EAAE,MAAM,EAAE,WAAW,GAAG,MAAM,CAOrF;AAMD,mCAAmC;AACnC,MAAM,WAAW,cAAc;IAC9B,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yDAAyD;IACzD,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS,CAatE;AAED,6DAA6D;AAC7D,MAAM,WAAW,YAAY;IAC5B,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,+DAA+D;IAC/D,cAAc,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,YAAY,CAWxF;AAMD,wBAAgB,SAAS,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAsPhD","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 * /grill, /goal, 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 { isLightModeEnv } from \"../../core/light.js\";\nimport { DEFAULT_MODE, DEFAULT_MODE_PROMPTS as MODE_DEFAULTS } from \"../../core/mode-prompts.js\";\nimport { SUBAGENT_DEPTH_ENV } from \"../../core/subagent-depth.js\";\nimport { EMBEDDED_PROMPTS } from \"../../init-templates.generated.js\";\nimport { mergeSearchPaths, readConfig, readMergedConfig, writeConfig } from \"./config.js\";\nimport { AUTO_LOOP_DONE_TOKEN, LOOP_AUTO_CHANGED, LOOP_AUTO_START, type LoopAutoStartPayload } from \"./loop.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/** Outcome of reading the first usable plan file out of a candidate list. */\ntype PlanLoadResult =\n\t| { status: \"loaded\"; sections: PlanSections }\n\t| { status: \"missing\" }\n\t| { status: \"error\"; path: string };\n\n/**\n * Reads the first candidate plan file that exists and is non-empty, parsed into\n * sections. Missing and blank files fall through to the next candidate; an\n * unreadable one stops the walk so the caller can report the specific path.\n */\nfunction loadPlanSections(candidatePaths: string[]): PlanLoadResult {\n\tfor (const planPath of candidatePaths) {\n\t\tif (!existsSync(planPath)) continue;\n\t\ttry {\n\t\t\tconst raw = readFileSync(planPath, \"utf8\").trim();\n\t\t\tif (raw) return { status: \"loaded\", sections: parsePlanSections(raw) };\n\t\t} catch {\n\t\t\treturn { status: \"error\", path: planPath };\n\t\t}\n\t}\n\treturn { status: \"missing\" };\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// Grill: plan interrogation\n// ============================================================================\n\n/** Which half (or both) of the grill flow to run. */\nexport type GrillTarget = \"both\" | \"me\" | \"plan\";\n\n/**\n * Parses the `/grill` argument into a target.\n *\n * Bare `/grill` runs both phases, `/grill me` only questions the user, and\n * `/grill plan` only critiques the plan. Anything else returns undefined so the\n * caller shows usage rather than guessing at intent.\n */\nexport function parseGrillTarget(args: string): GrillTarget | undefined {\n\tconst arg = args.trim().toLowerCase();\n\tif (!arg) return \"both\";\n\tif (arg === \"me\" || arg === \"plan\") return arg;\n\treturn undefined;\n}\n\n/**\n * Renders the parsed plan back into the message so the agent reviews a concrete\n * artifact rather than whatever it remembers writing. Falls back to the raw plan\n * text when no sections were recognised.\n */\nfunction renderPlanForReview(sections: PlanSections): string {\n\tconst parts: string[] = [];\n\tif (sections.goal) parts.push(`**Goal**\\n${sections.goal}`);\n\tif (sections.filesToModify) parts.push(`**Files to modify**\\n${sections.filesToModify}`);\n\tif (sections.newFiles) parts.push(`**New files**\\n${sections.newFiles}`);\n\tif (sections.tests) parts.push(`**Tests**\\n${sections.tests}`);\n\tif (sections.verification) parts.push(`**Verification**\\n${sections.verification}`);\n\treturn parts.length > 0 ? parts.join(\"\\n\\n\") : sections.raw;\n}\n\n/**\n * The `/grill` phases, read from the shipped prompt templates.\n *\n * These were two ~200-word string constants in this file. They are prose the\n * runtime injects verbatim - no interpolation, no branching, nothing a compiler\n * checks - so a `.md` under `templates/prompts/` is their right home, and the\n * existing build-time embed carries them in. Editing a prompt is now editing\n * prose rather than editing TypeScript that happens to contain prose.\n *\n * Each is trimmed: the files end with a newline and the phases are joined with\n * explicit separators below.\n */\nfunction grillPrompt(name: \"grill-me\" | \"grill-plan\" | \"grill-bridge\"): string {\n\treturn (EMBEDDED_PROMPTS[name] ?? \"\").trim();\n}\n\n/**\n * Builds the follow-up message injected by `/grill`.\n *\n * The two phases are ordered rather than alternative: underspecification sits\n * upstream of plan weakness, so critiquing a plan built on a misread request\n * produces a well-reviewed plan for the wrong job. Bare `/grill` therefore asks\n * first and folds the answers into the critique.\n */\nexport function buildGrillMessage(sections: PlanSections, target: GrillTarget): string {\n\tconst plan = renderPlanForReview(sections);\n\tconst me = grillPrompt(\"grill-me\");\n\tconst critique = grillPrompt(\"grill-plan\");\n\tif (target === \"me\") return `${me}\\n\\n---\\n\\n${plan}`;\n\tif (target === \"plan\") return `${critique}\\n\\n---\\n\\n${plan}`;\n\treturn `${me}\\n\\n${grillPrompt(\"grill-bridge\")}\\n\\n${critique}\\n\\n---\\n\\n${plan}`;\n}\n\n// ============================================================================\n// Goal: autonomous execution toward a completion condition\n// ============================================================================\n\n/** A parsed `/goal` invocation. */\nexport interface GoalInvocation {\n\t/** Explicit objective; falls back to the plan's Goal section when absent. */\n\tobjective?: string;\n\t/** Turn budget passed through to the autonomous loop. */\n\tmaxTurns?: number;\n}\n\n/**\n * Parses `/goal [--max-turns N] [objective]`.\n *\n * Returns undefined when `--max-turns` is present but not followed by a\n * non-negative integer, so the caller shows usage instead of silently running\n * with the default budget.\n */\nexport function parseGoalArgs(args: string): GoalInvocation | undefined {\n\tlet rest = args.trim();\n\tlet maxTurns: number | undefined;\n\n\tconst flag = /^--max-turns(?:\\s+(\\S+))?([\\s\\S]*)$/.exec(rest);\n\tif (flag) {\n\t\tconst raw = flag[1];\n\t\tif (!raw || !/^\\d+$/.test(raw)) return undefined;\n\t\tmaxTurns = Number(raw);\n\t\trest = flag[2].trim();\n\t}\n\n\treturn { objective: rest || undefined, maxTurns };\n}\n\n/** The two messages `/goal` hands to the autonomous loop. */\nexport interface GoalMessages {\n\t/** Delivered once, to start the run. */\n\ttask: string;\n\t/** Re-sent on each continuation to keep the target in view. */\n\tcontinuePrompt: string;\n}\n\n/**\n * Builds the messages for a `/goal` run.\n *\n * When the plan carries a Verification section it becomes the completion\n * condition, which is the difference between a loop that stops when it feels\n * finished and one that stops when something it can run says so. Both messages\n * restate the objective, because the loop's continuation prompt is all that\n * holds the target in place as the transcript grows.\n */\nexport function buildGoalMessages(objective: string, verification?: string): GoalMessages {\n\tconst condition = verification\n\t\t? `\\n\\nThe goal counts as complete only once this verification passes. Run it, and if it fails, fix what it reports and run it again:\\n\\n${verification}`\n\t\t: \"\";\n\n\treturn {\n\t\ttask: `Work autonomously toward this goal:\\n\\n${objective}${condition}`,\n\t\tcontinuePrompt:\n\t\t\t`Keep working toward the goal:\\n\\n${objective}${condition}\\n\\n` +\n\t\t\t`Reply with ${AUTO_LOOP_DONE_TOKEN} only when it is genuinely complete — not when it is merely close.`,\n\t};\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// `/grill me` asks the user a question via ask_options, which an unattended\n\t// `/loop auto` run has nobody to answer. Track the loop's broadcast state so\n\t// the command can drop its interrogation phase instead of stalling the run.\n\tlet autoLoopActive = false;\n\tpi.events.on(LOOP_AUTO_CHANGED, (data) => {\n\t\tautoLoopActive = (data as { active?: boolean } | undefined)?.active === true;\n\t});\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// Light mode strips every fixed prompt appendix. Leaving\n\t\t// cachedSystemPrompt unset makes before_agent_start a no-op, so no\n\t\t// `<!-- hoo-core: mode= -->` block is appended.\n\t\tif (isLightModeEnv()) {\n\t\t\treturn;\n\t\t}\n\n\t\t// A spawned subagent already carries its own system prompt (from its agent\n\t\t// definition) and its own tool allowlist. Appending the parent's mode prompt\n\t\t// on top contradicts it — a read-only `explore` child would be told to \"read\n\t\t// before editing\" and \"run tests after every change\" — and spends tokens on\n\t\t// rules for tools the child does not have. The pool sets this on every child.\n\t\tif (process.env[SUBAGENT_DEPTH_ENV]) {\n\t\t\treturn;\n\t\t}\n\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// ── /grill command ────────────────────────────────────────────────────────\n\t// Stress-tests the current plan in two phases: `me` surfaces the request's\n\t// unconfirmed assumptions as ask_options questions, `plan` critiques the plan\n\t// file itself. Unlike /approve this only injects a follow-up message — no\n\t// session switch, no mode change, no config write.\n\n\tpi.registerCommand(\"grill\", {\n\t\tdescription: \"Stress-test the current plan. Usage: /grill [me|plan]\",\n\t\tgetArgumentCompletions: (prefix: string) =>\n\t\t\t[\"me\", \"plan\"].filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s })),\n\t\thandler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst requested = parseGrillTarget(args);\n\t\t\tif (!requested) {\n\t\t\t\tctx.ui.notify(\"Usage: /grill [me|plan]\", \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Same lookup as /approve: per-session plan first, legacy file second.\n\t\t\tconst sessionPlanPath = cachedPlanPath ?? getPlanPath(ctx.cwd, ctx.sessionManager.getSessionId());\n\t\t\tconst loaded = loadPlanSections([sessionPlanPath, getLegacyPlanPath(ctx.cwd)]);\n\t\t\tif (loaded.status === \"error\") {\n\t\t\t\tctx.ui.notify(`Could not read ${relative(ctx.cwd, loaded.path) || loaded.path}`, \"error\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (loaded.status === \"missing\") {\n\t\t\t\tconst relPlan = relative(ctx.cwd, sessionPlanPath) || sessionPlanPath;\n\t\t\t\tctx.ui.notify(`No plan to grill — ${relPlan} not found. Run /plan first.`, \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Nobody is present to answer during an autonomous loop, so keep the\n\t\t\t// half of the flow that still works rather than blocking on a question.\n\t\t\tlet target = requested;\n\t\t\tif (autoLoopActive && target !== \"plan\") {\n\t\t\t\ttarget = \"plan\";\n\t\t\t\tctx.ui.notify(\"Autonomous loop active — grilling the plan only, skipping questions.\", \"info\");\n\t\t\t}\n\n\t\t\tpi.sendUserMessage(buildGrillMessage(loaded.sections, target), { deliverAs: \"followUp\" });\n\t\t},\n\t});\n\n\t// ── /goal command ─────────────────────────────────────────────────────────\n\t// Works autonomously toward a completion condition by driving the loop in\n\t// loop.ts over the LOOP_AUTO_START channel. Linear by design: a single thread\n\t// of execution that iterates until it reports done or exhausts its budget —\n\t// there is no task graph here, and no subtask fan-out.\n\t//\n\t// The run inherits whatever the active mode already permits (enabled_tools,\n\t// allowed_bash_commands, allowed_write_paths, auto_allow), so how much rope\n\t// an unattended goal gets is a mode-config decision, not one made here.\n\n\tpi.registerCommand(\"goal\", {\n\t\tdescription: \"Work autonomously toward a goal. Usage: /goal [--max-turns N] [objective]\",\n\t\tgetArgumentCompletions: () => [],\n\t\thandler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst parsed = parseGoalArgs(args);\n\t\t\tif (!parsed) {\n\t\t\t\tctx.ui.notify(\"Usage: /goal [--max-turns N] [objective]\", \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// The plan supplies the objective when none was typed, and its\n\t\t\t// verification section becomes the completion condition either way.\n\t\t\tconst sessionPlanPath = cachedPlanPath ?? getPlanPath(ctx.cwd, ctx.sessionManager.getSessionId());\n\t\t\tconst loaded = loadPlanSections([sessionPlanPath, getLegacyPlanPath(ctx.cwd)]);\n\t\t\tif (loaded.status === \"error\") {\n\t\t\t\tctx.ui.notify(`Could not read ${relative(ctx.cwd, loaded.path) || loaded.path}`, \"error\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst sections = loaded.status === \"loaded\" ? loaded.sections : undefined;\n\n\t\t\tconst objective = parsed.objective ?? sections?.goal;\n\t\t\tif (!objective) {\n\t\t\t\tconst relPlan = relative(ctx.cwd, sessionPlanPath) || sessionPlanPath;\n\t\t\t\tctx.ui.notify(\n\t\t\t\t\t`No objective — pass one as /goal <objective>, or write a Goal section in ${relPlan}.`,\n\t\t\t\t\t\"warning\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst { task, continuePrompt } = buildGoalMessages(objective, sections?.verification);\n\t\t\t// The loop announces itself on start, so nothing to notify here.\n\t\t\tpi.events.emit(LOOP_AUTO_START, {\n\t\t\t\ttask,\n\t\t\t\tmaxTurns: parsed.maxTurns,\n\t\t\t\tcontinuePrompt,\n\t\t\t} satisfies LoopAutoStartPayload);\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 loaded = loadPlanSections([sessionPlanPath, getLegacyPlanPath(ctx.cwd)]);\n\t\t\tif (loaded.status === \"error\") {\n\t\t\t\tctx.ui.notify(`Could not read ${relative(ctx.cwd, loaded.path) || loaded.path}`, \"error\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst approveMessage = loaded.status === \"loaded\" ? buildApproveMessage(loaded.sections) : undefined;\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"]}
|
|
@@ -14,6 +14,7 @@ import { join, relative } from "node:path";
|
|
|
14
14
|
import { getHooCodeDir } from "../../config.js";
|
|
15
15
|
import { isLightModeEnv } from "../../core/light.js";
|
|
16
16
|
import { DEFAULT_MODE, DEFAULT_MODE_PROMPTS as MODE_DEFAULTS } from "../../core/mode-prompts.js";
|
|
17
|
+
import { SUBAGENT_DEPTH_ENV } from "../../core/subagent-depth.js";
|
|
17
18
|
import { EMBEDDED_PROMPTS } from "../../init-templates.generated.js";
|
|
18
19
|
import { mergeSearchPaths, readConfig, readMergedConfig, writeConfig } from "./config.js";
|
|
19
20
|
import { AUTO_LOOP_DONE_TOKEN, LOOP_AUTO_CHANGED, LOOP_AUTO_START } from "./loop.js";
|
|
@@ -292,6 +293,14 @@ export function setupMode(pi) {
|
|
|
292
293
|
if (isLightModeEnv()) {
|
|
293
294
|
return;
|
|
294
295
|
}
|
|
296
|
+
// A spawned subagent already carries its own system prompt (from its agent
|
|
297
|
+
// definition) and its own tool allowlist. Appending the parent's mode prompt
|
|
298
|
+
// on top contradicts it — a read-only `explore` child would be told to "read
|
|
299
|
+
// before editing" and "run tests after every change" — and spends tokens on
|
|
300
|
+
// rules for tools the child does not have. The pool sets this on every child.
|
|
301
|
+
if (process.env[SUBAGENT_DEPTH_ENV]) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
295
304
|
// Steps 1–3: merge global + project configs
|
|
296
305
|
const config = readMergedConfig(ctx.cwd);
|
|
297
306
|
// Step 4: resolve mode from the merged config
|