@pugi/cli 0.1.0-beta.8 → 0.1.0-beta.87
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 +96 -0
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/assets/pugi-prozr2-mascot.ansi +9 -0
- package/bin/run.js +33 -1
- package/dist/commands/deploy.js +40 -40
- package/dist/commands/flatten.js +191 -0
- package/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +42 -27
- package/dist/commands/smoke.js +133 -0
- package/dist/core/agent-progress/cleanup.js +134 -0
- package/dist/core/agent-progress/schema.js +144 -0
- package/dist/core/agent-progress/writer.js +101 -0
- package/dist/core/agents/adaptive-router.js +330 -0
- package/dist/core/agents/query-decomposer.js +297 -0
- package/dist/core/agents/registry.js +2 -2
- package/dist/core/approvals/shortcut-resolver.js +98 -0
- package/dist/core/artifact-chain/dispatcher.js +148 -0
- package/dist/core/artifact-chain/exporter.js +164 -0
- package/dist/core/artifact-chain/state.js +243 -0
- package/dist/core/artifact-chain/steps.js +169 -0
- package/dist/core/ask-user/question.js +92 -0
- package/dist/core/audit/audit-trail.js +275 -0
- package/dist/core/auth/ensure-authenticated.js +129 -0
- package/dist/core/auth/env-provider.js +238 -0
- package/dist/core/auto-open-browser.js +4 -4
- package/dist/core/auto-update/channels.js +122 -0
- package/dist/core/auto-update/checker.js +241 -0
- package/dist/core/auto-update/state.js +235 -0
- package/dist/core/bare-mode/index.js +107 -0
- package/dist/core/bash/redirect.js +281 -0
- package/dist/core/bash-classifier.js +436 -40
- package/dist/core/checkpoint/resumer.js +149 -0
- package/dist/core/checkpoint/rewinder.js +291 -0
- package/dist/core/checkpoints/shadow-git.js +670 -0
- package/dist/core/citations/parser.js +109 -0
- package/dist/core/classifier/yolo-classifier.js +88 -0
- package/dist/core/codegraph/decision-store.js +248 -0
- package/dist/core/codegraph/detect-repo.js +459 -0
- package/dist/core/codegraph/install.js +134 -0
- package/dist/core/codegraph/offer-hook.js +220 -0
- package/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +208 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/consensus/anvil-fanout.js +25 -25
- package/dist/core/consensus/diff-capture.js +121 -12
- package/dist/core/consensus/rubric.js +21 -21
- package/dist/core/context/builder.js +6 -6
- package/dist/core/context/compaction-events.js +8 -8
- package/dist/core/context/compaction.js +31 -31
- package/dist/core/context/index.js +15 -8
- package/dist/core/context/invariants.js +51 -51
- package/dist/core/context/markdown-loader.js +28 -10
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/context/pugiignore.js +41 -41
- package/dist/core/context/repo-skeleton.js +37 -37
- package/dist/core/context/tool-eviction.js +55 -0
- package/dist/core/context/watcher.js +32 -32
- package/dist/core/context/working-set.js +23 -23
- package/dist/core/coordinator/agent-tools.js +77 -0
- package/dist/core/coordinator/agent-toolset.js +65 -0
- package/dist/core/coordinator/fsm.js +73 -0
- package/dist/core/coordinator/mode-fsm.js +70 -0
- package/dist/core/cost/rate-card.js +129 -0
- package/dist/core/cost/tracker.js +221 -0
- package/dist/core/credentials.js +12 -12
- package/dist/core/cron/scheduler.js +138 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +93 -0
- package/dist/core/diagnostics/probes/bare-mode.js +42 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/engine-live.js +46 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/hooks.js +118 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/pugi-md.js +89 -0
- package/dist/core/diagnostics/probes/sandbox.js +40 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/dispatch/cache-cleanup.js +197 -0
- package/dist/core/dispatch/cache-handoff.js +295 -0
- package/dist/core/edits/apply-patch-layer-e.js +189 -0
- package/dist/core/edits/dispatch.js +293 -7
- package/dist/core/edits/format-matrix.js +26 -0
- package/dist/core/edits/fuzzy-ladder.js +650 -0
- package/dist/core/edits/index.js +3 -1
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-a-apply.js +15 -15
- package/dist/core/edits/layer-a-fuzzy-apply.js +198 -0
- package/dist/core/edits/layer-b-apply.js +9 -9
- package/dist/core/edits/layer-c-apply.js +6 -6
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/marker-parser.js +12 -12
- package/dist/core/edits/security-gate.js +27 -27
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/edits/worktree.js +322 -0
- package/dist/core/engine/anvil-client.js +140 -26
- package/dist/core/engine/auto-compact.js +179 -0
- package/dist/core/engine/budgets.js +186 -0
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/index.js +1 -1
- package/dist/core/engine/intensity.js +158 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +1295 -227
- package/dist/core/engine/prompts.js +134 -16
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +1295 -59
- package/dist/core/evaluation/golden-dataset.js +293 -0
- package/dist/core/feedback/queue.js +177 -0
- package/dist/core/feedback/submitter.js +145 -0
- package/dist/core/file-cache.js +113 -1
- package/dist/core/flatten/flatten-repo.js +439 -0
- package/dist/core/format/osc8-link.js +28 -0
- package/dist/core/hook-chains.js +392 -0
- package/dist/core/hooks/citation-verify-hook.js +138 -0
- package/dist/core/hooks/citation-verify.js +112 -0
- package/dist/core/hooks/events.js +44 -0
- package/dist/core/hooks/index.js +15 -0
- package/dist/core/hooks/registry.js +213 -0
- package/dist/core/hooks/runner.js +236 -0
- package/dist/core/hooks/v2/event-emitter.js +115 -0
- package/dist/core/hooks/v2/executor.js +282 -0
- package/dist/core/hooks/v2/index.js +25 -0
- package/dist/core/hooks/v2/lifecycle.js +104 -0
- package/dist/core/hooks/v2/loader.js +216 -0
- package/dist/core/hooks/v2/matcher.js +125 -0
- package/dist/core/hooks/v2/trust.js +143 -0
- package/dist/core/hooks/v2/types.js +86 -0
- package/dist/core/image/renderer.js +71 -0
- package/dist/core/init/detector.js +582 -0
- package/dist/core/init/template-renderer.js +242 -0
- package/dist/core/jobs/registry.js +18 -18
- package/dist/core/ledger/results-tsv.js +142 -0
- package/dist/core/log-discipline/stdout-redirect.js +51 -0
- package/dist/core/lsp/cache.js +105 -0
- package/dist/core/lsp/client.js +776 -0
- package/dist/core/lsp/language-detect.js +66 -0
- package/dist/core/lsp/post-edit-diagnostics.js +171 -0
- package/dist/core/lsp/symbol-tools.js +372 -0
- package/dist/core/mcp/client.js +97 -28
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/orchestrator-tools.js +662 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/registry.js +39 -17
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/mcp/trust.js +10 -10
- package/dist/core/memory/dual-write.js +416 -0
- package/dist/core/memory/passive-extract.js +130 -0
- package/dist/core/memory/phase1-kinds.js +20 -0
- package/dist/core/memory/secret-scanner.js +304 -0
- package/dist/core/memory-sync/queue.js +170 -0
- package/dist/core/metrics/extract.js +113 -0
- package/dist/core/modes/roo-modes.js +68 -0
- package/dist/core/onboarding/ensure-initialized.js +133 -0
- package/dist/core/onboarding/marker.js +111 -0
- package/dist/core/onboarding/telemetry-state.js +108 -0
- package/dist/core/output-style/presets.js +176 -0
- package/dist/core/output-style/state.js +185 -0
- package/dist/core/path-security.js +287 -5
- package/dist/core/permission.js +82 -22
- package/dist/core/permissions/auto-classifier.js +124 -0
- package/dist/core/permissions/bash-parser.js +371 -0
- package/dist/core/permissions/circuit-breaker.js +83 -0
- package/dist/core/permissions/constrained-edit.js +91 -0
- package/dist/core/permissions/gate.js +278 -0
- package/dist/core/permissions/index.js +20 -0
- package/dist/core/permissions/mode.js +174 -0
- package/dist/core/permissions/network-egress.js +137 -0
- package/dist/core/permissions/state.js +241 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/plan-mode/ui-state.js +51 -0
- package/dist/core/plans/plan-artifact.js +721 -0
- package/dist/core/policy-limits/etag-store.js +122 -0
- package/dist/core/prd-check/parser.js +215 -0
- package/dist/core/prd-check/reporter.js +127 -0
- package/dist/core/prd-check/session-review.js +557 -0
- package/dist/core/prd-check/verifiers.js +223 -0
- package/dist/core/prompt-cache/client-cache.js +99 -0
- package/dist/core/prompts/assembly.js +29 -0
- package/dist/core/prompts/registry.js +364 -0
- package/dist/core/pugi-md/cc-compat-rules.js +735 -0
- package/dist/core/pugi-md/context-injector.js +76 -0
- package/dist/core/pugi-md/walk-up.js +207 -0
- package/dist/core/python/uv-installer.js +270 -0
- package/dist/core/python/uv-resolver.js +83 -0
- package/dist/core/rate-limit/narrator.js +146 -0
- package/dist/core/recipes/cli-types.js +20 -0
- package/dist/core/recipes/loader.js +103 -0
- package/dist/core/recipes/runner.js +345 -0
- package/dist/core/recipes/schema.js +587 -0
- package/dist/core/release-notes/parser.js +241 -0
- package/dist/core/release-notes/state.js +116 -0
- package/dist/core/repl/ask.js +37 -37
- package/dist/core/repl/cancellation.js +26 -26
- package/dist/core/repl/cap-warning.js +4 -4
- package/dist/core/repl/clipboard-read.js +11 -11
- package/dist/core/repl/dispatch-fsm.js +12 -12
- package/dist/core/repl/history-search.js +15 -15
- package/dist/core/repl/history.js +28 -18
- package/dist/core/repl/kill-ring.js +5 -5
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/privacy-banner.js +22 -22
- package/dist/core/repl/session.js +2157 -214
- package/dist/core/repl/slash-commands.js +533 -40
- package/dist/core/repl/store/index.js +1 -1
- package/dist/core/repl/store/jsonl-log.js +22 -22
- package/dist/core/repl/store/lockfile.js +10 -10
- package/dist/core/repl/store/session-store.js +136 -107
- package/dist/core/repl/store/types.js +15 -15
- package/dist/core/repl/store/uuid-v7.js +12 -12
- package/dist/core/repl/workspace-context.js +43 -21
- package/dist/core/repo-map/build.js +125 -0
- package/dist/core/repo-map/cache.js +185 -0
- package/dist/core/repo-map/extractor.js +254 -0
- package/dist/core/repo-map/formatter.js +145 -0
- package/dist/core/repo-map/page-rank.js +105 -0
- package/dist/core/repo-map/scanner.js +211 -0
- package/dist/core/retry-budget/budget.js +284 -0
- package/dist/core/retry-budget/index.js +5 -0
- package/dist/core/retry-budget/retry-cap.js +74 -0
- package/dist/core/routing/lead-worker.js +43 -0
- package/dist/core/routing/pre-flight-estimator.js +108 -0
- package/dist/core/runs/run-tree.js +103 -0
- package/dist/core/security/injection-scanner.js +367 -0
- package/dist/core/security/output-filter.js +418 -0
- package/dist/core/session/env-file.js +105 -0
- package/dist/core/session/section-budgets.js +140 -0
- package/dist/core/session.js +92 -0
- package/dist/core/settings.js +286 -5
- package/dist/core/share/formatter.js +271 -0
- package/dist/core/share/redactor.js +221 -0
- package/dist/core/share/uploader.js +267 -0
- package/dist/core/skills/defaults.js +457 -0
- package/dist/core/skills/loader.js +22 -22
- package/dist/core/skills/sources.js +27 -27
- package/dist/core/smoke/headless-driver.js +174 -0
- package/dist/core/smoke/orchestrator.js +194 -0
- package/dist/core/smoke/runner.js +238 -0
- package/dist/core/smoke/scenario-parser.js +316 -0
- package/dist/core/statusline.js +99 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +132 -43
- package/dist/core/subagents/index.js +19 -6
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/telemetry/emitter.js +229 -0
- package/dist/core/telemetry/queue.js +251 -0
- package/dist/core/theme/context.js +91 -0
- package/dist/core/theme/presets.js +228 -0
- package/dist/core/theme/state.js +181 -0
- package/dist/core/todos/invariant.js +10 -0
- package/dist/core/todos/state.js +177 -0
- package/dist/core/tool-schema/compressor.js +89 -0
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/core/trust.js +2 -2
- package/dist/core/tui/thinking-block.js +64 -0
- package/dist/core/vim/keymap.js +288 -0
- package/dist/core/vim/state.js +92 -0
- package/dist/core/watch-markers/marker-watcher.js +133 -0
- package/dist/core/worktree-manager/cleanup.js +123 -0
- package/dist/core/worktree-manager/manager.js +303 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +4151 -489
- package/dist/runtime/commands/agents.js +30 -30
- package/dist/runtime/commands/budget.js +5 -5
- package/dist/runtime/commands/cancel.js +231 -0
- package/dist/runtime/commands/chain.js +489 -0
- package/dist/runtime/commands/codegraph-status.js +227 -0
- package/dist/runtime/commands/compact.js +297 -0
- package/dist/runtime/commands/config.js +32 -32
- package/dist/runtime/commands/cost.js +199 -0
- package/dist/runtime/commands/delegate.js +244 -13
- package/dist/runtime/commands/dispatch.js +126 -0
- package/dist/runtime/commands/doctor.js +579 -0
- package/dist/runtime/commands/feedback.js +184 -0
- package/dist/runtime/commands/hooks.js +184 -0
- package/dist/runtime/commands/init.js +254 -0
- package/dist/runtime/commands/lsp.js +368 -0
- package/dist/runtime/commands/mcp.js +879 -0
- package/dist/runtime/commands/memory.js +582 -0
- package/dist/runtime/commands/model.js +237 -0
- package/dist/runtime/commands/onboarding.js +275 -0
- package/dist/runtime/commands/patch.js +128 -0
- package/dist/runtime/commands/permissions.js +112 -0
- package/dist/runtime/commands/plan.js +143 -0
- package/dist/runtime/commands/prd-check.js +285 -0
- package/dist/runtime/commands/privacy.js +17 -17
- package/dist/runtime/commands/recipe.js +325 -0
- package/dist/runtime/commands/redo-blob-store.js +92 -0
- package/dist/runtime/commands/redo.js +361 -0
- package/dist/runtime/commands/release-notes.js +229 -0
- package/dist/runtime/commands/repo-map.js +95 -0
- package/dist/runtime/commands/report.js +299 -0
- package/dist/runtime/commands/resume.js +118 -0
- package/dist/runtime/commands/review-consensus.js +68 -53
- package/dist/runtime/commands/rewind.js +333 -0
- package/dist/runtime/commands/roster.js +14 -14
- package/dist/runtime/commands/sessions.js +163 -0
- package/dist/runtime/commands/share.js +316 -0
- package/dist/runtime/commands/skills.js +31 -31
- package/dist/runtime/commands/status.js +186 -0
- package/dist/runtime/commands/stickers.js +82 -0
- package/dist/runtime/commands/style.js +194 -0
- package/dist/runtime/commands/theme.js +196 -0
- package/dist/runtime/commands/undo.js +54 -22
- package/dist/runtime/commands/update.js +289 -0
- package/dist/runtime/commands/vim.js +140 -0
- package/dist/runtime/commands/worktree.js +177 -0
- package/dist/runtime/commands/worktrees.js +155 -0
- package/dist/runtime/headless-repl.js +195 -0
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/plan-decompose.js +531 -0
- package/dist/runtime/update-check.js +28 -28
- package/dist/runtime/version.js +65 -0
- package/dist/skills/bundled/batch.js +617 -0
- package/dist/skills/bundled/index.js +45 -0
- package/dist/skills/bundled/loop.js +358 -0
- package/dist/skills/bundled/remember.js +383 -0
- package/dist/skills/bundled/simplify.js +289 -0
- package/dist/skills/bundled/skillify.js +373 -0
- package/dist/skills/bundled/stuck.js +558 -0
- package/dist/skills/bundled/verify.js +439 -0
- package/dist/testing/vcr.js +486 -0
- package/dist/tools/agent-tool.js +229 -0
- package/dist/tools/apply-patch.js +556 -0
- package/dist/tools/ask-user-question.js +222 -0
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/bash.js +623 -45
- package/dist/tools/brief.js +224 -0
- package/dist/tools/enter-worktree.js +250 -0
- package/dist/tools/exit-worktree.js +147 -0
- package/dist/tools/file-tools.js +161 -44
- package/dist/tools/lsp-tools.js +189 -0
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/powershell.js +268 -0
- package/dist/tools/registry.js +85 -0
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/sleep.js +99 -0
- package/dist/tools/synthetic-output.js +133 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tools/todo-write.js +184 -0
- package/dist/tools/verify-plan-execution.js +295 -0
- package/dist/tools/web-fetch-injection-scanner.js +207 -0
- package/dist/tools/web-fetch.js +195 -10
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/agent-tree.js +11 -1
- package/dist/tui/ask-modal.js +14 -14
- package/dist/tui/ask-user-question-prompt.js +203 -0
- package/dist/tui/compact-banner.js +81 -0
- package/dist/tui/conversation-pane.js +85 -11
- package/dist/tui/cost-table.js +111 -0
- package/dist/tui/device-flow.js +2 -2
- package/dist/tui/doctor-table.js +46 -0
- package/dist/tui/feedback-prompt.js +156 -0
- package/dist/tui/input-box.js +247 -32
- package/dist/tui/login-picker.js +3 -3
- package/dist/tui/markdown-render.js +6 -6
- package/dist/tui/onboarding-wizard.js +240 -0
- package/dist/tui/permissions-picker.js +86 -0
- package/dist/tui/render.js +35 -0
- package/dist/tui/repl-render.js +332 -54
- package/dist/tui/repl-splash-art.js +16 -16
- package/dist/tui/repl-splash-mascot.js +48 -24
- package/dist/tui/repl-splash.js +22 -22
- package/dist/tui/repl.js +124 -44
- package/dist/tui/slash-palette.js +6 -6
- package/dist/tui/splash.js +2 -2
- package/dist/tui/status-bar.js +109 -31
- package/dist/tui/status-table.js +7 -0
- package/dist/tui/stickers-art.js +136 -0
- package/dist/tui/style-table.js +28 -0
- package/dist/tui/theme-table.js +29 -0
- package/dist/tui/thinking-spinner.js +123 -0
- package/dist/tui/tool-stream-pane.js +53 -4
- package/dist/tui/update-banner.js +27 -2
- package/dist/tui/vim-input.js +267 -0
- package/dist/tui/welcome-banner.js +107 -0
- package/dist/tui/welcome-data.js +293 -0
- package/dist/tui/workspace-context.js +2 -2
- package/docs/examples/codegraph.mcp.json +10 -0
- package/package.json +23 -6
- package/test/scenarios/codegen-create-file.scenario.txt +13 -0
- package/test/scenarios/compact-force.scenario.txt +11 -0
- package/test/scenarios/identity.scenario.txt +11 -0
- package/test/scenarios/persona-handoff.scenario.txt +11 -0
- package/test/scenarios/walkback.scenario.txt +12 -0
- package/dist/core/engine/compaction-hook.js +0 -154
|
@@ -1,20 +1,47 @@
|
|
|
1
|
-
import { editTool, globTool, grepTool, OperatorAbortedError, readTool, writeTool, } from '../../tools/file-tools.js';
|
|
1
|
+
import { editTool, globTool, grepTool, OperatorAbortedError, readTool, StaleReadError, writeTool, } from '../../tools/file-tools.js';
|
|
2
2
|
import { bashToolSync } from '../../tools/bash.js';
|
|
3
|
+
import { powerShellToolSync } from '../../tools/powershell.js';
|
|
4
|
+
import { askUser } from '../../tools/ask-user.js';
|
|
5
|
+
import { askUserQuestionJsonSchema, dispatchAskUserQuestion, } from '../../tools/ask-user-question.js';
|
|
6
|
+
import { skillInvoke, skillList } from '../../tools/skill-tool.js';
|
|
7
|
+
import { taskCreate, taskGet, taskList, taskUpdate, } from '../../tools/tasks.js';
|
|
8
|
+
import { dispatchTodoWrite, todoWriteJsonSchema, } from '../../tools/todo-write.js';
|
|
9
|
+
// Tool gap pack : Brief/Sleep/SyntheticOutput/
|
|
10
|
+
// EnterWorktree/ExitWorktree. Each tool exports a Zod-free hand-rolled
|
|
11
|
+
// JSON-schema fragment + a sentinel-returning dispatcher, matching the
|
|
12
|
+
// `todo_write` / `ask_user_question` conventions.
|
|
13
|
+
import { briefJsonSchema, dispatchBrief } from '../../tools/brief.js';
|
|
14
|
+
import { dispatchVerifyPlanExecution, verifyPlanExecutionJsonSchema, } from '../../tools/verify-plan-execution.js';
|
|
15
|
+
import { dispatchSleep, sleepJsonSchema } from '../../tools/sleep.js';
|
|
16
|
+
import { dispatchSyntheticOutput, syntheticOutputJsonSchema, } from '../../tools/synthetic-output.js';
|
|
17
|
+
import { dispatchEnterWorktree, enterWorktreeJsonSchema, } from '../../tools/enter-worktree.js';
|
|
18
|
+
import { dispatchExitWorktree, exitWorktreeJsonSchema, } from '../../tools/exit-worktree.js';
|
|
19
|
+
import { webFetchTool } from '../../tools/web-fetch.js';
|
|
20
|
+
import { webSearchTool } from '../../tools/web-search.js';
|
|
21
|
+
import { agentTool } from '../../tools/agent-tool.js';
|
|
22
|
+
import { multiEdit } from '../../tools/multi-edit.js';
|
|
23
|
+
import { buildMcpToolDefs, defaultNonInteractiveMcpPrompt, dispatchMcpTool, MCP_TOOL_PREFIX, } from '../../tools/mcp-tool.js';
|
|
24
|
+
import { firePostToolUseFailureChain } from '../hook-chains.js';
|
|
25
|
+
import { buildDenialContext, DENIAL_REMINDER_THRESHOLD, } from '../denial-tracking/state.js';
|
|
26
|
+
import { stripInternalFields } from './strip-internal-fields.js';
|
|
27
|
+
import { applyAskAnswer, gate as permissionGate, getToolClass, PermissionDenied, } from '../permissions/index.js';
|
|
28
|
+
import { RetryBudget, RetryBudgetExhausted, hashArgs } from '../retry-budget/index.js';
|
|
29
|
+
import { runPostEditDiagnostics, } from '../lsp/post-edit-diagnostics.js';
|
|
3
30
|
/**
|
|
4
31
|
* Tool-bridge: turns the abstract tool registry into:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
32
|
+
* 1. An OpenAI-shaped tools schema for `EngineLoopClient.send`.
|
|
33
|
+
* 2. A single executor callback that dispatches each tool_call to the
|
|
34
|
+
* concrete `file-tools.ts` handler under workspace permissions.
|
|
8
35
|
*
|
|
9
36
|
* The bridge enforces two CLI-side invariants that the runtime cannot:
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
37
|
+
* - Plan-mode refusal. When `kind === 'plan'`, the executor refuses
|
|
38
|
+
* write/edit/bash by throwing `PLAN_MODE_REFUSED:<tool>` (sentinel
|
|
39
|
+
* recognised by `runEngineLoop` to terminate with status
|
|
40
|
+
* `tool_refused`). The schema also omits the mutating tools so the
|
|
41
|
+
* model is unlikely to attempt them in the first place.
|
|
42
|
+
* - Argument validation. Each call's `arguments` string is JSON-parsed
|
|
43
|
+
* and shape-checked here; bad JSON or missing fields are surfaced
|
|
44
|
+
* to the model as a tool error string so it can correct itself.
|
|
18
45
|
*
|
|
19
46
|
* The bridge does NOT touch session.ts directly — `file-tools.ts`
|
|
20
47
|
* already records every call. The engine adapter wires a hook layer on
|
|
@@ -23,17 +50,116 @@ import { bashToolSync } from '../../tools/bash.js';
|
|
|
23
50
|
/**
|
|
24
51
|
* Read-only subset surfaced to plan-mode. Mutating tools (write, edit,
|
|
25
52
|
* bash) are intentionally absent so the model rarely tries them.
|
|
53
|
+
*
|
|
54
|
+
* β1: task_* + skill + ask_user_question + web_fetch are all read-only
|
|
55
|
+
* from the workspace's perspective (no file writes), so they stay
|
|
56
|
+
* available in plan mode. The ledger writes for `task_*` land in
|
|
57
|
+
* `.pugi/sessions/<id>/tasks.jsonl` which is metadata, not source.
|
|
26
58
|
*/
|
|
27
|
-
const READ_ONLY_TOOLS = new Set([
|
|
59
|
+
const READ_ONLY_TOOLS = new Set([
|
|
60
|
+
'read',
|
|
61
|
+
'grep',
|
|
62
|
+
'glob',
|
|
63
|
+
'ask_user_question',
|
|
64
|
+
'skill',
|
|
65
|
+
'skills_list',
|
|
66
|
+
'task_create',
|
|
67
|
+
'task_get',
|
|
68
|
+
'task_list',
|
|
69
|
+
'task_update',
|
|
70
|
+
// `todo_write` writes to `.pugi/todos.json`
|
|
71
|
+
// — metadata, not source. From the workspace's perspective it is
|
|
72
|
+
// read-only (no source mutation), so plan-mode keeps the tool
|
|
73
|
+
// available: planning a refactor frequently means writing the
|
|
74
|
+
// todo board BEFORE picking which file to touch.
|
|
75
|
+
'todo_write',
|
|
76
|
+
// Tool gap pack : `brief` persists structured
|
|
77
|
+
// status notes to `.pugi/briefs/<session>.jsonl`. Like `todo_write`
|
|
78
|
+
// the writes land in metadata, not source, so plan mode keeps the
|
|
79
|
+
// tool available — planning frequently means emitting a "planning"
|
|
80
|
+
// brief first.
|
|
81
|
+
'brief',
|
|
82
|
+
// Backlog #5 P0 : verify_plan_execution reads session
|
|
83
|
+
// audit log (metadata, not source). Safe in plan mode — a planning
|
|
84
|
+
// loop needs to verify its plan-capture steps before any writes.
|
|
85
|
+
'verify_plan_execution',
|
|
86
|
+
// Tool gap pack : `sleep` is a no-op as far as the
|
|
87
|
+
// workspace is concerned (wall-clock delay only). Plan mode keeps it
|
|
88
|
+
// available so a planning loop can throttle its own polling.
|
|
89
|
+
'sleep',
|
|
90
|
+
'web_fetch',
|
|
91
|
+
// β1b T4 : web_search is read-only from the workspace's
|
|
92
|
+
// perspective (no file writes, no shell). Egress goes through the
|
|
93
|
+
// Anvil-proxied Brave Search API, gated by the same opt-in posture as
|
|
94
|
+
// web_fetch. Plan mode keeps the tool available because reading the
|
|
95
|
+
// web is part of how a plan is researched.
|
|
96
|
+
'web_search',
|
|
97
|
+
]);
|
|
28
98
|
/**
|
|
29
|
-
* Tools
|
|
30
|
-
* (
|
|
31
|
-
*
|
|
32
|
-
* the
|
|
99
|
+
* Tools the engine loop dispatches. β1 expands the M1 cornerstone six
|
|
100
|
+
* (read/write/edit/grep/glob/bash) with task_* + ask_user_question +
|
|
101
|
+
* skill + skill list + web_fetch. The registry advertises these slots
|
|
102
|
+
* to the runtime; without dispatcher entries the model would call
|
|
103
|
+
* "unknown tool" errors.
|
|
33
104
|
*/
|
|
34
|
-
const WIRED_TOOLS = new Set([
|
|
35
|
-
|
|
105
|
+
const WIRED_TOOLS = new Set([
|
|
106
|
+
'read',
|
|
107
|
+
'write',
|
|
108
|
+
'edit',
|
|
109
|
+
'grep',
|
|
110
|
+
'glob',
|
|
111
|
+
'bash',
|
|
112
|
+
// PowerShell tool for Windows-first workflows.
|
|
113
|
+
// Same bash permission class — destructive-pattern classifier applies.
|
|
114
|
+
// Plan mode excludes shell tools by design (read-only); the planMode
|
|
115
|
+
// check on the schema side already handles that, so we just list it
|
|
116
|
+
// alongside 'bash' here.
|
|
117
|
+
'powershell',
|
|
118
|
+
'ask_user_question',
|
|
119
|
+
'skill',
|
|
120
|
+
'skills_list',
|
|
121
|
+
'task_create',
|
|
122
|
+
'task_get',
|
|
123
|
+
'task_list',
|
|
124
|
+
'task_update',
|
|
125
|
+
// see READ_ONLY_TOOLS above for the rationale.
|
|
126
|
+
'todo_write',
|
|
127
|
+
// Tool gap pack: see READ_ONLY_TOOLS above for `brief` / `sleep`.
|
|
128
|
+
'brief',
|
|
129
|
+
// Backlog #5 P0 : verify_plan_execution anti-fake-dispatch gate.
|
|
130
|
+
'verify_plan_execution',
|
|
131
|
+
'sleep',
|
|
132
|
+
// Tool gap pack: scratch-worktree primitives. Not in
|
|
133
|
+
// READ_ONLY_TOOLS — they mutate workspace state (a new git worktree
|
|
134
|
+
// is a workspace change even though the touched subtree is
|
|
135
|
+
// `.pugi`-scoped). Plan mode excludes them just like write/edit/bash.
|
|
136
|
+
'enter_worktree',
|
|
137
|
+
'exit_worktree',
|
|
138
|
+
// Tool gap pack: experimental engine-only echo helper. Gated
|
|
139
|
+
// behind allowSyntheticOutput; the schema layer omits it unless the
|
|
140
|
+
// caller opted in, but we list the name here so a deliberately-opted
|
|
141
|
+
// executor passes the WIRED_TOOLS guard.
|
|
142
|
+
'synthetic_output',
|
|
143
|
+
'web_fetch',
|
|
144
|
+
// β1b T4: see READ_ONLY_TOOLS above.
|
|
145
|
+
'web_search',
|
|
146
|
+
// β2 S3 : real subagent spawn primitive. Only advertised
|
|
147
|
+
// when buildToolsSchema is called with allowAgent=true (orchestrator
|
|
148
|
+
// / root Pugi context); plan-mode also excludes it because spawning
|
|
149
|
+
// a write-capable child violates plan-mode's read-only contract.
|
|
150
|
+
'agent',
|
|
151
|
+
// β7 L5+T11 : transactional multi-file edit. Routes
|
|
152
|
+
// through the same security gate as Layer A/B/C; not advertised in
|
|
153
|
+
// plan mode (mutation surface).
|
|
154
|
+
'multi_edit',
|
|
155
|
+
]);
|
|
156
|
+
export function buildToolsSchema(kind, options = { allowFetch: false, allowSearch: false }) {
|
|
36
157
|
const planMode = kind === 'plan';
|
|
158
|
+
// β4 M1/M3: splice MCP tools BEFORE the native list assembly so the
|
|
159
|
+
// engine-loop sees them in stable alphabetical order alongside native
|
|
160
|
+
// tools. We keep the entries appended after the native push so plan-
|
|
161
|
+
// mode can be filtered by namespace prefix in one place at the end.
|
|
162
|
+
const mcpDefs = buildMcpToolDefs(options.mcpRegistry);
|
|
37
163
|
const toolDefs = [
|
|
38
164
|
{
|
|
39
165
|
name: 'read',
|
|
@@ -49,13 +175,16 @@ export function buildToolsSchema(kind) {
|
|
|
49
175
|
},
|
|
50
176
|
{
|
|
51
177
|
name: 'grep',
|
|
52
|
-
description: 'Substring-match every workspace file. Returns up to 200 matches with {path, line, text}.
|
|
178
|
+
description: 'Substring-match every workspace file. Returns up to 200 matches with {path, line, text}. Canonical arg: `query`. Aliases accepted: text, pattern, q, search.',
|
|
53
179
|
parameters: {
|
|
54
180
|
type: 'object',
|
|
55
|
-
additionalProperties:
|
|
56
|
-
required: ['query'],
|
|
181
|
+
additionalProperties: true,
|
|
57
182
|
properties: {
|
|
58
|
-
query: { type: 'string', description: 'Substring to search for.' },
|
|
183
|
+
query: { type: 'string', description: 'Substring to search for (canonical).' },
|
|
184
|
+
text: { type: 'string', description: 'Alias for query.' },
|
|
185
|
+
pattern: { type: 'string', description: 'Alias for query.' },
|
|
186
|
+
q: { type: 'string', description: 'Alias for query.' },
|
|
187
|
+
search: { type: 'string', description: 'Alias for query.' },
|
|
59
188
|
},
|
|
60
189
|
},
|
|
61
190
|
},
|
|
@@ -72,10 +201,286 @@ export function buildToolsSchema(kind) {
|
|
|
72
201
|
},
|
|
73
202
|
},
|
|
74
203
|
];
|
|
204
|
+
// β1 T1/T6: TodoWrite (Pugi grammar = `task_*`). Append-only ledger
|
|
205
|
+
// at `.pugi/sessions/<id>/tasks.jsonl`.
|
|
206
|
+
toolDefs.push({
|
|
207
|
+
name: 'task_create',
|
|
208
|
+
description: 'Append a new task to the session todo ledger. Returns the assigned task id and full record. Mirrors the standard tool TodoWrite/create.',
|
|
209
|
+
parameters: {
|
|
210
|
+
type: 'object',
|
|
211
|
+
additionalProperties: false,
|
|
212
|
+
required: ['title'],
|
|
213
|
+
properties: {
|
|
214
|
+
title: { type: 'string', description: 'Short imperative summary, max 2000 chars.' },
|
|
215
|
+
status: {
|
|
216
|
+
type: 'string',
|
|
217
|
+
enum: ['pending', 'in_progress', 'completed', 'cancelled'],
|
|
218
|
+
description: 'Initial status. Default pending.',
|
|
219
|
+
},
|
|
220
|
+
notes: { type: 'string', description: 'Optional free-form context.' },
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
}, {
|
|
224
|
+
name: 'task_get',
|
|
225
|
+
description: 'Fetch a single task record by id. Returns null when absent.',
|
|
226
|
+
parameters: {
|
|
227
|
+
type: 'object',
|
|
228
|
+
additionalProperties: false,
|
|
229
|
+
required: ['id'],
|
|
230
|
+
properties: { id: { type: 'string' } },
|
|
231
|
+
},
|
|
232
|
+
}, {
|
|
233
|
+
name: 'task_list',
|
|
234
|
+
description: 'List all tasks for the current session ordered by createdAt ascending.',
|
|
235
|
+
parameters: { type: 'object', additionalProperties: false, properties: {} },
|
|
236
|
+
}, {
|
|
237
|
+
name: 'task_update',
|
|
238
|
+
description: 'Mutate status/title/notes on an existing task. Throws on unknown id. Append-only journal.',
|
|
239
|
+
parameters: {
|
|
240
|
+
type: 'object',
|
|
241
|
+
additionalProperties: false,
|
|
242
|
+
required: ['id'],
|
|
243
|
+
properties: {
|
|
244
|
+
id: { type: 'string' },
|
|
245
|
+
title: { type: 'string' },
|
|
246
|
+
status: {
|
|
247
|
+
type: 'string',
|
|
248
|
+
enum: ['pending', 'in_progress', 'completed', 'cancelled'],
|
|
249
|
+
},
|
|
250
|
+
notes: { type: 'string' },
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
// `todo_write` — batch TodoWrite mirror of
|
|
255
|
+
// the upstream tool's upstream tool. Whereas `task_*` above is granular
|
|
256
|
+
// (one mutation per call, JSONL append, session-scoped),
|
|
257
|
+
// `todo_write` snapshots the FULL board in one call, JSON snapshot
|
|
258
|
+
// at `.pugi/todos.json`, workspace-scoped. Enforces the single-
|
|
259
|
+
// in-progress invariant at dispatch time: a batch with >1
|
|
260
|
+
// `in_progress` rejects with `TODO_INVARIANT_VIOLATED` and the
|
|
261
|
+
// on-disk board is left unchanged.
|
|
262
|
+
toolDefs.push({
|
|
263
|
+
name: 'todo_write',
|
|
264
|
+
description: 'Replace the workspace todo board (batch snapshot, not incremental). Emit the FULL todo list every call. ' +
|
|
265
|
+
'At most ONE item may carry status="in_progress" — violations reject with TODO_INVARIANT_VIOLATED. ' +
|
|
266
|
+
'Persisted atomically to .pugi/todos.json. Mirrors the standard tool TodoWrite verbatim.',
|
|
267
|
+
parameters: todoWriteJsonSchema,
|
|
268
|
+
});
|
|
269
|
+
// Tool gap pack : `brief` — structured operator
|
|
270
|
+
// progress note. JSONL-append к `.pugi/briefs/<session>.jsonl` via
|
|
271
|
+
// the atomic tmp+rename pattern. Plan-mode safe (metadata only, no
|
|
272
|
+
// source mutation).
|
|
273
|
+
toolDefs.push({
|
|
274
|
+
name: 'brief',
|
|
275
|
+
description: 'Emit a short structured progress note to the operator. Use INSTEAD of narrating in prose ' +
|
|
276
|
+
'when you want to surface "what I am doing now". One JSON record per call, persisted к ' +
|
|
277
|
+
'.pugi/briefs/<session>.jsonl. Required: headline (<=120 chars), status (planning|working|blocked|done). ' +
|
|
278
|
+
'Optional: detail (<=2000 chars).',
|
|
279
|
+
parameters: briefJsonSchema,
|
|
280
|
+
});
|
|
281
|
+
// Backlog #5 P0 : verify_plan_execution — anti-fake-dispatch gate.
|
|
282
|
+
// Reads the session audit log (metadata only, no source mutation). Plan-mode
|
|
283
|
+
// safe: a plan-loop frequently needs к verify its plan-capture steps before
|
|
284
|
+
// any write turn fires. Surface это as a tool the model can call right
|
|
285
|
+
// before emitting а "done" message on multi-step turns.
|
|
286
|
+
toolDefs.push({
|
|
287
|
+
name: 'verify_plan_execution',
|
|
288
|
+
description: 'Assert that every step in a previously-stated plan actually executed. ' +
|
|
289
|
+
'Reads the session audit log (tool calls + file mutations recorded this session) ' +
|
|
290
|
+
'and checks each step\'s declared requirements. Returns { status: "verified" | "gap", gaps: [...] }. ' +
|
|
291
|
+
'When status is "gap" the engine loop continues so the model can fill the missing ' +
|
|
292
|
+
'steps or explicitly explain why they were skipped. Call this BEFORE emitting а ' +
|
|
293
|
+
'final "done" message when you stated а multi-step plan at the start of the turn.',
|
|
294
|
+
parameters: verifyPlanExecutionJsonSchema,
|
|
295
|
+
});
|
|
296
|
+
// Tool gap pack : `sleep` — wall-clock pause primitive.
|
|
297
|
+
// Counts against --max-turns like any other dispatch; the model should
|
|
298
|
+
// prefer a real poll loop (read + grep + retry) over blind sleep.
|
|
299
|
+
toolDefs.push({
|
|
300
|
+
name: 'sleep',
|
|
301
|
+
description: 'Pause the engine loop for an integer number of seconds (1..600). ' +
|
|
302
|
+
'Counts against the turn budget. Prefer a real poll loop over blind sleep — ' +
|
|
303
|
+
'this tool exists only for fixed cooldowns the operator owns.',
|
|
304
|
+
parameters: sleepJsonSchema,
|
|
305
|
+
});
|
|
306
|
+
if (!planMode) {
|
|
307
|
+
// Tool gap pack : scratch-worktree primitives.
|
|
308
|
+
// `enter_worktree` materialises a fresh git worktree at
|
|
309
|
+
// `.pugi/worktrees/<taskId>/` so a long task can land its edits in
|
|
310
|
+
// isolation; `exit_worktree` is the cleanup primitive. Both are
|
|
311
|
+
// workspace mutations, so plan mode excludes them.
|
|
312
|
+
toolDefs.push({
|
|
313
|
+
name: 'enter_worktree',
|
|
314
|
+
description: 'Open a scratch git worktree at .pugi/worktrees/<taskId>/ for write-isolated work. ' +
|
|
315
|
+
'Required: taskId (lowercase slug). Optional: baseRef (defaults to main). ' +
|
|
316
|
+
'Returns { worktreePath, branchName }. Pair with exit_worktree for cleanup.',
|
|
317
|
+
parameters: enterWorktreeJsonSchema,
|
|
318
|
+
});
|
|
319
|
+
toolDefs.push({
|
|
320
|
+
name: 'exit_worktree',
|
|
321
|
+
description: 'Tear down a scratch worktree previously opened by enter_worktree. ' +
|
|
322
|
+
'The worktreePath MUST live under <workspaceRoot>/.pugi/worktrees/ — anything else refuses. ' +
|
|
323
|
+
'Runs `git worktree remove --force` then rmSync. Idempotent.',
|
|
324
|
+
parameters: exitWorktreeJsonSchema,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
// Tool gap pack : experimental engine-only echo
|
|
328
|
+
// helper. Advertised only when the caller explicitly opted in via
|
|
329
|
+
// `allowSyntheticOutput: true`. Off-by-default mirrors the privacy
|
|
330
|
+
// posture used for `allowFetch` / `allowSearch` — every customer-side
|
|
331
|
+
// CLI omits this so the model cannot use it as a side-channel that
|
|
332
|
+
// bypasses the normal tool-result logging.
|
|
333
|
+
if (options.allowSyntheticOutput) {
|
|
334
|
+
toolDefs.push({
|
|
335
|
+
name: 'synthetic_output',
|
|
336
|
+
description: 'Engine-only echo helper. Writes verbatim text to the requested stream (stdout|stderr). ' +
|
|
337
|
+
`Capped at ${(16 * 1024).toString()} bytes per call. Test fixture, not for production agent flows.`,
|
|
338
|
+
parameters: syntheticOutputJsonSchema,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
// β1 T2 → structured AskUserQuestion bridge.
|
|
342
|
+
// Schema upgraded to 's multi-choice form: header chip +
|
|
343
|
+
// {label, description} per option. Dispatcher accepts the structured
|
|
344
|
+
// form (preferred) AND the legacy string-array form so existing
|
|
345
|
+
// callers / tests keep working until the next major bump.
|
|
346
|
+
//
|
|
347
|
+
// Interactive TTY → returns the picked label(s).
|
|
348
|
+
// Non-TTY / no bridge → `[user_input_required]` envelope.
|
|
349
|
+
toolDefs.push({
|
|
350
|
+
name: 'ask_user_question',
|
|
351
|
+
description: 'Clarifying multi-choice question to the operator. Use INSTEAD of asking in prose when one parameter is missing. Required: question (?-ended), header (≤12 chars), 2-4 options each with {label, description}. NEVER include "Other" — UI auto-adds. Budget: max 1 per turn.',
|
|
352
|
+
parameters: askUserQuestionJsonSchema,
|
|
353
|
+
});
|
|
354
|
+
// β1 T3: Skill tool — discover + invoke locally-installed skills.
|
|
355
|
+
toolDefs.push({
|
|
356
|
+
name: 'skills_list',
|
|
357
|
+
description: 'List installed skills (global + workspace). Returns name+description+scope.',
|
|
358
|
+
parameters: {
|
|
359
|
+
type: 'object',
|
|
360
|
+
additionalProperties: false,
|
|
361
|
+
properties: {
|
|
362
|
+
scope: { type: 'string', enum: ['all', 'global', 'workspace'] },
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
}, {
|
|
366
|
+
name: 'skill',
|
|
367
|
+
description: 'Load a skill body by name. Workspace scope wins over global. Body capped at 32KB.',
|
|
368
|
+
parameters: {
|
|
369
|
+
type: 'object',
|
|
370
|
+
additionalProperties: false,
|
|
371
|
+
required: ['name'],
|
|
372
|
+
properties: { name: { type: 'string' } },
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
// β1 T5 → β1a r1 (gating fix): WebFetch wire-in. Schema
|
|
376
|
+
// mirrors the existing tool surface in
|
|
377
|
+
// `apps/pugi-cli/src/tools/web-fetch.ts`. SSRF guard runs inside the
|
|
378
|
+
// tool itself, but advertising the tool to the model when the tenant
|
|
379
|
+
// has not opted in is itself a privacy leak — the model could infer
|
|
380
|
+
// URL patterns and try to exfiltrate via the refused call's argument
|
|
381
|
+
// bytes. Only push the schema entry when the operator has explicitly
|
|
382
|
+
// enabled fetch (either via `.pugi/settings.json::web.fetch.enabled`
|
|
383
|
+
// or via `--allow-fetch`).
|
|
384
|
+
if (options.allowFetch) {
|
|
385
|
+
toolDefs.push({
|
|
386
|
+
name: 'web_fetch',
|
|
387
|
+
description: 'One-shot HTTP GET against an operator-supplied URL. Response is parsed to Markdown and wrapped in <untrusted-content> sentinel. Gated off by default.',
|
|
388
|
+
parameters: {
|
|
389
|
+
type: 'object',
|
|
390
|
+
additionalProperties: false,
|
|
391
|
+
required: ['url'],
|
|
392
|
+
properties: {
|
|
393
|
+
url: { type: 'string', description: 'Fully-qualified http(s) URL.' },
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
// β1b T4 : web_search advertisement. Same off-by-default
|
|
399
|
+
// privacy posture as web_fetch — the query string itself is an egress
|
|
400
|
+
// event that can leak operator intent to the upstream Brave Search
|
|
401
|
+
// backend. The tool dispatcher applies SSRF guards (no localhost via
|
|
402
|
+
// the Anvil proxy URL), rate-limits (5 req/min per session), and caps
|
|
403
|
+
// the result payload at 1 MiB. Sentinel-wrapped results so the model
|
|
404
|
+
// treats every snippet as data, not instructions.
|
|
405
|
+
if (options.allowSearch) {
|
|
406
|
+
toolDefs.push({
|
|
407
|
+
name: 'web_search',
|
|
408
|
+
description: 'Search the web via Brave Search (Anvil-proxied). Returns up to 10 sentinel-wrapped {title, url, snippet} results. Rate-limited to 5 calls/min per session. Gated off by default.',
|
|
409
|
+
parameters: {
|
|
410
|
+
type: 'object',
|
|
411
|
+
additionalProperties: false,
|
|
412
|
+
required: ['query'],
|
|
413
|
+
properties: {
|
|
414
|
+
query: {
|
|
415
|
+
type: 'string',
|
|
416
|
+
description: 'Search query, max 256 chars. Plain text — no operators.',
|
|
417
|
+
},
|
|
418
|
+
count: {
|
|
419
|
+
type: 'integer',
|
|
420
|
+
description: 'Optional result count (1..10, default 10).',
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
// β2 S3 : `agent` tool — subagent spawn primitive.
|
|
427
|
+
// Off by default; surfaced only when the caller explicitly opts in
|
|
428
|
+
// (orchestrator parents pass allowAgent=true via the engine adapter).
|
|
429
|
+
// Plan mode FORCES the tool off regardless because a write-capable
|
|
430
|
+
// child would violate plan-mode's read-only contract.
|
|
431
|
+
if (options.allowAgent && !planMode) {
|
|
432
|
+
toolDefs.push({
|
|
433
|
+
name: 'agent',
|
|
434
|
+
description: 'Spawn a specialist subagent under a Cyber-Zoo brand persona. '
|
|
435
|
+
+ 'Role selects the persona + isolation tier: '
|
|
436
|
+
+ 'researcher/reviewer/architect are read-only, verifier reads + runs tests, '
|
|
437
|
+
+ 'coder/release/devops/design_qa get write + bash. '
|
|
438
|
+
+ 'The child runs a fresh Anvil engine loop with its own transcript and '
|
|
439
|
+
+ 'returns a JSON envelope (filesChanged, toolCallCount, status, summary). '
|
|
440
|
+
+ 'Use this when the work needs a specialist persona OR write isolation via a scratch worktree.',
|
|
441
|
+
parameters: {
|
|
442
|
+
type: 'object',
|
|
443
|
+
additionalProperties: false,
|
|
444
|
+
required: ['role', 'brief'],
|
|
445
|
+
properties: {
|
|
446
|
+
role: {
|
|
447
|
+
type: 'string',
|
|
448
|
+
enum: [
|
|
449
|
+
'orchestrator',
|
|
450
|
+
'architect',
|
|
451
|
+
'coder',
|
|
452
|
+
'verifier',
|
|
453
|
+
'reviewer',
|
|
454
|
+
'researcher',
|
|
455
|
+
'release',
|
|
456
|
+
'devops',
|
|
457
|
+
'design_qa',
|
|
458
|
+
],
|
|
459
|
+
description: 'SubagentRole — selects persona + isolation tier.',
|
|
460
|
+
},
|
|
461
|
+
brief: {
|
|
462
|
+
type: 'string',
|
|
463
|
+
maxLength: 8000,
|
|
464
|
+
description: 'One-paragraph task description forwarded to the child as the user prompt. '
|
|
465
|
+
+ 'Be concrete: include filenames, expected behavior, and acceptance criteria.',
|
|
466
|
+
},
|
|
467
|
+
isolation: {
|
|
468
|
+
type: 'string',
|
|
469
|
+
enum: ['worktree', 'shared_fs', 'auto'],
|
|
470
|
+
description: 'Optional override. `worktree` forces a scratch git worktree for write isolation; '
|
|
471
|
+
+ '`shared_fs` forces same-tree execution; `auto` (default) defers to the role tier.',
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
}
|
|
75
477
|
if (!planMode) {
|
|
76
478
|
toolDefs.push({
|
|
77
479
|
name: 'write',
|
|
78
|
-
description: 'Create or overwrite a workspace file.
|
|
480
|
+
description: 'Create or overwrite a workspace file. Prefer edit for existing files. ' +
|
|
481
|
+
'For OVERWRITE of an existing file, you MUST read the file first in this session — ' +
|
|
482
|
+
'write refuses with STALE_READ if the file changed since your last read, or if you ' +
|
|
483
|
+
'never read it. New-file creation (path does not exist) skips that gate. Workspace-scoped.',
|
|
79
484
|
parameters: {
|
|
80
485
|
type: 'object',
|
|
81
486
|
additionalProperties: false,
|
|
@@ -87,7 +492,10 @@ export function buildToolsSchema(kind) {
|
|
|
87
492
|
},
|
|
88
493
|
}, {
|
|
89
494
|
name: 'edit',
|
|
90
|
-
description: 'Replace exactly one occurrence of oldString with newString inside an already-read file.
|
|
495
|
+
description: 'Replace exactly one occurrence of oldString with newString inside an already-read file. ' +
|
|
496
|
+
'Refuses with STALE_READ if the file was never read this session or the on-disk contents ' +
|
|
497
|
+
'drifted since the read (mtime+sha gate). Recovery: re-read with the `read` tool, then ' +
|
|
498
|
+
'retry the edit. Also fails if oldString is missing or duplicate.',
|
|
91
499
|
parameters: {
|
|
92
500
|
type: 'object',
|
|
93
501
|
additionalProperties: false,
|
|
@@ -100,18 +508,131 @@ export function buildToolsSchema(kind) {
|
|
|
100
508
|
},
|
|
101
509
|
}, {
|
|
102
510
|
name: 'bash',
|
|
103
|
-
description: 'Run a shell command inside the workspace root via /bin/sh -c. Inherits a sanitized env (PUGI_API_KEY/PUGI_LOGIN_TOKEN stripped).
|
|
511
|
+
description: 'Run a shell command inside the workspace root via /bin/sh -c. Inherits a sanitized env (PUGI_API_KEY/PUGI_LOGIN_TOKEN stripped). 60s timeout. Output capped at 32KB combined stdout+stderr. ' +
|
|
512
|
+
'Optional `redirect` opts the call into log-discipline mode: stdout+stderr are written to a file on disk (default `.pugi/runs/<sessionId>/bash-<hash>.log` or a workspace-relative override) and the response carries the path + last N lines (default 20, max 200) instead of the full output. Use redirect for long-running scripts (builds, training loops, agentic stdout dumps) where the trailing lines + a path to the full log saves thousands of tokens vs the truncated head. ' +
|
|
513
|
+
'Returns {exitCode, stdout, stderr, truncated} on the buffered path, or {exitCode, stdout:\'\', stderr:\'\', logPath, tail, truncated:false} when redirect is set.',
|
|
104
514
|
parameters: {
|
|
105
515
|
type: 'object',
|
|
106
516
|
additionalProperties: false,
|
|
107
517
|
required: ['command'],
|
|
108
518
|
properties: {
|
|
109
519
|
command: { type: 'string', description: 'Single shell command to execute.' },
|
|
520
|
+
redirect: {
|
|
521
|
+
type: 'object',
|
|
522
|
+
additionalProperties: false,
|
|
523
|
+
description: 'When set, redirect stdout+stderr to a file instead of returning content. Use for long-running scripts that produce thousands of lines.',
|
|
524
|
+
properties: {
|
|
525
|
+
path: {
|
|
526
|
+
type: 'string',
|
|
527
|
+
description: 'Workspace-relative path to write the log file. Defaults to `.pugi/runs/<sessionId>/bash-<commandHash>.log`. Absolute paths or `..` traversal are rejected.',
|
|
528
|
+
},
|
|
529
|
+
tailLines: {
|
|
530
|
+
type: 'number',
|
|
531
|
+
description: 'Number of trailing lines to fold into the response tail. Default 20, max 200.',
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
}, {
|
|
538
|
+
name: 'powershell',
|
|
539
|
+
description: 'Run a PowerShell command via `pwsh -NoProfile -Command` (or `powershell.exe` fallback on Windows). Same security posture as bash — destructive pattern gate applies. 30s default timeout, 120s max. Output capped at 64KB. Returns {exitCode, stdout, stderr, truncated, shellBinary}. Prefer the dedicated bash tool for /bin/sh scripts; use this when the operator needs native pwsh cmdlets or *.ps1 syntax.',
|
|
540
|
+
parameters: {
|
|
541
|
+
type: 'object',
|
|
542
|
+
additionalProperties: false,
|
|
543
|
+
required: ['command'],
|
|
544
|
+
properties: {
|
|
545
|
+
command: { type: 'string', description: 'Single PowerShell command or script.' },
|
|
546
|
+
cwd: { type: 'string', description: 'Optional cwd; defaults to workspace root.' },
|
|
547
|
+
timeoutMs: { type: 'number', description: 'Hard timeout (default 30000, max 120000).' },
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
// β7 L5+T11 : transactional multi-file edit. Either
|
|
552
|
+
// all entries land or none do — failures roll the workspace back
|
|
553
|
+
// via the same journal + snapshot machinery the dispatcher uses.
|
|
554
|
+
// Cap is 50 entries; beyond that the operator (or model) should
|
|
555
|
+
// split the refactor or use Layer C rewrites.
|
|
556
|
+
{
|
|
557
|
+
name: 'multi_edit',
|
|
558
|
+
description: 'Apply an ordered batch of single-occurrence file edits as one transaction. ' +
|
|
559
|
+
'Each entry is {file, oldString, newString} like the `edit` tool. Either every ' +
|
|
560
|
+
'edit lands or none do — a failure rolls the workspace back to the pre-dispatch ' +
|
|
561
|
+
'state via journal + snapshot. Cap 50 edits per call. Use this for coordinated ' +
|
|
562
|
+
'refactors (rename across files, add an import to many modules).',
|
|
563
|
+
parameters: {
|
|
564
|
+
type: 'object',
|
|
565
|
+
additionalProperties: false,
|
|
566
|
+
required: ['edits'],
|
|
567
|
+
properties: {
|
|
568
|
+
edits: {
|
|
569
|
+
type: 'array',
|
|
570
|
+
minItems: 1,
|
|
571
|
+
maxItems: 50,
|
|
572
|
+
items: {
|
|
573
|
+
type: 'object',
|
|
574
|
+
additionalProperties: false,
|
|
575
|
+
required: ['file', 'oldString', 'newString'],
|
|
576
|
+
properties: {
|
|
577
|
+
file: { type: 'string', description: 'Workspace-relative file path.' },
|
|
578
|
+
oldString: { type: 'string', description: 'Verbatim substring; must be unique in the pre-edit file.' },
|
|
579
|
+
newString: { type: 'string', description: 'Replacement string. Empty string means delete.' },
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
},
|
|
110
583
|
},
|
|
111
584
|
},
|
|
112
585
|
});
|
|
113
586
|
}
|
|
114
|
-
|
|
587
|
+
// β4 M1/M3: append MCP tools last. Plan mode skips them because every
|
|
588
|
+
// MCP tool is treated as medium-risk until per-tool annotations land
|
|
589
|
+
// in the MCP spec; treating MCP read-as-read would require server-
|
|
590
|
+
// side metadata we cannot trust today (a misconfigured server could
|
|
591
|
+
// claim `read` while running a destructive op).
|
|
592
|
+
if (!planMode) {
|
|
593
|
+
for (const def of mcpDefs) {
|
|
594
|
+
toolDefs.push({
|
|
595
|
+
name: def.name,
|
|
596
|
+
description: def.description,
|
|
597
|
+
parameters: def.parameters,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// L3 : leak-parity underscore-prefix filter. Every
|
|
602
|
+
// tool's parameter schema is scrubbed of `_`-prefixed fields before
|
|
603
|
+
// the model ever sees it. Native tool schemas above currently declare
|
|
604
|
+
// no `_*` fields, but MCP tools surfaced through buildMcpToolDefs
|
|
605
|
+
// come from third-party servers whose authors may follow the same
|
|
606
|
+
// convention (an MCP tool can declare `_sessionId` knowing the CLI
|
|
607
|
+
// dispatcher will inject it before forwarding). The dispatcher
|
|
608
|
+
// (buildExecutor below) does NOT strip these from the args record at
|
|
609
|
+
// call time — `_internal*` keys still flow through to tool handlers
|
|
610
|
+
// when an upstream layer populates them.
|
|
611
|
+
return toolDefs.map((tool) => ({
|
|
612
|
+
name: tool.name,
|
|
613
|
+
description: tool.description,
|
|
614
|
+
parameters: stripInternalFields(tool.parameters),
|
|
615
|
+
}));
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* L11: tolerant args-parse for the denial fingerprint. Unlike
|
|
619
|
+
* `parseArgs` (which throws on malformed JSON so the model sees a
|
|
620
|
+
* parse error), this swallows failures and returns `{}` — the denial
|
|
621
|
+
* tracker needs SOME key even when the raw payload is unparseable,
|
|
622
|
+
* because malformed-args spam is itself a pattern operators want to
|
|
623
|
+
* see in `/permissions denials`.
|
|
624
|
+
*/
|
|
625
|
+
function safeParseForTracking(raw) {
|
|
626
|
+
if (!raw || raw.trim() === '')
|
|
627
|
+
return {};
|
|
628
|
+
try {
|
|
629
|
+
return JSON.parse(raw);
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
// Use the raw string as the fingerprint payload so repeated
|
|
633
|
+
// identical malformed dispatches still cluster.
|
|
634
|
+
return { _rawArgs: raw.slice(0, 512) };
|
|
635
|
+
}
|
|
115
636
|
}
|
|
116
637
|
function parseArgs(raw) {
|
|
117
638
|
if (!raw || raw.trim() === '')
|
|
@@ -127,39 +648,189 @@ function parseArgs(raw) {
|
|
|
127
648
|
throw new Error(`invalid JSON in tool arguments: ${error.message}`);
|
|
128
649
|
}
|
|
129
650
|
}
|
|
651
|
+
/**
|
|
652
|
+
* Strict canonical-only argument coercion (leak P0 L2).
|
|
653
|
+
*
|
|
654
|
+
* Reverts the beta.17 alias acceptance (`file` / `filename` / `filepath`
|
|
655
|
+
* / `file_path` → `path`). The alias shim was the wrong direction: it
|
|
656
|
+
* paved over a model-side prompt-drift bug at the runtime layer, weakened
|
|
657
|
+
* the strict JSON-Schema contract one layer up (`additionalProperties:
|
|
658
|
+
* false`), and drifted away from the upstream reference (research memo
|
|
659
|
+
* §1.1 — `z.strictObject` rejects aliased fields).
|
|
660
|
+
*
|
|
661
|
+
* The compensating change ships in the persona prompts: Pugi's system
|
|
662
|
+
* prompt and Hiroshi's persona body now declare canonical parameter
|
|
663
|
+
* names with few-shot wrong/right contrasts so the model learns the
|
|
664
|
+
* grammar upstream of the bridge.
|
|
665
|
+
*/
|
|
130
666
|
function requireString(obj, key) {
|
|
131
667
|
const v = obj[key];
|
|
132
|
-
if (typeof v
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
|
|
668
|
+
if (typeof v === 'string')
|
|
669
|
+
return v;
|
|
670
|
+
throw new Error(`tool argument "${key}" must be a string`);
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Accept `path` (canonical) or `filePath` (the upstream tool convention) for
|
|
674
|
+
* write/edit/read tool arguments. Models trained on CC system prompts
|
|
675
|
+
* emit `filePath`; insisting on `path` only forces 2-3 retry waste on
|
|
676
|
+
* every file write (CEO live smoke: snake.html dispatch
|
|
677
|
+
* burned 2 turns retrying `{filePath: ...}` payloads before falling
|
|
678
|
+
* back к bash heredoc). Defense-in-depth alias keeps canonical name
|
|
679
|
+
* (so persona prompts can still teach `path` as the right answer) AND
|
|
680
|
+
* tolerates the CC-trained variant without operator-visible failure.
|
|
681
|
+
*/
|
|
682
|
+
function requirePathArg(obj) {
|
|
683
|
+
if (typeof obj['path'] === 'string')
|
|
684
|
+
return obj['path'];
|
|
685
|
+
if (typeof obj['filePath'] === 'string')
|
|
686
|
+
return obj['filePath'];
|
|
687
|
+
throw new Error('tool argument "path" must be a string (alias "filePath" also accepted)');
|
|
136
688
|
}
|
|
137
689
|
export function buildExecutor(input) {
|
|
138
|
-
const { kind, ctx, hooks, sessionId } = input;
|
|
690
|
+
const { kind, ctx, hooks, mvpHooksConfig, sessionId, askUserBridge, interactive, allowFetch, allowSearch, allowSyntheticOutput, agentDispatch, mcpRegistry, permissionMode, permissionAlwaysCache, permissionAsk, } = input;
|
|
691
|
+
// per-cycle budget. Default to a fresh instance scoped to
|
|
692
|
+
// this executor's closure lifetime; tests pass their own.
|
|
693
|
+
const retryBudget = input.retryBudget ?? new RetryBudget();
|
|
694
|
+
const mcpPrompt = input.mcpPrompt ?? defaultNonInteractiveMcpPrompt;
|
|
695
|
+
const workspaceRoot = input.workspaceRoot ?? ctx.root;
|
|
139
696
|
const planMode = kind === 'plan';
|
|
697
|
+
const denialTracking = input.denialTracking;
|
|
698
|
+
// L11: helper that records a denial (when tracking is wired) and
|
|
699
|
+
// ALWAYS returns an Error whose message includes a compact
|
|
700
|
+
// `<denial-context>` reminder when the same (tool, args) pair has
|
|
701
|
+
// already been refused at least once before in this session.
|
|
702
|
+
//
|
|
703
|
+
// The reminder is appended to the THROWN message — the engine loop
|
|
704
|
+
// appends thrown messages to the transcript as tool-result strings,
|
|
705
|
+
// so the model sees the aggregate the next time it considers a
|
|
706
|
+
// dispatch. Without this every retry would only see the latest
|
|
707
|
+
// single-turn reason and could loop indefinitely.
|
|
708
|
+
//
|
|
709
|
+
// Best-effort: a hash/clone failure inside the tracker MUST NOT
|
|
710
|
+
// mask the original refusal. The catch path falls back to a bare
|
|
711
|
+
// Error with the reason text.
|
|
712
|
+
const recordDenial = (toolName, args, reason) => {
|
|
713
|
+
if (!denialTracking)
|
|
714
|
+
return new Error(reason);
|
|
715
|
+
try {
|
|
716
|
+
const record = denialTracking.recordDenial(toolName, args, reason);
|
|
717
|
+
// Only inject the reminder once the threshold is hit — the very
|
|
718
|
+
// first denial is the model's first chance to learn, no need to
|
|
719
|
+
// shout. From the 2nd repeat onwards the model has demonstrated
|
|
720
|
+
// it is not learning from the single-turn sentinel, so we splice
|
|
721
|
+
// the aggregate context.
|
|
722
|
+
if (record.count >= DENIAL_REMINDER_THRESHOLD) {
|
|
723
|
+
const reminder = buildDenialContext(denialTracking);
|
|
724
|
+
if (reminder.length > 0) {
|
|
725
|
+
return new Error(`${reason}\n\n${reminder}`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
// Tracking is best-effort. Fall through to the bare Error so
|
|
731
|
+
// the refusal still propagates.
|
|
732
|
+
}
|
|
733
|
+
return new Error(reason);
|
|
734
|
+
};
|
|
140
735
|
return async ({ name, arguments: argsRaw }) => {
|
|
141
|
-
|
|
142
|
-
|
|
736
|
+
// β4 M1/M3: MCP tool names live outside WIRED_TOOLS. They are
|
|
737
|
+
// validated lazily by the dispatcher (the registry knows which
|
|
738
|
+
// names are actually exposed). The namespace check happens FIRST
|
|
739
|
+
// so a bad `mcp__bogus__foo` does not collide with the native
|
|
740
|
+
// unknown-tool branch.
|
|
741
|
+
const isMcpName = name.startsWith(MCP_TOOL_PREFIX);
|
|
742
|
+
// L11: parse-or-empty args once up-front so every deny path
|
|
743
|
+
// below can fingerprint the call against the denial tracker. We
|
|
744
|
+
// tolerate parse failure — `{}` keys still produce a stable hash
|
|
745
|
+
// (the model may have sent malformed JSON, but the refusal is
|
|
746
|
+
// semantic, not parse-driven).
|
|
747
|
+
const argsForTracking = safeParseForTracking(argsRaw);
|
|
748
|
+
if (!isMcpName && !WIRED_TOOLS.has(name)) {
|
|
749
|
+
throw recordDenial(name, argsForTracking, `unknown tool: ${name}`);
|
|
143
750
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
751
|
+
// — canonical 4-mode permission gate. Routes the dispatch
|
|
752
|
+
// decision BEFORE the legacy plan-mode-only enforcement so the new
|
|
753
|
+
// surface is the source of truth when the caller opted in. Absent
|
|
754
|
+
// `permissionMode` falls through to the legacy plan-mode branch
|
|
755
|
+
// (existing semantics preserved for callsites that have not
|
|
756
|
+
// migrated yet).
|
|
757
|
+
let hooksBypassed = false;
|
|
758
|
+
if (permissionMode) {
|
|
759
|
+
const decision = permissionGate(name, argsRaw, {
|
|
760
|
+
permissionMode,
|
|
761
|
+
...(permissionAlwaysCache ? { alwaysCache: permissionAlwaysCache } : {}),
|
|
762
|
+
});
|
|
763
|
+
if (decision.decision === 'deny') {
|
|
764
|
+
throw new PermissionDenied(name, getToolClass(name), permissionMode, decision.reason);
|
|
765
|
+
}
|
|
766
|
+
if (decision.decision === 'ask') {
|
|
767
|
+
if (!permissionAsk) {
|
|
768
|
+
// Non-interactive caller (CI / pipes / agent-as-tool) cannot
|
|
769
|
+
// surface a prompt. Collapse to deny so the loop receives a
|
|
770
|
+
// deterministic refusal instead of hanging.
|
|
771
|
+
throw new PermissionDenied(name, decision.toolClass, permissionMode, `Ask mode: no operator prompt available for ${name} (non-interactive caller)`);
|
|
772
|
+
}
|
|
773
|
+
const answer = await permissionAsk({
|
|
774
|
+
toolName: name,
|
|
775
|
+
toolClass: decision.toolClass,
|
|
776
|
+
question: decision.question,
|
|
777
|
+
options: decision.options,
|
|
778
|
+
});
|
|
779
|
+
const verdict = permissionAlwaysCache
|
|
780
|
+
? applyAskAnswer(permissionAlwaysCache, name, answer)
|
|
781
|
+
: applyAskAnswer({ alwaysAllowed: new Set(), alwaysDenied: new Set() }, name, answer);
|
|
782
|
+
if (verdict.decision === 'deny') {
|
|
783
|
+
throw new PermissionDenied(name, decision.toolClass, permissionMode, verdict.reason);
|
|
784
|
+
}
|
|
785
|
+
// verdict.decision === 'allow' falls through to dispatch.
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
// allow — honour the bypass flag for the hook layer below.
|
|
789
|
+
hooksBypassed = decision.hooksBypassed === true;
|
|
790
|
+
}
|
|
149
791
|
}
|
|
150
|
-
|
|
792
|
+
else if (planMode) {
|
|
793
|
+
// Legacy plan-mode enforcement (kind === 'plan') stays in place
|
|
794
|
+
// for callers that have not opted into the canonical gate.
|
|
795
|
+
// MCP tools are uniformly refused in plan mode (see schema-side
|
|
796
|
+
// rationale in buildToolsSchema). Native tools split via
|
|
797
|
+
// READ_ONLY_TOOLS as before.
|
|
798
|
+
if (isMcpName || !READ_ONLY_TOOLS.has(name)) {
|
|
799
|
+
throw recordDenial(name, argsForTracking, `PLAN_MODE_REFUSED: ${name} is not allowed in plan mode`);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
// : refuse cancelled-token tool dispatch BEFORE PreToolUse
|
|
151
803
|
// hooks fire so a cancelled brief never reaches user-defined
|
|
152
804
|
// hook scripts. Sentinel `OPERATOR_ABORTED:<tool>` is recognised
|
|
153
805
|
// by `runEngineLoop` as a terminal-cancel signal so the loop
|
|
154
806
|
// returns control to the caller rather than retrying the model.
|
|
155
807
|
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
156
|
-
throw
|
|
808
|
+
throw recordDenial(name, argsForTracking, `OPERATOR_ABORTED: ${name} refused — operator cancelled the dispatch.`);
|
|
809
|
+
}
|
|
810
|
+
// — per-cycle tool retry budget. Same tool + same canonical
|
|
811
|
+
// args = same bucket. Once the cap is hit we throw a typed sentinel
|
|
812
|
+
// so the model is forced out of a repair loop. We gate AFTER
|
|
813
|
+
// permission (denied calls do not burn budget) and BEFORE PreToolUse
|
|
814
|
+
// hooks (hook-blocked retries DO count — the model still issued the
|
|
815
|
+
// same call). The `recordAttempt` fires unconditionally so warn-only
|
|
816
|
+
// mode (PUGI_RETRY_BUDGET_DISABLED=1) still tracks the pattern for
|
|
817
|
+
// diagnostics.
|
|
818
|
+
const argHash = hashArgs(argsRaw);
|
|
819
|
+
const budgetDecision = retryBudget.shouldAllow(name, argHash);
|
|
820
|
+
retryBudget.recordAttempt(name, argHash);
|
|
821
|
+
if (!budgetDecision.allowed) {
|
|
822
|
+
throw new RetryBudgetExhausted(name, budgetDecision.cap, argHash);
|
|
157
823
|
}
|
|
158
824
|
// Fire PreToolUse hooks. The match grammar takes the tool name and
|
|
159
825
|
// (when extractable) the target path. Each new tool dispatch starts a
|
|
160
826
|
// fresh dedup batch so a hook fires once per dispatch, not once per
|
|
161
827
|
// session.
|
|
162
|
-
|
|
828
|
+
//
|
|
829
|
+
// — bypass mode skips the entire hook layer (PreToolUse +
|
|
830
|
+
// PostToolUse + PostToolUseFailure). The gate's allow decision
|
|
831
|
+
// carries the `hooksBypassed` flag; we honour it here so the
|
|
832
|
+
// executor stays single-pass.
|
|
833
|
+
if (hooks && sessionId && !hooksBypassed) {
|
|
163
834
|
hooks.resetBatch();
|
|
164
835
|
const path = extractToolPath(name, argsRaw);
|
|
165
836
|
const preCtx = {
|
|
@@ -179,37 +850,218 @@ export function buildExecutor(input) {
|
|
|
179
850
|
const hook = matchingPreHooks[i];
|
|
180
851
|
const result = preResults[i];
|
|
181
852
|
if (hook && result && hook.onFailure === 'block' && !result.ok) {
|
|
182
|
-
|
|
853
|
+
// L11: record the PreToolUse hook denial so the model
|
|
854
|
+
// sees the pattern reminder on subsequent turns. Without
|
|
855
|
+
// this the model would re-issue the same refused call and
|
|
856
|
+
// burn a turn each time before noticing the loop.
|
|
857
|
+
throw recordDenial(name, argsForTracking, `HOOK_BLOCKED: PreToolUse hook (${hook.run.slice(0, 80)}) refused ${name} (exit=${result.exitCode})`);
|
|
183
858
|
}
|
|
184
859
|
}
|
|
185
860
|
}
|
|
186
|
-
|
|
861
|
+
// MVP: fire `hooks-mvp.json` PreToolUse hooks. Distinct
|
|
862
|
+
// config file from the legacy `hooks.json` system so operator
|
|
863
|
+
// configs do not collide. Same blocking semantics — a non-zero
|
|
864
|
+
// exit from a hook declared `blocking: true` refuses the dispatch
|
|
865
|
+
// with `HOOK_BLOCKED:` sentinel. Bypass mode skips this surface
|
|
866
|
+
// identically to the legacy hooks block above.
|
|
867
|
+
if (mvpHooksConfig && sessionId && !hooksBypassed && !mvpHooksConfig.isEmpty()) {
|
|
868
|
+
const { fireHooks } = await import('../hooks/index.js');
|
|
869
|
+
const outcome = await fireHooks({
|
|
870
|
+
config: mvpHooksConfig,
|
|
871
|
+
event: 'PreToolUse',
|
|
872
|
+
payload: {
|
|
873
|
+
event: 'PreToolUse',
|
|
874
|
+
sessionId,
|
|
875
|
+
toolName: name,
|
|
876
|
+
toolInputSummary: hashArgs(argsRaw),
|
|
877
|
+
},
|
|
878
|
+
toolName: name,
|
|
879
|
+
workspaceRoot: ctx.root,
|
|
880
|
+
});
|
|
881
|
+
if (outcome.anyBlocked) {
|
|
882
|
+
const blocking = outcome.results.find((r) => r.blocked);
|
|
883
|
+
const sentinel = blocking?.blockSentinel ??
|
|
884
|
+
`HOOK_BLOCKED: PreToolUse MVP-hook refused ${name}`;
|
|
885
|
+
throw recordDenial(name, argsForTracking, sentinel);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
// β4 M1/M3: MCP dispatch deferred to the `dispatch` closure below so
|
|
889
|
+
// PostToolUse / PostToolUseFailure hooks observe MCP calls just like
|
|
890
|
+
// native calls. The dispatcher does its own argument parsing — MCP
|
|
891
|
+
// arg errors surface as model-visible `[MCP dispatch error] ...`
|
|
892
|
+
// strings, not throws.
|
|
893
|
+
const args = isMcpName ? {} : parseArgs(argsRaw);
|
|
187
894
|
const dispatch = async () => {
|
|
895
|
+
if (isMcpName) {
|
|
896
|
+
return dispatchMcpTool({
|
|
897
|
+
name,
|
|
898
|
+
argumentsRaw: argsRaw,
|
|
899
|
+
registry: mcpRegistry,
|
|
900
|
+
prompt: mcpPrompt,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
// β1 T1/T2/T3/T5/T6: async-dispatch the new tool surface.
|
|
904
|
+
// task_*, skill, ask_user_question, web_fetch all live behind
|
|
905
|
+
// an async or async-compatible boundary.
|
|
906
|
+
if (name === 'task_create' || name === 'task_get' || name === 'task_list' || name === 'task_update') {
|
|
907
|
+
return dispatchTaskTool(name, args, { workspaceRoot, sessionId });
|
|
908
|
+
}
|
|
909
|
+
if (name === 'todo_write') {
|
|
910
|
+
// batch TodoWrite. The dispatcher delegates the
|
|
911
|
+
// Zod validation + atomic persist to the tool module — any
|
|
912
|
+
// ZodError or `TODO_INVARIANT_VIOLATED` sentinel surfaces here
|
|
913
|
+
// as a thrown Error and lands on the catch arm below, which
|
|
914
|
+
// re-emits it through the PostToolUseFailure hook.
|
|
915
|
+
return dispatchTodoWrite({ workspaceRoot }, args);
|
|
916
|
+
}
|
|
917
|
+
// Tool gap pack : brief / sleep / synthetic_output /
|
|
918
|
+
// enter_worktree / exit_worktree dispatchers. Each tool returns a
|
|
919
|
+
// sentinel string on recoverable validation failures (no throw)
|
|
920
|
+
// so the engine adapter surfaces them as plain tool results and
|
|
921
|
+
// the model can self-correct.
|
|
922
|
+
if (name === 'brief') {
|
|
923
|
+
return dispatchBrief({
|
|
924
|
+
workspaceRoot,
|
|
925
|
+
// Fallback when running outside an audit session (CI, smoke
|
|
926
|
+
// tests, one-shot CLI commands) — keep the JSONL writes
|
|
927
|
+
// grouped under a stable basename instead of dropping them.
|
|
928
|
+
sessionId: sessionId ?? 'no-session',
|
|
929
|
+
}, args);
|
|
930
|
+
}
|
|
931
|
+
if (name === 'verify_plan_execution') {
|
|
932
|
+
// Backlog #5 P0 : anti-fake-dispatch gate. Reads
|
|
933
|
+
// the session audit log accumulated during this dispatch (and
|
|
934
|
+
// earlier turns in the same engine loop invocation).
|
|
935
|
+
return dispatchVerifyPlanExecution(ctx.session, args);
|
|
936
|
+
}
|
|
937
|
+
if (name === 'sleep') {
|
|
938
|
+
return dispatchSleep({}, args);
|
|
939
|
+
}
|
|
940
|
+
if (name === 'synthetic_output') {
|
|
941
|
+
if (!allowSyntheticOutput) {
|
|
942
|
+
// Mirrors the `web_fetch` / `agent` defense-in-depth posture:
|
|
943
|
+
// a stale schema must never let the model invoke an opt-in
|
|
944
|
+
// tool. Surface a clear refusal sentinel the dispatcher can
|
|
945
|
+
// record for denial tracking.
|
|
946
|
+
throw new Error('synthetic_output: tool not enabled in this executor. Engine-only fixture; opt in via allowSyntheticOutput.');
|
|
947
|
+
}
|
|
948
|
+
return dispatchSyntheticOutput({}, args);
|
|
949
|
+
}
|
|
950
|
+
if (name === 'enter_worktree') {
|
|
951
|
+
return dispatchEnterWorktree({ workspaceRoot }, args);
|
|
952
|
+
}
|
|
953
|
+
if (name === 'exit_worktree') {
|
|
954
|
+
return dispatchExitWorktree({ workspaceRoot }, args);
|
|
955
|
+
}
|
|
956
|
+
if (name === 'ask_user_question') {
|
|
957
|
+
return dispatchAskUser(args, { interactive: Boolean(interactive), bridge: askUserBridge });
|
|
958
|
+
}
|
|
959
|
+
if (name === 'skill' || name === 'skills_list') {
|
|
960
|
+
return dispatchSkillTool(name, args, { workspaceRoot });
|
|
961
|
+
}
|
|
962
|
+
if (name === 'web_fetch') {
|
|
963
|
+
return dispatchWebFetch(args, { ctx, allowFetch: Boolean(allowFetch) });
|
|
964
|
+
}
|
|
965
|
+
if (name === 'web_search') {
|
|
966
|
+
return dispatchWebSearch(args, {
|
|
967
|
+
ctx,
|
|
968
|
+
allowSearch: Boolean(allowSearch),
|
|
969
|
+
sessionId,
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
if (name === 'multi_edit') {
|
|
973
|
+
return dispatchMultiEdit(args, ctx);
|
|
974
|
+
}
|
|
975
|
+
if (name === 'agent') {
|
|
976
|
+
// β2a r1 (Backend Architect P1): defense in depth.
|
|
977
|
+
// `WIRED_TOOLS` includes `agent`, so a plan-mode model that
|
|
978
|
+
// fabricates an `agent` tool call would otherwise be routed
|
|
979
|
+
// here. The plan-mode refusal at the top of the executor only
|
|
980
|
+
// fires for tools NOT in READ_ONLY_TOOLS; `agent` is
|
|
981
|
+
// intentionally absent from both sets, so we explicitly refuse
|
|
982
|
+
// it here. This pairs with `native-pugi.ts` hard-gating
|
|
983
|
+
// `agentDispatch` itself off in plan mode — without this
|
|
984
|
+
// defensive throw a future schema bug could let a plan-mode
|
|
985
|
+
// model spawn a write-capable child and break the read-only
|
|
986
|
+
// contract.
|
|
987
|
+
if (planMode) {
|
|
988
|
+
throw recordDenial(name, argsForTracking, 'PLAN_MODE_REFUSED: agent is not allowed in plan mode');
|
|
989
|
+
}
|
|
990
|
+
return dispatchAgent(args, agentDispatch);
|
|
991
|
+
}
|
|
188
992
|
return dispatchTool(name, args, ctx);
|
|
189
993
|
};
|
|
190
994
|
try {
|
|
191
995
|
const result = await dispatch();
|
|
192
|
-
|
|
996
|
+
// post-edit LSP diagnostics. After a
|
|
997
|
+
// successful `edit` / `write` / `multi_edit`, ask the cached
|
|
998
|
+
// language server for diagnostics on the touched file(s) and
|
|
999
|
+
// append the result to the tool envelope so the model can
|
|
1000
|
+
// self-correct in the same turn. Silent skip when the language
|
|
1001
|
+
// is unsupported, no server is installed, or the request times
|
|
1002
|
+
// out — agent throughput beats diagnostic recall.
|
|
1003
|
+
const augmented = await appendPostEditDiagnostics(name, args, ctx, result);
|
|
1004
|
+
if (hooks && sessionId && !hooksBypassed) {
|
|
193
1005
|
const path = extractToolPath(name, argsRaw);
|
|
194
1006
|
await hooks.fire({
|
|
195
1007
|
sessionId,
|
|
196
1008
|
event: 'PostToolUse',
|
|
197
1009
|
tool: name,
|
|
198
1010
|
path,
|
|
199
|
-
payload: { tool: name, arguments: argsRaw, ok: true, result:
|
|
1011
|
+
payload: { tool: name, arguments: argsRaw, ok: true, result: augmented.slice(0, 1024) },
|
|
200
1012
|
});
|
|
201
1013
|
}
|
|
202
|
-
return
|
|
1014
|
+
return augmented;
|
|
203
1015
|
}
|
|
204
1016
|
catch (error) {
|
|
205
|
-
//
|
|
1017
|
+
// #24 (CEO P1) — hook chains. After the legacy
|
|
1018
|
+
// PostToolUseFailure registry fire (per-error-class block below),
|
|
1019
|
+
// ALSO fire the settings.json hook chain. Chains are best-effort:
|
|
1020
|
+
// a chain command crash never propagates back here so the engine
|
|
1021
|
+
// loop sees the original throw unchanged.
|
|
1022
|
+
const fireFailureChain = async (errorMessage) => {
|
|
1023
|
+
try {
|
|
1024
|
+
await firePostToolUseFailureChain(ctx.root, {
|
|
1025
|
+
toolName: name,
|
|
1026
|
+
args: argsForTracking,
|
|
1027
|
+
error: errorMessage,
|
|
1028
|
+
exitCode: 1,
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
catch (chainError) {
|
|
1032
|
+
process.stderr.write(`[pugi hook-chains] PostToolUseFailure chain crashed: ${chainError.message}\n`);
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
// — surface the PermissionDenied sentinel as a model-
|
|
1036
|
+
// readable message instead of leaking the raw Error type. The
|
|
1037
|
+
// string format is stable so the engine adapter / spec layer
|
|
1038
|
+
// can pattern-match against it.
|
|
1039
|
+
if (error instanceof PermissionDenied) {
|
|
1040
|
+
// PostToolUseFailure fires for visibility unless bypass is on.
|
|
1041
|
+
if (hooks && sessionId && !hooksBypassed) {
|
|
1042
|
+
await hooks.fire({
|
|
1043
|
+
sessionId,
|
|
1044
|
+
event: 'PostToolUseFailure',
|
|
1045
|
+
tool: name,
|
|
1046
|
+
payload: {
|
|
1047
|
+
tool: name,
|
|
1048
|
+
arguments: argsRaw,
|
|
1049
|
+
ok: false,
|
|
1050
|
+
error: error.toModelMessage(),
|
|
1051
|
+
},
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
await fireFailureChain(error.toModelMessage());
|
|
1055
|
+
throw new Error(error.toModelMessage());
|
|
1056
|
+
}
|
|
1057
|
+
// : re-shape OperatorAbortedError throws from the
|
|
206
1058
|
// file-tools layer into the same `OPERATOR_ABORTED:` sentinel
|
|
207
1059
|
// the upstream cancellation gate uses so `runEngineLoop` sees
|
|
208
1060
|
// a consistent terminal-cancel signal regardless of whether
|
|
209
1061
|
// the abort landed pre-dispatch or mid-tool (e.g. inside the
|
|
210
1062
|
// grep file-loop).
|
|
211
1063
|
if (error instanceof OperatorAbortedError) {
|
|
212
|
-
if (hooks && sessionId) {
|
|
1064
|
+
if (hooks && sessionId && !hooksBypassed) {
|
|
213
1065
|
const path = extractToolPath(name, argsRaw);
|
|
214
1066
|
await hooks.fire({
|
|
215
1067
|
sessionId,
|
|
@@ -224,9 +1076,37 @@ export function buildExecutor(input) {
|
|
|
224
1076
|
},
|
|
225
1077
|
});
|
|
226
1078
|
}
|
|
227
|
-
|
|
1079
|
+
await fireFailureChain(`OPERATOR_ABORTED: ${name}`);
|
|
1080
|
+
throw recordDenial(name, argsForTracking, `OPERATOR_ABORTED: ${name} aborted mid-execution.`);
|
|
1081
|
+
}
|
|
1082
|
+
// re-shape StaleReadError into a
|
|
1083
|
+
// deterministic STALE_READ:<reason> sentinel so the model's
|
|
1084
|
+
// retry policy can pattern-match on a stable prefix instead of
|
|
1085
|
+
// free-form prose. The model is expected to re-read the file and
|
|
1086
|
+
// retry the edit — the message points it at exactly that recovery
|
|
1087
|
+
// path. PostToolUseFailure hooks observe the typed error so an
|
|
1088
|
+
// operator can build a "warn me when stale edits keep happening"
|
|
1089
|
+
// hook (likely a concurrency / multi-agent indicator).
|
|
1090
|
+
if (error instanceof StaleReadError) {
|
|
1091
|
+
if (hooks && sessionId && !hooksBypassed) {
|
|
1092
|
+
const path = extractToolPath(name, argsRaw);
|
|
1093
|
+
await hooks.fire({
|
|
1094
|
+
sessionId,
|
|
1095
|
+
event: 'PostToolUseFailure',
|
|
1096
|
+
tool: name,
|
|
1097
|
+
path,
|
|
1098
|
+
payload: {
|
|
1099
|
+
tool: name,
|
|
1100
|
+
arguments: argsRaw,
|
|
1101
|
+
ok: false,
|
|
1102
|
+
error: `STALE_READ: ${error.reason} on ${error.path}`,
|
|
1103
|
+
},
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
await fireFailureChain(`STALE_READ: ${error.reason} on ${error.path}`);
|
|
1107
|
+
throw recordDenial(name, argsForTracking, `STALE_READ: ${name} on ${error.path} refused (${error.reason}). Re-read the file with the \`read\` tool, then retry the ${name}.`);
|
|
228
1108
|
}
|
|
229
|
-
if (hooks && sessionId) {
|
|
1109
|
+
if (hooks && sessionId && !hooksBypassed) {
|
|
230
1110
|
const path = extractToolPath(name, argsRaw);
|
|
231
1111
|
await hooks.fire({
|
|
232
1112
|
sessionId,
|
|
@@ -241,6 +1121,7 @@ export function buildExecutor(input) {
|
|
|
241
1121
|
},
|
|
242
1122
|
});
|
|
243
1123
|
}
|
|
1124
|
+
await fireFailureChain(error instanceof Error ? error.message : String(error));
|
|
244
1125
|
throw error;
|
|
245
1126
|
}
|
|
246
1127
|
};
|
|
@@ -266,7 +1147,7 @@ function extractToolPath(name, argsRaw) {
|
|
|
266
1147
|
function dispatchTool(name, args, ctx) {
|
|
267
1148
|
switch (name) {
|
|
268
1149
|
case 'read': {
|
|
269
|
-
const { path } = { path:
|
|
1150
|
+
const { path } = { path: requirePathArg(args) };
|
|
270
1151
|
const content = readTool(ctx, path);
|
|
271
1152
|
// Cap the content surfaced back to the model so a 10MB file
|
|
272
1153
|
// does not blow the context window. The model sees the head
|
|
@@ -279,7 +1160,7 @@ function dispatchTool(name, args, ctx) {
|
|
|
279
1160
|
}
|
|
280
1161
|
case 'write': {
|
|
281
1162
|
const wargs = {
|
|
282
|
-
path:
|
|
1163
|
+
path: requirePathArg(args),
|
|
283
1164
|
content: requireString(args, 'content'),
|
|
284
1165
|
};
|
|
285
1166
|
writeTool(ctx, wargs.path, wargs.content);
|
|
@@ -287,7 +1168,7 @@ function dispatchTool(name, args, ctx) {
|
|
|
287
1168
|
}
|
|
288
1169
|
case 'edit': {
|
|
289
1170
|
const eargs = {
|
|
290
|
-
path:
|
|
1171
|
+
path: requirePathArg(args),
|
|
291
1172
|
oldString: requireString(args, 'oldString'),
|
|
292
1173
|
newString: requireString(args, 'newString'),
|
|
293
1174
|
};
|
|
@@ -295,7 +1176,11 @@ function dispatchTool(name, args, ctx) {
|
|
|
295
1176
|
return `edited ${eargs.path}`;
|
|
296
1177
|
}
|
|
297
1178
|
case 'grep': {
|
|
298
|
-
const
|
|
1179
|
+
const queryRaw = args.query ?? args.text ?? args.pattern ?? args.q ?? args.search;
|
|
1180
|
+
if (typeof queryRaw !== 'string' || queryRaw.length === 0) {
|
|
1181
|
+
throw new Error('tool argument "query" must be a non-empty string (aliases: text/pattern/q/search)');
|
|
1182
|
+
}
|
|
1183
|
+
const gargs = { query: queryRaw };
|
|
299
1184
|
const matches = grepTool(ctx, gargs.query);
|
|
300
1185
|
if (matches.length === 0)
|
|
301
1186
|
return `no matches for ${gargs.query}`;
|
|
@@ -312,12 +1197,37 @@ function dispatchTool(name, args, ctx) {
|
|
|
312
1197
|
return `${results.length} path(s):\n${results.slice(0, 100).join('\n')}${results.length > 100 ? `\n(... ${results.length - 100} more)` : ''}`;
|
|
313
1198
|
}
|
|
314
1199
|
case 'bash': {
|
|
315
|
-
const
|
|
316
|
-
//
|
|
1200
|
+
const command = requireString(args, 'command');
|
|
1201
|
+
// Pugi backlog P2 — parse the optional redirect block. We
|
|
1202
|
+
// accept either no field, an empty object (== "use defaults"),
|
|
1203
|
+
// or `{path?, tailLines?}`. The bash tool's helper layer
|
|
1204
|
+
// normalises both values; we only do the outer-shape parse
|
|
1205
|
+
// here so a malformed arg surfaces as a model-readable error.
|
|
1206
|
+
const rawRedirect = args['redirect'];
|
|
1207
|
+
let redirect;
|
|
1208
|
+
if (rawRedirect !== undefined && rawRedirect !== null) {
|
|
1209
|
+
if (typeof rawRedirect !== 'object' || Array.isArray(rawRedirect)) {
|
|
1210
|
+
throw new Error('tool argument "redirect" must be an object when present');
|
|
1211
|
+
}
|
|
1212
|
+
const r = rawRedirect;
|
|
1213
|
+
const pathArg = r['path'];
|
|
1214
|
+
const tailArg = r['tailLines'];
|
|
1215
|
+
if (pathArg !== undefined && typeof pathArg !== 'string') {
|
|
1216
|
+
throw new Error('redirect.path must be a string when present');
|
|
1217
|
+
}
|
|
1218
|
+
if (tailArg !== undefined && (typeof tailArg !== 'number' || !Number.isFinite(tailArg))) {
|
|
1219
|
+
throw new Error('redirect.tailLines must be a finite number when present');
|
|
1220
|
+
}
|
|
1221
|
+
redirect = {
|
|
1222
|
+
...(pathArg !== undefined ? { path: pathArg } : {}),
|
|
1223
|
+
...(tailArg !== undefined ? { tailLines: tailArg } : {}),
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
// The class-aware bash tool (sprint ) replaces the legacy
|
|
317
1227
|
// file-tools entry point. We use the sync variant here because
|
|
318
1228
|
// dispatchTool's signature is sync; the async tool is reserved
|
|
319
|
-
// for the REPL path (sprint
|
|
320
|
-
const result = bashToolSync({ cmd:
|
|
1229
|
+
// for the REPL path (sprint ) where promises are first class.
|
|
1230
|
+
const result = bashToolSync({ cmd: command, ...(redirect !== undefined ? { redirect } : {}) }, {
|
|
321
1231
|
root: ctx.root,
|
|
322
1232
|
settings: ctx.settings,
|
|
323
1233
|
session: ctx.session,
|
|
@@ -330,6 +1240,10 @@ function dispatchTool(name, args, ctx) {
|
|
|
330
1240
|
];
|
|
331
1241
|
if (result.artifactRef)
|
|
332
1242
|
parts.push(`artifactRef=${result.artifactRef}`);
|
|
1243
|
+
if (result.logPath)
|
|
1244
|
+
parts.push(`logPath=${result.logPath}`);
|
|
1245
|
+
if (result.tail)
|
|
1246
|
+
parts.push(`tail:\n${result.tail}`);
|
|
333
1247
|
if (result.truncated)
|
|
334
1248
|
parts.push('truncated=true');
|
|
335
1249
|
if (result.timedOut)
|
|
@@ -337,9 +1251,331 @@ function dispatchTool(name, args, ctx) {
|
|
|
337
1251
|
const body = parts.filter(Boolean).join('\n');
|
|
338
1252
|
return body || '(no output)';
|
|
339
1253
|
}
|
|
1254
|
+
case 'powershell': {
|
|
1255
|
+
// pwsh dispatcher. Permission gate reuses the
|
|
1256
|
+
// bash classifier so destructive patterns block the same way.
|
|
1257
|
+
const command = requireString(args, 'command');
|
|
1258
|
+
const cwd = optionalString(args, 'cwd');
|
|
1259
|
+
const timeoutMs = optionalNumber(args, 'timeoutMs');
|
|
1260
|
+
const psResult = powerShellToolSync({ cmd: command, ...(cwd !== undefined ? { cwd } : {}), ...(timeoutMs !== undefined ? { timeoutMs } : {}) }, {
|
|
1261
|
+
root: ctx.root,
|
|
1262
|
+
settings: ctx.settings,
|
|
1263
|
+
session: ctx.session,
|
|
1264
|
+
source: 'agent',
|
|
1265
|
+
});
|
|
1266
|
+
const parts = [
|
|
1267
|
+
`exit=${psResult.exitCode}`,
|
|
1268
|
+
`shell=${psResult.shellBinary}`,
|
|
1269
|
+
psResult.stdout ? `stdout:\n${psResult.stdout}` : '',
|
|
1270
|
+
psResult.stderr ? `stderr:\n${psResult.stderr}` : '',
|
|
1271
|
+
];
|
|
1272
|
+
if (psResult.truncated)
|
|
1273
|
+
parts.push('truncated=true');
|
|
1274
|
+
if (psResult.timedOut)
|
|
1275
|
+
parts.push('timedOut=true');
|
|
1276
|
+
return parts.filter(Boolean).join('\n') || '(no output)';
|
|
1277
|
+
}
|
|
340
1278
|
default:
|
|
341
1279
|
// Exhaustive; unreachable because of the WIRED_TOOLS guard above.
|
|
342
1280
|
throw new Error(`unhandled tool: ${name}`);
|
|
343
1281
|
}
|
|
344
1282
|
}
|
|
1283
|
+
/* ----------------------------- β1 dispatchers ----------------------------- */
|
|
1284
|
+
function dispatchTaskTool(name, args, opts) {
|
|
1285
|
+
if (!opts.sessionId) {
|
|
1286
|
+
throw new Error(`${name}: no sessionId in scope — task ledger requires a session`);
|
|
1287
|
+
}
|
|
1288
|
+
const tctx = { workspaceRoot: opts.workspaceRoot, sessionId: opts.sessionId };
|
|
1289
|
+
switch (name) {
|
|
1290
|
+
case 'task_create': {
|
|
1291
|
+
const title = requireString(args, 'title');
|
|
1292
|
+
const status = optionalString(args, 'status');
|
|
1293
|
+
const notes = optionalString(args, 'notes');
|
|
1294
|
+
const record = taskCreate(tctx, {
|
|
1295
|
+
title,
|
|
1296
|
+
...(status !== undefined ? { status: status } : {}),
|
|
1297
|
+
...(notes !== undefined ? { notes } : {}),
|
|
1298
|
+
});
|
|
1299
|
+
return JSON.stringify(record);
|
|
1300
|
+
}
|
|
1301
|
+
case 'task_get': {
|
|
1302
|
+
const id = requireString(args, 'id');
|
|
1303
|
+
const record = taskGet(tctx, id);
|
|
1304
|
+
return record ? JSON.stringify(record) : 'null';
|
|
1305
|
+
}
|
|
1306
|
+
case 'task_list': {
|
|
1307
|
+
const list = taskList(tctx);
|
|
1308
|
+
return JSON.stringify(list);
|
|
1309
|
+
}
|
|
1310
|
+
case 'task_update': {
|
|
1311
|
+
const id = requireString(args, 'id');
|
|
1312
|
+
const title = optionalString(args, 'title');
|
|
1313
|
+
const status = optionalString(args, 'status');
|
|
1314
|
+
const notes = optionalString(args, 'notes');
|
|
1315
|
+
const record = taskUpdate(tctx, {
|
|
1316
|
+
id,
|
|
1317
|
+
...(title !== undefined ? { title } : {}),
|
|
1318
|
+
...(status !== undefined ? { status: status } : {}),
|
|
1319
|
+
...(notes !== undefined ? { notes } : {}),
|
|
1320
|
+
});
|
|
1321
|
+
return JSON.stringify(record);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
async function dispatchAskUser(args, opts) {
|
|
1326
|
+
const rawOptions = args['options'];
|
|
1327
|
+
if (!Array.isArray(rawOptions)) {
|
|
1328
|
+
throw new Error('ask_user_question: options must be an array');
|
|
1329
|
+
}
|
|
1330
|
+
// detect structured vs legacy form. Structured
|
|
1331
|
+
// entries are objects with {label, description}; legacy entries are
|
|
1332
|
+
// plain strings. The structured path validates via Zod and emits the
|
|
1333
|
+
// [ask_user_question:answered|cancelled|timeout] envelope. The legacy
|
|
1334
|
+
// path stays for back-compat with the existing β1 T2 tests + the
|
|
1335
|
+
// <pugi-ask> prompt envelope (which still feeds string options).
|
|
1336
|
+
const looksStructured = rawOptions.length > 0
|
|
1337
|
+
&& typeof rawOptions[0] === 'object'
|
|
1338
|
+
&& rawOptions[0] !== null
|
|
1339
|
+
&& !Array.isArray(rawOptions[0]);
|
|
1340
|
+
if (looksStructured) {
|
|
1341
|
+
const result = await dispatchAskUserQuestion({ interactive: opts.interactive, ...(opts.bridge ? { bridge: opts.bridge } : {}) }, args);
|
|
1342
|
+
return result.envelope;
|
|
1343
|
+
}
|
|
1344
|
+
// Legacy string-array form.
|
|
1345
|
+
const question = requireString(args, 'question');
|
|
1346
|
+
const options = rawOptions.map((o, i) => {
|
|
1347
|
+
if (typeof o !== 'string') {
|
|
1348
|
+
throw new Error(`ask_user_question: options[${i}] must be a string`);
|
|
1349
|
+
}
|
|
1350
|
+
return o;
|
|
1351
|
+
});
|
|
1352
|
+
const multiSelect = args['multiSelect'] === true;
|
|
1353
|
+
const result = await askUser({ interactive: opts.interactive, ...(opts.bridge ? { bridge: opts.bridge } : {}) }, { question, options, multiSelect });
|
|
1354
|
+
return result.envelope;
|
|
1355
|
+
}
|
|
1356
|
+
async function dispatchSkillTool(name, args, opts) {
|
|
1357
|
+
if (name === 'skills_list') {
|
|
1358
|
+
const scopeArg = optionalString(args, 'scope');
|
|
1359
|
+
const scope = scopeArg === 'global' || scopeArg === 'workspace' ? scopeArg : 'all';
|
|
1360
|
+
const list = skillList({ workspaceRoot: opts.workspaceRoot }, { scope });
|
|
1361
|
+
return JSON.stringify(list);
|
|
1362
|
+
}
|
|
1363
|
+
// name === 'skill' (invoke).
|
|
1364
|
+
// β1a r1 : `skillInvoke` is now async — it re-verifies
|
|
1365
|
+
// the trust manifest sha256 against the on-disk body on every call.
|
|
1366
|
+
// Bubble up `await` so a post-install tamper surfaces as a tool
|
|
1367
|
+
// error the model sees, not a swallowed Promise<SkillInvokeResult>.
|
|
1368
|
+
const skName = requireString(args, 'name');
|
|
1369
|
+
const result = await skillInvoke({ workspaceRoot: opts.workspaceRoot }, { name: skName });
|
|
1370
|
+
return JSON.stringify(result);
|
|
1371
|
+
}
|
|
1372
|
+
async function dispatchWebFetch(args, opts) {
|
|
1373
|
+
const url = requireString(args, 'url');
|
|
1374
|
+
const result = await webFetchTool({ url }, {
|
|
1375
|
+
settings: opts.ctx.settings,
|
|
1376
|
+
allowFetch: opts.allowFetch,
|
|
1377
|
+
});
|
|
1378
|
+
return JSON.stringify(result);
|
|
1379
|
+
}
|
|
1380
|
+
async function dispatchWebSearch(args, opts) {
|
|
1381
|
+
const query = requireString(args, 'query');
|
|
1382
|
+
// `count` is optional integer 1..10. Validate here so the tool layer
|
|
1383
|
+
// gets a clean value (the tool clamps internally too — defense in
|
|
1384
|
+
// depth, since the model can pass anything).
|
|
1385
|
+
let count;
|
|
1386
|
+
if (args['count'] !== undefined && args['count'] !== null) {
|
|
1387
|
+
const n = args['count'];
|
|
1388
|
+
if (typeof n !== 'number' || !Number.isInteger(n)) {
|
|
1389
|
+
throw new Error('web_search: count must be an integer');
|
|
1390
|
+
}
|
|
1391
|
+
count = n;
|
|
1392
|
+
}
|
|
1393
|
+
const result = await webSearchTool({ query, ...(count !== undefined ? { count } : {}) }, {
|
|
1394
|
+
settings: opts.ctx.settings,
|
|
1395
|
+
allowSearch: opts.allowSearch,
|
|
1396
|
+
sessionId: opts.sessionId,
|
|
1397
|
+
});
|
|
1398
|
+
return JSON.stringify(result);
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* β2 S3 dispatch — wire the model-emitted `agent` tool call to the
|
|
1402
|
+
* real subagent spawn primitive. When the executor was built without
|
|
1403
|
+
* `agentDispatch` (e.g. a child loop, or a parent that explicitly
|
|
1404
|
+
* disabled subagent spawn), the call is refused with a structured
|
|
1405
|
+
* envelope so the model can adapt instead of crashing the parent loop.
|
|
1406
|
+
*/
|
|
1407
|
+
async function dispatchAgent(args, opts) {
|
|
1408
|
+
if (!opts) {
|
|
1409
|
+
// No dispatch context — return a structured refusal envelope.
|
|
1410
|
+
// This matches the agent-tool.ts no-engine-client path and lets
|
|
1411
|
+
// the model decide whether to retry inline or abandon the
|
|
1412
|
+
// delegation. Throwing here would terminate the parent on a tool
|
|
1413
|
+
// error frame which is the wrong UX when the issue is config.
|
|
1414
|
+
return JSON.stringify({
|
|
1415
|
+
ok: false,
|
|
1416
|
+
status: 'failed',
|
|
1417
|
+
summary: 'agent tool refused: dispatch not wired in this engine adapter. '
|
|
1418
|
+
+ 'Re-run from a parent loop with agentDispatch configured.',
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
const parsed = parseAgentArgs(args);
|
|
1422
|
+
const result = await agentTool(parsed, {
|
|
1423
|
+
session: opts.parentSession,
|
|
1424
|
+
engineClient: opts.engineClient,
|
|
1425
|
+
...(opts.parentBudgetRemaining
|
|
1426
|
+
? { parentBudgetRemaining: opts.parentBudgetRemaining }
|
|
1427
|
+
: {}),
|
|
1428
|
+
});
|
|
1429
|
+
return JSON.stringify(result);
|
|
1430
|
+
}
|
|
1431
|
+
function parseAgentArgs(args) {
|
|
1432
|
+
// Surface a clean error message to the model when the args don't
|
|
1433
|
+
// match the schema. agentTool itself also validates via Zod; this
|
|
1434
|
+
// pre-parse layer keeps the error stack short.
|
|
1435
|
+
const role = requireString(args, 'role');
|
|
1436
|
+
const brief = requireString(args, 'brief');
|
|
1437
|
+
const isolationRaw = optionalString(args, 'isolation');
|
|
1438
|
+
const out = {
|
|
1439
|
+
role: role,
|
|
1440
|
+
brief,
|
|
1441
|
+
...(isolationRaw ? { isolation: isolationRaw } : {}),
|
|
1442
|
+
};
|
|
1443
|
+
return out;
|
|
1444
|
+
}
|
|
1445
|
+
function optionalString(obj, key) {
|
|
1446
|
+
const v = obj[key];
|
|
1447
|
+
if (v === undefined || v === null)
|
|
1448
|
+
return undefined;
|
|
1449
|
+
if (typeof v !== 'string') {
|
|
1450
|
+
throw new Error(`tool argument "${key}" must be a string when present`);
|
|
1451
|
+
}
|
|
1452
|
+
return v;
|
|
1453
|
+
}
|
|
1454
|
+
function optionalNumber(obj, key) {
|
|
1455
|
+
const v = obj[key];
|
|
1456
|
+
if (v === undefined || v === null)
|
|
1457
|
+
return undefined;
|
|
1458
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
|
1459
|
+
throw new Error(`tool argument "${key}" must be a finite number when present`);
|
|
1460
|
+
}
|
|
1461
|
+
return v;
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* β7 L5+T11: dispatch the model-emitted `multi_edit` tool call. The
|
|
1465
|
+
* tool returns a structured result envelope; we serialize it to JSON
|
|
1466
|
+
* for the engine loop. A refused dispatch (security, no_match,
|
|
1467
|
+
* ambiguous_match, etc.) surfaces as `ok: false` in the envelope —
|
|
1468
|
+
* the model can re-strategise rather than crashing the loop.
|
|
1469
|
+
*/
|
|
1470
|
+
function dispatchMultiEdit(args, ctx) {
|
|
1471
|
+
const raw = args['edits'];
|
|
1472
|
+
if (!Array.isArray(raw)) {
|
|
1473
|
+
throw new Error('multi_edit: edits must be an array');
|
|
1474
|
+
}
|
|
1475
|
+
const edits = raw.map((item, i) => {
|
|
1476
|
+
if (!item || typeof item !== 'object') {
|
|
1477
|
+
throw new Error(`multi_edit: edits[${i}] must be an object`);
|
|
1478
|
+
}
|
|
1479
|
+
const obj = item;
|
|
1480
|
+
const file = obj['file'];
|
|
1481
|
+
const oldString = obj['oldString'];
|
|
1482
|
+
const newString = obj['newString'];
|
|
1483
|
+
if (typeof file !== 'string') {
|
|
1484
|
+
throw new Error(`multi_edit: edits[${i}].file must be a string`);
|
|
1485
|
+
}
|
|
1486
|
+
if (typeof oldString !== 'string') {
|
|
1487
|
+
throw new Error(`multi_edit: edits[${i}].oldString must be a string`);
|
|
1488
|
+
}
|
|
1489
|
+
if (typeof newString !== 'string') {
|
|
1490
|
+
throw new Error(`multi_edit: edits[${i}].newString must be a string`);
|
|
1491
|
+
}
|
|
1492
|
+
return { file, oldString, newString };
|
|
1493
|
+
});
|
|
1494
|
+
const result = multiEdit(ctx, edits);
|
|
1495
|
+
return JSON.stringify(result);
|
|
1496
|
+
}
|
|
1497
|
+
/* ---------------------------- hook ---------------------------- */
|
|
1498
|
+
/**
|
|
1499
|
+
* Tool names that mutate workspace files. After a successful dispatch
|
|
1500
|
+
* of any of these, the L15 post-edit diagnostics hook fires. The set
|
|
1501
|
+
* is intentionally tight — `task_*` / `todo_write` write to ledger
|
|
1502
|
+
* files (not workspace source) so they stay out, and `bash` is too
|
|
1503
|
+
* coarse (a `bash` call can write any path, and we'd need to parse
|
|
1504
|
+
* the command to know which — out of scope for L15).
|
|
1505
|
+
*/
|
|
1506
|
+
const POST_EDIT_TOOLS = new Set(['edit', 'write', 'multi_edit']);
|
|
1507
|
+
/**
|
|
1508
|
+
* Append LSP diagnostics to the tool envelope after a successful
|
|
1509
|
+
* edit / write / multi_edit. Silent skip is the default — missing
|
|
1510
|
+
* binary, unsupported language, request timeout, and "no diagnostics"
|
|
1511
|
+
* all leave the envelope unchanged.
|
|
1512
|
+
*
|
|
1513
|
+
* Opt-in via `.pugi/settings.json::lsp.postEditDiagnostics = true`
|
|
1514
|
+
* OR `PUGI_LSP_POST_EDIT=1`. Off by default until dogfood validates
|
|
1515
|
+
* the cold-start cost vs the model-loop benefit ().
|
|
1516
|
+
*/
|
|
1517
|
+
async function appendPostEditDiagnostics(name, args, ctx, result) {
|
|
1518
|
+
if (!POST_EDIT_TOOLS.has(name))
|
|
1519
|
+
return result;
|
|
1520
|
+
if (!isPostEditEnabled(ctx))
|
|
1521
|
+
return result;
|
|
1522
|
+
const paths = extractEditedPaths(name, args);
|
|
1523
|
+
if (paths.length === 0)
|
|
1524
|
+
return result;
|
|
1525
|
+
const tails = [];
|
|
1526
|
+
for (const filePath of paths) {
|
|
1527
|
+
const opts = {
|
|
1528
|
+
cwd: ctx.root,
|
|
1529
|
+
...(ctx.settings.lsp ? { lspSettings: ctx.settings.lsp } : {}),
|
|
1530
|
+
};
|
|
1531
|
+
try {
|
|
1532
|
+
const diag = await runPostEditDiagnostics(filePath, opts);
|
|
1533
|
+
if (!diag.skip) {
|
|
1534
|
+
tails.push(diag.tail);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
catch {
|
|
1538
|
+
// Belt-and-suspenders: any unexpected throw from the hook is
|
|
1539
|
+
// swallowed. The model never blocks on LSP.
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
if (tails.length === 0)
|
|
1543
|
+
return result;
|
|
1544
|
+
return `${result}\n${tails.join('\n')}`;
|
|
1545
|
+
}
|
|
1546
|
+
function isPostEditEnabled(ctx) {
|
|
1547
|
+
const envFlag = process.env.PUGI_LSP_POST_EDIT;
|
|
1548
|
+
if (envFlag === '1' || envFlag === 'true')
|
|
1549
|
+
return true;
|
|
1550
|
+
if (envFlag === '0' || envFlag === 'false')
|
|
1551
|
+
return false;
|
|
1552
|
+
return ctx.settings.lsp?.postEditDiagnostics === true;
|
|
1553
|
+
}
|
|
1554
|
+
/**
|
|
1555
|
+
* Pull the workspace-relative file path(s) the tool just touched.
|
|
1556
|
+
* Each branch mirrors the args shape its `dispatch*` handler reads;
|
|
1557
|
+
* a deformed args object yields an empty list so the hook silently
|
|
1558
|
+
* skips instead of throwing inside the augmentation layer.
|
|
1559
|
+
*/
|
|
1560
|
+
function extractEditedPaths(name, args) {
|
|
1561
|
+
if (name === 'edit' || name === 'write') {
|
|
1562
|
+
const path = args['path'];
|
|
1563
|
+
return typeof path === 'string' && path.length > 0 ? [path] : [];
|
|
1564
|
+
}
|
|
1565
|
+
if (name === 'multi_edit') {
|
|
1566
|
+
const edits = args['edits'];
|
|
1567
|
+
if (!Array.isArray(edits))
|
|
1568
|
+
return [];
|
|
1569
|
+
const seen = new Set();
|
|
1570
|
+
for (const entry of edits) {
|
|
1571
|
+
if (!entry || typeof entry !== 'object')
|
|
1572
|
+
continue;
|
|
1573
|
+
const file = entry['file'];
|
|
1574
|
+
if (typeof file === 'string' && file.length > 0)
|
|
1575
|
+
seen.add(file);
|
|
1576
|
+
}
|
|
1577
|
+
return Array.from(seen);
|
|
1578
|
+
}
|
|
1579
|
+
return [];
|
|
1580
|
+
}
|
|
345
1581
|
//# sourceMappingURL=tool-bridge.js.map
|