@pugi/cli 0.1.0-beta.8 → 0.1.0-beta.88
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 +132 -0
- package/LICENSE +1 -1
- 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 +3 -3
- 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 +151 -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 +298 -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 +36 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +4203 -493
- 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 +73 -39
- 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/sigint-guard.js +272 -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 +288 -0
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/bash.js +624 -46
- 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-chips.js +257 -0
- 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 +25 -7
- 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,25 +1,54 @@
|
|
|
1
1
|
import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { resolve } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
+
import { AsyncEventQueue, EngineEventEmitter, modelSupportsThinking, runEngineLoop, splitThinkingBlocks, } from '@pugi/sdk';
|
|
4
5
|
import { FileReadCache } from '../file-cache.js';
|
|
5
6
|
import { loadSettings } from '../settings.js';
|
|
6
7
|
import { openSession, recordToolCall, recordToolResult } from '../session.js';
|
|
8
|
+
import { prewarmRealDispatch } from '../subagents/dispatcher.js';
|
|
9
|
+
import { resolveAutoCompactConfig, resolveBudget } from './budgets.js';
|
|
10
|
+
import { maybeCompact } from './auto-compact.js';
|
|
11
|
+
import { writeAuditEvent } from '../audit/audit-trail.js';
|
|
7
12
|
import { buildExecutor, buildToolsSchema } from './tool-bridge.js';
|
|
8
13
|
import { personaSlugFor, systemPromptFor } from './prompts.js';
|
|
14
|
+
import { CancellationToken } from '../repl/cancellation.js';
|
|
15
|
+
import { fireTaskCompletedChain } from '../hook-chains.js';
|
|
16
|
+
// β5a R5+R6 + P1 : per-turn `<context>` prefix + intent
|
|
17
|
+
// classifier marker. Both pure functions, no fs cost at adapter init.
|
|
18
|
+
// Per-dir markdown traverse fires once per `run()`; budget capped so
|
|
19
|
+
// it never dominates the prompt budget.
|
|
20
|
+
import { buildContextPrefix, spliceContextPrefix } from './context-prefix.js';
|
|
21
|
+
import { applyIntentMarker, classifyIntent } from './intent.js';
|
|
22
|
+
import { loadTraversedMarkdown } from '../context/markdown-traverse.js';
|
|
23
|
+
import { isBareMode } from '../bare-mode/index.js';
|
|
24
|
+
import { walkUpPugiMd } from '../pugi-md/walk-up.js';
|
|
25
|
+
import { renderAmbientContext } from '../pugi-md/context-injector.js';
|
|
26
|
+
// Backlog : `@import` + `paths:` glob loader.
|
|
27
|
+
// Runs over each `HierarchyFile` the walker returns to expand imports
|
|
28
|
+
// (capped + cycle-safe) and capture per-rule `paths:` frontmatter. The
|
|
29
|
+
// loader is pure-fs so it cannot break the engine loop — any failure
|
|
30
|
+
// degrades to "no expansion for this file" and the un-expanded walker
|
|
31
|
+
// body is used as the rule body.
|
|
32
|
+
import { loadRulesFile } from '../pugi-md/cc-compat-rules.js';
|
|
33
|
+
import { homedir as osHomedir } from 'node:os';
|
|
34
|
+
// L11 : per-session DenialTrackingState. One instance
|
|
35
|
+
// per `run()` so denials cluster by (tool, args) within the same
|
|
36
|
+
// command but do NOT leak across CLI invocations.
|
|
37
|
+
import { DenialTrackingState } from '../denial-tracking/state.js';
|
|
9
38
|
/**
|
|
10
39
|
* Real `NativePugiEngineAdapter`. Drives the Pugi CLI's tool-use loop:
|
|
11
40
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
41
|
+
* 1. Pick a system prompt + persona based on the task kind
|
|
42
|
+
* (code/explain/fix/plan/build).
|
|
43
|
+
* 2. Build an OpenAI-shaped tools schema from the local tool registry,
|
|
44
|
+
* gated by plan-mode (read-only).
|
|
45
|
+
* 3. Open a workspace tool context (settings, session, read cache).
|
|
46
|
+
* 4. Drive `runEngineLoop` against an `EngineLoopClient` until the
|
|
47
|
+
* model returns a final text answer or the per-command budget is
|
|
48
|
+
* exhausted.
|
|
49
|
+
* 5. Surface every turn / tool call into both the engine event stream
|
|
50
|
+
* (consumer-visible status events) and the existing session log
|
|
51
|
+
* (`.pugi/events.jsonl`) so audit replay sees every step.
|
|
23
52
|
*
|
|
24
53
|
* The adapter is intentionally transport-agnostic. `client` is required
|
|
25
54
|
* at construction; the CLI builds an `AnvilEngineLoopClient` from the
|
|
@@ -28,12 +57,12 @@ import { personaSlugFor, systemPromptFor } from './prompts.js';
|
|
|
28
57
|
* up so unit tests can construct the adapter with an in-memory client.
|
|
29
58
|
*
|
|
30
59
|
* The engine task → loop mapping:
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
60
|
+
* - `task.kind === 'build_task'` is mapped to the `build` command.
|
|
61
|
+
* - `task.prompt` is the user message.
|
|
62
|
+
* - `task.workspaceRoot` pins the workspace root for tool execution.
|
|
63
|
+
* - `task.permissionMode` is read by the existing permission module;
|
|
64
|
+
* the adapter itself only enforces the plan-mode tool gate which is
|
|
65
|
+
* keyed on `kind`, not on permissionMode.
|
|
37
66
|
*/
|
|
38
67
|
export class NativePugiEngineAdapter {
|
|
39
68
|
options;
|
|
@@ -41,7 +70,7 @@ export class NativePugiEngineAdapter {
|
|
|
41
70
|
/**
|
|
42
71
|
* Per-adapter scratch map: links the loop's tool_call id to the
|
|
43
72
|
* audit record id returned by `recordToolCall`. Code Reviewer P2
|
|
44
|
-
* retro
|
|
73
|
+
* retro moved this off the module scope — two adapters
|
|
45
74
|
* driven concurrently (cabinet UI + CLI on the same process) would
|
|
46
75
|
* otherwise share the same Map and a fast turn from adapter A
|
|
47
76
|
* could `.delete()` an entry that belonged to adapter B before its
|
|
@@ -50,8 +79,30 @@ export class NativePugiEngineAdapter {
|
|
|
50
79
|
* to a single `run()` invocation.
|
|
51
80
|
*/
|
|
52
81
|
engineToolCallIds = new Map();
|
|
82
|
+
/**
|
|
83
|
+
* β3 streaming additive: optional typed event emitter that mirrors
|
|
84
|
+
* every async-queue event so external consumers (admin-api SSE
|
|
85
|
+
* controller, future cabinet WebSocket relay) can attach without
|
|
86
|
+
* holding the async iterator. The CLI itself only consumes the
|
|
87
|
+
* `AsyncIterable<EngineEvent>` returned by `run()`; the emitter is
|
|
88
|
+
* a fan-out point for additional subscribers.
|
|
89
|
+
*/
|
|
90
|
+
streamEmitter = new EngineEventEmitter();
|
|
53
91
|
constructor(options) {
|
|
54
92
|
this.options = options;
|
|
93
|
+
// β2a r1 (Backend Architect P1): kick off the real
|
|
94
|
+
// dispatcher's module import at adapter init so the first
|
|
95
|
+
// `agent` tool call does not pay 50-200ms cold-start. We fire
|
|
96
|
+
// the promise without awaiting — by the time the engine loop
|
|
97
|
+
// runs and the model issues an `agent` call, the import has
|
|
98
|
+
// resolved. The promise is swallowed because a failed prewarm
|
|
99
|
+
// would surface again at dispatch time with the real error.
|
|
100
|
+
void prewarmRealDispatch().catch(() => {
|
|
101
|
+
// Intentional no-op: the actual dispatch call will surface
|
|
102
|
+
// the import failure (if any) with the right call stack. A
|
|
103
|
+
// prewarm-time failure is just a missed optimization, not a
|
|
104
|
+
// correctness issue.
|
|
105
|
+
});
|
|
55
106
|
}
|
|
56
107
|
async capabilities() {
|
|
57
108
|
return {
|
|
@@ -59,7 +110,13 @@ export class NativePugiEngineAdapter {
|
|
|
59
110
|
supportsFileEdits: true,
|
|
60
111
|
supportsShell: true,
|
|
61
112
|
supportsLsp: false,
|
|
62
|
-
|
|
113
|
+
// β2 S2 : real subagent dispatch shipped via the
|
|
114
|
+
// `agent` tool (apps/pugi-cli/src/tools/agent-tool.ts) plus the
|
|
115
|
+
// genuine `runEngineLoop`-backed dispatcher
|
|
116
|
+
// (apps/pugi-cli/src/core/subagents/dispatcher-real.ts). The
|
|
117
|
+
// capability flag flips after S1 + S3 + S4 land so cabinet UI +
|
|
118
|
+
// remote orchestrators can rely on the advertised contract.
|
|
119
|
+
supportsSubagents: true,
|
|
63
120
|
};
|
|
64
121
|
}
|
|
65
122
|
async *run(task, ctx) {
|
|
@@ -67,235 +124,993 @@ export class NativePugiEngineAdapter {
|
|
|
67
124
|
const root = task.workspaceRoot;
|
|
68
125
|
const session = this.options.session ?? openSession(root);
|
|
69
126
|
const settings = loadSettings(root);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
127
|
+
// P1 fix (deep audit): wire ctx.signal (AbortSignal) into
|
|
128
|
+
// a CancellationToken so the tool-bridge cancellation gate
|
|
129
|
+
// (`ctx.cancellation?.isAborted` check at tool-bridge.ts:656 +
|
|
130
|
+
// file-tools `gateOnCancellation` calls) fires when the operator
|
|
131
|
+
// aborts mid-tool. Before this fix `toolCtx` carried no cancellation
|
|
132
|
+
// field — only the next runEngineLoop iteration via `ctx.signal`
|
|
133
|
+
// aborted at the turn boundary, so a long-running tool (a sleeping
|
|
134
|
+
// bash command, a slow grep across the repo) could not be cancelled
|
|
135
|
+
// mid-call.
|
|
136
|
+
//
|
|
137
|
+
// The token is wired one-way: ctx.signal -> token. Aborting the
|
|
138
|
+
// token directly does NOT propagate back to the AbortSignal; the
|
|
139
|
+
// engine's own cancellation already lives upstream via the signal
|
|
140
|
+
// so the back-edge is unnecessary.
|
|
141
|
+
//
|
|
142
|
+
// r2 fix (triple-review P1): the abort listener was
|
|
143
|
+
// registered with `{ once: true }` — on actual abort it auto-detaches
|
|
144
|
+
// and disappears, but on the (common) NON-abort path where `run()`
|
|
145
|
+
// completes cleanly the listener stays attached to `ctx.signal`
|
|
146
|
+
// forever. Over a long REPL session (one shared AbortController per
|
|
147
|
+
// session, many run() invocations) listeners accumulate one per
|
|
148
|
+
// run, leaking memory and CPU on `dispatchEvent`. We now track the
|
|
149
|
+
// detach handle and call it unconditionally in the run()'s finally
|
|
150
|
+
// block so cleanup happens on both the success and abort paths.
|
|
151
|
+
const cancellation = new CancellationToken();
|
|
152
|
+
let detachAbortListener;
|
|
153
|
+
if (ctx.signal) {
|
|
154
|
+
if (ctx.signal.aborted) {
|
|
155
|
+
cancellation.abort();
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
const handler = () => cancellation.abort();
|
|
159
|
+
ctx.signal.addEventListener('abort', handler, { once: true });
|
|
160
|
+
detachAbortListener = () => {
|
|
161
|
+
ctx.signal.removeEventListener('abort', handler);
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// r2 (triple-review P1): everything below runs inside a
|
|
166
|
+
// try/finally so the AbortSignal listener detaches on BOTH the
|
|
167
|
+
// success and abort paths. Without this wrap a long REPL session
|
|
168
|
+
// (one persistent AbortController, many run() invocations) leaked
|
|
169
|
+
// one abort listener per non-aborted run.
|
|
170
|
+
//
|
|
171
|
+
// #24 (CEO P1) — TaskCompleted chain. We
|
|
172
|
+
// capture `taskStartedAt` BEFORE the try block so the duration
|
|
173
|
+
// measured by the chain payload covers the full dispatch wall
|
|
174
|
+
// time (including the abort-listener wiring above). The
|
|
175
|
+
// `fireTaskCompletedOnce` guard ensures the chain fires at most
|
|
176
|
+
// once per `run()` invocation even when multiple `yield result`
|
|
177
|
+
// sites are reached (defensive — the existing flow yields exactly
|
|
178
|
+
// one result, but a future code path that yields twice would
|
|
179
|
+
// double-fire otherwise).
|
|
180
|
+
const taskStartedAt = Date.now();
|
|
181
|
+
let taskCompletedFired = false;
|
|
182
|
+
const fireTaskCompletedOnce = async (exitCode, toolCalls, filesChangedList) => {
|
|
183
|
+
if (taskCompletedFired)
|
|
184
|
+
return;
|
|
185
|
+
taskCompletedFired = true;
|
|
186
|
+
try {
|
|
187
|
+
await fireTaskCompletedChain(root, {
|
|
188
|
+
command: kind,
|
|
189
|
+
exitCode,
|
|
190
|
+
durationMs: Date.now() - taskStartedAt,
|
|
191
|
+
toolCalls,
|
|
192
|
+
filesChanged: [...filesChangedList],
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
catch (chainError) {
|
|
196
|
+
process.stderr.write(`[pugi hook-chains] TaskCompleted chain crashed: ${chainError.message}\n`);
|
|
83
197
|
}
|
|
84
|
-
: defaultEngineBudgets[kind];
|
|
85
|
-
yield {
|
|
86
|
-
type: 'status',
|
|
87
|
-
message: `Pugi engine starting: kind=${kind} budget=${budget.maxToolCalls} calls / ${budget.maxTokens} tokens`,
|
|
88
198
|
};
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
199
|
+
try {
|
|
200
|
+
const toolCtx = {
|
|
201
|
+
root,
|
|
202
|
+
settings,
|
|
203
|
+
session,
|
|
204
|
+
readCache: new FileReadCache(),
|
|
205
|
+
cancellation,
|
|
206
|
+
};
|
|
207
|
+
// L11 : instantiate per-`run()` denial tracker. The
|
|
208
|
+
// executor records every refusal (PLAN_MODE_REFUSED, HOOK_BLOCKED,
|
|
209
|
+
// OPERATOR_ABORTED, STALE_READ, unknown-tool, plan-mode agent) and
|
|
210
|
+
// the user-prompt assembler below splices a compact reminder when
|
|
211
|
+
// the same (tool, args) pair has been denied twice or more. The
|
|
212
|
+
// tracker is in-memory only — the audit ledger at
|
|
213
|
+
// `.pugi/events.jsonl` already captures the full per-event log for
|
|
214
|
+
// forensic replay; this surface is the model-facing aggregate.
|
|
215
|
+
const denialTracking = new DenialTrackingState();
|
|
216
|
+
// β1a r1 (budget wiring): swap the legacy SDK per-
|
|
217
|
+
// command budget lookup for the Pl9 `resolveBudget()` pipeline so
|
|
218
|
+
// `.pugi/settings.json::budgets.<command>` overrides actually take
|
|
219
|
+
// effect at runtime + the HARD_MAX_* caps guard misconfigured
|
|
220
|
+
// envelopes pre-flight. Before this fix the β1 Pl9 module
|
|
221
|
+
// (`core/engine/budgets.ts`) was dead code — the adapter still
|
|
222
|
+
// read the per-command defaults from the SDK, so operators who
|
|
223
|
+
// set `budgets.code.maxTokens = 50000` in settings.json got the
|
|
224
|
+
// legacy 30k anyway and `assertBudgetWithinTier` never ran.
|
|
225
|
+
//
|
|
226
|
+
// Task-level token override (e.g. CLI `--max-tokens`) keeps
|
|
227
|
+
// precedence; tool-call ceiling falls through to the resolved
|
|
228
|
+
// budget so a careless caller cannot disable the call-count
|
|
229
|
+
// guard by setting only token count.
|
|
230
|
+
//
|
|
231
|
+
// Triple-review P1 follow-up : forward `task.budget.turns`
|
|
232
|
+
// through the resolver so `EngineBudget.maxTurns` actually lands on
|
|
233
|
+
// the SDK's `runEngineLoop`. The CLI seam packs both `--max-turns`
|
|
234
|
+
// (explicit operator override) and the intensity profile's per-tier
|
|
235
|
+
// cap into this field with explicit-flag-wins precedence.
|
|
236
|
+
const taskBudgetOverride = {};
|
|
237
|
+
if (task.budget?.tokens)
|
|
238
|
+
taskBudgetOverride.maxTokens = task.budget.tokens;
|
|
239
|
+
if (task.budget?.turns !== undefined)
|
|
240
|
+
taskBudgetOverride.maxTurns = task.budget.turns;
|
|
241
|
+
const budget = resolveBudget(kind, settings, Object.keys(taskBudgetOverride).length > 0 ? taskBudgetOverride : undefined);
|
|
242
|
+
// CEO P1 #14 (auto-compact): resolve the per-workspace
|
|
243
|
+
// override of the 75% threshold gate. Default is `{ enabled: true,
|
|
244
|
+
// thresholdRatio: 0.75 }`; operators kill it via
|
|
245
|
+
// `.pugi/settings.json::autoCompact.enabled = false` или retune the
|
|
246
|
+
// ratio. The resolved config is captured by the closure that
|
|
247
|
+
// `runEngineLoop` invokes pre-send on every turn.
|
|
248
|
+
const autoCompactConfig = resolveAutoCompactConfig(settings);
|
|
249
|
+
// β3 streaming: pre-build the typed stream event queue so the hook
|
|
250
|
+
// callbacks below can push live events that this async generator
|
|
251
|
+
// yields IMMEDIATELY (instead of buffering until `runEngineLoop`
|
|
252
|
+
// completes). Operator now sees the first `tool.start` within
|
|
253
|
+
// ~tens of ms of the model emitting it, not 30+ s after the loop
|
|
254
|
+
// settles.
|
|
255
|
+
const streamQueue = new AsyncEventQueue();
|
|
256
|
+
const emitter = this.streamEmitter;
|
|
257
|
+
const supportsThinking = modelSupportsThinking(this.options.model);
|
|
258
|
+
/**
|
|
259
|
+
* Push one typed stream event into BOTH the per-run async queue
|
|
260
|
+
* (the CLI's iterator) and the long-lived emitter (the multiplex
|
|
261
|
+
* fan-out for admin-api SSE / cabinet WebSocket subscribers).
|
|
262
|
+
* The function stamps `timestamp` once so both consumers see the
|
|
263
|
+
* same wall clock.
|
|
264
|
+
*/
|
|
265
|
+
const emitStream = (event) => {
|
|
266
|
+
const stamped = {
|
|
267
|
+
...event,
|
|
268
|
+
timestamp: new Date().toISOString(),
|
|
269
|
+
};
|
|
270
|
+
streamQueue.push(stamped);
|
|
271
|
+
emitter.emit('event', stamped);
|
|
272
|
+
};
|
|
273
|
+
// r1 fix per triple-review Backend Architect P1: unify yield path via
|
|
274
|
+
// emitStream + streamQueue drain so the iterator consumer does NOT
|
|
275
|
+
// see this status frame twice. Pre-fix did both bare yield + emitStream
|
|
276
|
+
// → iterator got 2 copies, emitter got 1.
|
|
277
|
+
emitStream({
|
|
278
|
+
type: 'status',
|
|
279
|
+
message: `Pugi engine starting: kind=${kind} budget=${budget.maxToolCalls} calls / ${budget.maxTokens} tokens`,
|
|
280
|
+
});
|
|
281
|
+
// #21 : emit `dispatch_start` to the
|
|
282
|
+
// tenant-wide audit trail at `~/.pugi/audit/<tenant>/<slug>-<hash>
|
|
283
|
+
// .jsonl`. Append-only, never throws — a misconfigured audit
|
|
284
|
+
// surface must not block a dispatch. The per-session mirror under
|
|
285
|
+
// `.pugi/sessions/<id>/events.jsonl` remains as a redundant copy.
|
|
286
|
+
writeAuditEvent({
|
|
287
|
+
event: 'dispatch_start',
|
|
288
|
+
sessionId: session.id,
|
|
289
|
+
workspaceRoot: root,
|
|
290
|
+
data: {
|
|
291
|
+
kind,
|
|
292
|
+
promptLength: task.prompt.length,
|
|
293
|
+
maxToolCalls: budget.maxToolCalls,
|
|
294
|
+
maxTokens: budget.maxTokens,
|
|
295
|
+
model: this.options.model ?? null,
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
// β5a R1+R4+R5+R6+P1 : build the per-turn `<context>`
|
|
299
|
+
// prefix and apply the intent marker so the model sees:
|
|
300
|
+
// 1. cwd + open-files + per-dir-conventions block (R5+R6)
|
|
301
|
+
// 2. a `<intent kind="definitional">` wrapper when the operator
|
|
302
|
+
// asked a knowledge question (P1) — fixes the "What is grep?
|
|
303
|
+
// → bash man grep" loss mode flagged by the .X eval.
|
|
304
|
+
//
|
|
305
|
+
// All caps enforced inside the builders (5 KB block + 50 entries
|
|
306
|
+
// + top-3 markdown). Worst-case prompt growth is ~5 KB, well
|
|
307
|
+
// inside any per-command token budget.
|
|
308
|
+
//
|
|
309
|
+
// cwd is sourced from `process.cwd()` — the operator's shell pwd
|
|
310
|
+
// when they invoked `pugi`. For non-REPL CLI paths this is
|
|
311
|
+
// accurate; the REPL session retains the launch cwd for the
|
|
312
|
+
// lifetime of the session which is what the operator expects.
|
|
313
|
+
const cwdForTraverse = process.cwd();
|
|
314
|
+
// cwd → homedir walk-up that picks up every
|
|
315
|
+
// ambient `PUGI.md` (or `CLAUDE.md` as a fallback) the operator
|
|
316
|
+
// has placed above their workspace. This is the cross-project
|
|
317
|
+
// hierarchy walk — distinct from the workspace-bounded
|
|
318
|
+
// `loadTraversedMarkdown` below which only sees files INSIDE the
|
|
319
|
+
// workspace root. Render the concatenation once at session boot
|
|
320
|
+
// and prepend to the system prompt so the model treats the
|
|
321
|
+
// operator's personal guidance as ambient context for the whole
|
|
322
|
+
// session. `--bare` () skips this walk entirely.
|
|
323
|
+
let ambientContextBlock = '';
|
|
324
|
+
if (!isBareMode()) {
|
|
325
|
+
try {
|
|
326
|
+
const hierarchy = walkUpPugiMd(cwdForTraverse);
|
|
327
|
+
// Backlog : expand `@import` directives and
|
|
328
|
+
// capture `paths:` frontmatter for each ambient file. The
|
|
329
|
+
// walker already returned the raw bodies; the loader replaces
|
|
330
|
+
// each body with its `@import`-expanded variant + appends any
|
|
331
|
+
// imported children at the same hierarchy level. Failures are
|
|
332
|
+
// localised per-file so one malformed `~/CLAUDE.md` cannot
|
|
333
|
+
// break the rest of the chain.
|
|
334
|
+
const expanded = await expandHierarchyWithImports(hierarchy, cwdForTraverse);
|
|
335
|
+
ambientContextBlock = renderAmbientContext(expanded);
|
|
132
336
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
337
|
+
catch {
|
|
338
|
+
// Pure FS surface — if it throws (programmer error in the
|
|
339
|
+
// walker, not a per-file fs error which is already swallowed
|
|
340
|
+
// inside) we drop ambient context for this session rather
|
|
341
|
+
// than crashing the engine loop. Doctor probe still surfaces
|
|
342
|
+
// the hierarchy state for operator triage.
|
|
343
|
+
ambientContextBlock = '';
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// AST-light repo-map injection. We build a
|
|
347
|
+
// compact `## Repo map` block (capped at the formatter's default
|
|
348
|
+
// 8 KB ≈ 2K tokens) from the workspace source tree + splice it
|
|
349
|
+
// onto the system prompt alongside the ambient PUGI.md block.
|
|
350
|
+
// `--bare` skips this exactly like the PUGI.md walk — the engine
|
|
351
|
+
// sees nothing the operator did not explicitly hand it. The build
|
|
352
|
+
// is deferred к `setImmediate` semantics by being a sync call
|
|
353
|
+
// AFTER the boot probes; the cost is one stat per source file
|
|
354
|
+
// (the cache catches mtime-unchanged files и skips re-extraction).
|
|
355
|
+
// Failures are swallowed: repo-map is enrichment, never a gate.
|
|
356
|
+
let repoMapBlock = '';
|
|
357
|
+
if (!isBareMode()) {
|
|
358
|
+
try {
|
|
359
|
+
const { buildAndFormatRepoMap } = await import('../repo-map/build.js');
|
|
360
|
+
const verdict = buildAndFormatRepoMap({
|
|
361
|
+
root,
|
|
362
|
+
// Boot path is best-effort: never refresh during engine boot
|
|
363
|
+
// (the operator can `pugi repo-map --refresh` manually). The
|
|
364
|
+
// cache freshness check catches every realistic edit pattern
|
|
365
|
+
// and avoids walking the tree on every engine invocation.
|
|
366
|
+
refresh: false,
|
|
367
|
+
// Persist the cache so the next boot reuses extracts. Engine
|
|
368
|
+
// boot runs on every command, so missing the persist would
|
|
369
|
+
// hot-loop the extractor on each invocation.
|
|
370
|
+
writeCache: true,
|
|
371
|
+
// Omit the formatter's section header — the system prompt
|
|
372
|
+
// already structures the ambient blocks, и a second `##`
|
|
373
|
+
// would fragment the prompt cache на a model-by-model basis.
|
|
374
|
+
omitHeader: false,
|
|
137
375
|
});
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
376
|
+
if (verdict.build.ok && verdict.format && verdict.format.bytes > 0) {
|
|
377
|
+
repoMapBlock = verdict.format.text;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
// Any failure in the repo-map pipeline drops the block. The
|
|
382
|
+
// engine continues without enrichment — the failure mode is
|
|
383
|
+
// identical to the cold-boot path before L28 landed.
|
|
384
|
+
repoMapBlock = '';
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
let traverseResult;
|
|
388
|
+
// `--bare` skips the parent-dir PUGI.md /
|
|
389
|
+
// AGENTS.md / CLAUDE.md / GEMINI.md walk-up. The engine sees only
|
|
390
|
+
// the operator's prompt + working-set + intent marker, with no
|
|
391
|
+
// ambient project context injection. Mirrors the standard tool's
|
|
392
|
+
// --bare semantics.
|
|
393
|
+
if (isBareMode()) {
|
|
394
|
+
traverseResult = { loaded: [], warnings: [], totalBytes: 0 };
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
try {
|
|
398
|
+
traverseResult = await loadTraversedMarkdown({
|
|
399
|
+
cwd: cwdForTraverse,
|
|
400
|
+
workspaceRoot: root,
|
|
144
401
|
});
|
|
145
402
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
// Stash the audit id on the call for `onToolResult` to close.
|
|
152
|
-
this.engineToolCallIds.set(call.id, id);
|
|
153
|
-
// Extract a candidate path for write/edit so we can build the
|
|
154
|
-
// filesChanged summary if (and only if) the call succeeds. Bad
|
|
155
|
-
// JSON is harmless here — we ignore it and the executor surfaces
|
|
156
|
-
// the actual parse error to the model.
|
|
157
|
-
if (call.name === 'write' || call.name === 'edit') {
|
|
158
|
-
const path = extractPathArg(call.arguments);
|
|
159
|
-
if (path)
|
|
160
|
-
pendingMutations.set(call.id, path);
|
|
403
|
+
catch {
|
|
404
|
+
// Per-dir markdown is a NICE-TO-HAVE; a fs error here must
|
|
405
|
+
// never break the engine loop. Fall back to an empty result
|
|
406
|
+
// so the prefix block still surfaces cwd + working set.
|
|
407
|
+
traverseResult = { loaded: [], warnings: [], totalBytes: 0 };
|
|
161
408
|
}
|
|
162
|
-
|
|
409
|
+
}
|
|
410
|
+
const intentClassification = classifyIntent(task.prompt);
|
|
411
|
+
const intentHint = intentClassification.intent !== 'ambiguous' ? intentClassification.intent : undefined;
|
|
412
|
+
const cwdRelative = relativeOrAbsolute(root, cwdForTraverse);
|
|
413
|
+
const prefix = buildContextPrefix({
|
|
414
|
+
cwdRelative,
|
|
415
|
+
// β5a defers wiring the live WorkingSet snapshot to the REPL
|
|
416
|
+
// session integration (R5+R6 here only covers the engine-side
|
|
417
|
+
// builder). When the REPL passes its working set down, the
|
|
418
|
+
// engine surface fills in. For now the prefix carries cwd +
|
|
419
|
+
// per-dir conventions + intent which are the two biggest
|
|
420
|
+
// win-rate moves per the .X eval.
|
|
421
|
+
traversedMarkdown: traverseResult.loaded,
|
|
422
|
+
intentHint,
|
|
423
|
+
});
|
|
424
|
+
if (prefix.bytes > 0 || intentClassification.intent === 'definitional') {
|
|
425
|
+
emitStream({
|
|
163
426
|
type: 'status',
|
|
164
|
-
message: `
|
|
427
|
+
message: `context: cwd=${cwdRelative} per-dir-md=${prefix.counts.markdownIncluded}/${prefix.counts.markdownTotal} intent=${intentClassification.intent}`,
|
|
165
428
|
});
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
429
|
+
}
|
|
430
|
+
const decoratedPrompt = applyIntentMarker(task.prompt, intentClassification.intent);
|
|
431
|
+
const finalUserPrompt = spliceContextPrefix(prefix.block, decoratedPrompt);
|
|
432
|
+
// Track files mutated by the loop. We extract the path from the JSON
|
|
433
|
+
// arguments of every successful write/edit tool call; `bash` is left
|
|
434
|
+
// out because its filesystem footprint is opaque (a single command
|
|
435
|
+
// can touch dozens of paths via `make`, `pnpm build`, etc). The
|
|
436
|
+
// per-session events.jsonl already carries every file_mutation event
|
|
437
|
+
// for replay; this set is only the headline summary the CLI prints.
|
|
438
|
+
const filesChanged = new Set();
|
|
439
|
+
// Pending lookup: call.id → path extracted from arguments. We only
|
|
440
|
+
// commit to `filesChanged` when the corresponding onToolResult fires
|
|
441
|
+
// with `ok: true`, so a refused or failed edit does not surface as
|
|
442
|
+
// a phantom change in the operator summary.
|
|
443
|
+
const pendingMutations = new Map();
|
|
444
|
+
// Per-session events mirror — `.pugi/sessions/<id>/events.jsonl`.
|
|
445
|
+
// The existing global log at `.pugi/events.jsonl` is preserved as
|
|
446
|
+
// the audit-replay source of truth; this mirror is the easy-to-find
|
|
447
|
+
// per-run log for operators and the cabinet UI (Sprint 2B).
|
|
448
|
+
const sessionEventsPath = openSessionMirror(root, session.id);
|
|
449
|
+
const hooks = {
|
|
450
|
+
// CEO P1 #14 (auto-compact): single operator-visible
|
|
451
|
+
// line on stderr — keep parity with the upstream tool's
|
|
452
|
+
// `Compacted N turns into Y tokens; continuing.` message. We mirror
|
|
453
|
+
// the event into the session log + stream emitter as a `status`
|
|
454
|
+
// frame так that admin-api SSE consumers + the cabinet UI render
|
|
455
|
+
// it without a schema change.
|
|
456
|
+
onAutoCompact: (event) => {
|
|
457
|
+
const pct = Math.round((event.preUsedTokens / Math.max(1, event.maxTokens)) * 100);
|
|
458
|
+
const line = `engine: auto-compacted ${event.droppedCount} turns at ${event.preUsedTokens}/${event.maxTokens} (${pct}%)`;
|
|
459
|
+
// Single-line stderr write — operator-visible per spec.
|
|
460
|
+
process.stderr.write(`${line}\n`);
|
|
461
|
+
emitStream({ type: 'status', message: line });
|
|
462
|
+
appendSessionMirror(sessionEventsPath, {
|
|
463
|
+
type: 'auto_compact',
|
|
464
|
+
droppedCount: event.droppedCount,
|
|
465
|
+
preUsedTokens: event.preUsedTokens,
|
|
466
|
+
postUsedTokens: event.postUsedTokens,
|
|
467
|
+
maxTokens: event.maxTokens,
|
|
468
|
+
gist: event.gist,
|
|
469
|
+
});
|
|
470
|
+
// #21: tenant-wide audit trail mirror.
|
|
471
|
+
writeAuditEvent({
|
|
472
|
+
event: 'auto_compact',
|
|
473
|
+
sessionId: session.id,
|
|
474
|
+
workspaceRoot: root,
|
|
475
|
+
data: {
|
|
476
|
+
droppedCount: event.droppedCount,
|
|
477
|
+
preUsedTokens: event.preUsedTokens,
|
|
478
|
+
postUsedTokens: event.postUsedTokens,
|
|
479
|
+
maxTokens: event.maxTokens,
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
},
|
|
483
|
+
onTurnStart: (turnIndex, messageCount) => {
|
|
484
|
+
const msg = `turn ${turnIndex + 1}: requesting model (transcript=${messageCount} messages)`;
|
|
485
|
+
emitStream({ type: 'status', message: msg });
|
|
486
|
+
appendSessionMirror(sessionEventsPath, { type: 'turn_start', turn: turnIndex + 1, transcript: messageCount });
|
|
487
|
+
},
|
|
488
|
+
onTurnComplete: (turnIndex, response) => {
|
|
489
|
+
if (response.stop === 'tool_use') {
|
|
490
|
+
const calls = response.assistantMessage.toolCalls ?? [];
|
|
491
|
+
emitStream({
|
|
492
|
+
type: 'status',
|
|
493
|
+
message: `turn ${turnIndex + 1}: model requested ${calls.length} tool call(s)`,
|
|
494
|
+
});
|
|
495
|
+
appendSessionMirror(sessionEventsPath, {
|
|
496
|
+
type: 'turn_complete',
|
|
497
|
+
turn: turnIndex + 1,
|
|
498
|
+
stop: 'tool_use',
|
|
499
|
+
toolCalls: calls.length,
|
|
500
|
+
tokensUsed: response.tokensUsed,
|
|
501
|
+
});
|
|
178
502
|
}
|
|
179
|
-
else {
|
|
180
|
-
|
|
503
|
+
else if (response.stop === 'text') {
|
|
504
|
+
emitStream({
|
|
505
|
+
type: 'status',
|
|
506
|
+
message: `turn ${turnIndex + 1}: model returned final text (${response.content.length} chars)`,
|
|
507
|
+
});
|
|
508
|
+
appendSessionMirror(sessionEventsPath, {
|
|
509
|
+
type: 'turn_complete',
|
|
510
|
+
turn: turnIndex + 1,
|
|
511
|
+
stop: 'text',
|
|
512
|
+
contentLength: response.content.length,
|
|
513
|
+
tokensUsed: response.tokensUsed,
|
|
514
|
+
});
|
|
515
|
+
// β3 E4 thinking-block surface: only Claude / Gemini families
|
|
516
|
+
// advertise structured thinking today. The model resolver may
|
|
517
|
+
// return a slug we don't recognise; in that case we skip the
|
|
518
|
+
// split silently. When we DO recognise it, every `<thinking>`
|
|
519
|
+
// / `<thought>` block becomes a separate `thinking.start`/
|
|
520
|
+
// `thinking.delta`/`thinking.end` triplet so the TUI can
|
|
521
|
+
// render one collapsed pane row per block. The visible text
|
|
522
|
+
// (post-strip) flows to the regular `text.delta` channel so
|
|
523
|
+
// the conversation pane never shows raw <thinking> markup.
|
|
524
|
+
if (supportsThinking && response.content.length > 0) {
|
|
525
|
+
const split = splitThinkingBlocks(response.content);
|
|
526
|
+
for (const block of split.thinkingBlocks) {
|
|
527
|
+
const blockId = `think-${randomUUID().slice(0, 8)}`;
|
|
528
|
+
emitStream({ type: 'thinking.start', blockId });
|
|
529
|
+
emitStream({ type: 'thinking.delta', blockId, chunk: block });
|
|
530
|
+
emitStream({ type: 'thinking.end', blockId });
|
|
531
|
+
}
|
|
532
|
+
if (split.visibleText.length > 0) {
|
|
533
|
+
emitStream({ type: 'text.delta', chunk: split.visibleText });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
else if (response.content.length > 0) {
|
|
537
|
+
emitStream({ type: 'text.delta', chunk: response.content });
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
onToolCall: (call) => {
|
|
542
|
+
// Record under an `engine_tool` prefix so the audit log can
|
|
543
|
+
// distinguish loop-driven calls from direct CLI tool calls.
|
|
544
|
+
const id = recordToolCall(session, `engine:${call.name}`, call.arguments.slice(0, 200));
|
|
545
|
+
// Stash the audit id on the call for `onToolResult` to close.
|
|
546
|
+
this.engineToolCallIds.set(call.id, id);
|
|
547
|
+
// Extract a candidate path for write/edit so we can build the
|
|
548
|
+
// filesChanged summary if (and only if) the call succeeds. Bad
|
|
549
|
+
// JSON is harmless here — we ignore it and the executor surfaces
|
|
550
|
+
// the actual parse error to the model.
|
|
551
|
+
if (call.name === 'write' || call.name === 'edit') {
|
|
552
|
+
const path = extractPathArg(call.arguments);
|
|
553
|
+
if (path)
|
|
554
|
+
pendingMutations.set(call.id, path);
|
|
181
555
|
}
|
|
182
|
-
|
|
556
|
+
emitStream({
|
|
557
|
+
type: 'tool.start',
|
|
558
|
+
callId: call.id,
|
|
559
|
+
name: call.name,
|
|
560
|
+
arguments: call.arguments,
|
|
561
|
+
});
|
|
562
|
+
emitStream({
|
|
563
|
+
type: 'status',
|
|
564
|
+
message: `tool_call: ${call.name}(${call.arguments.slice(0, 80)}${call.arguments.length > 80 ? '...' : ''})`,
|
|
565
|
+
});
|
|
566
|
+
appendSessionMirror(sessionEventsPath, {
|
|
567
|
+
type: 'tool_call',
|
|
568
|
+
tool: call.name,
|
|
569
|
+
callId: call.id,
|
|
570
|
+
argsPreview: call.arguments.slice(0, 200),
|
|
571
|
+
});
|
|
572
|
+
// #21: tenant-wide audit trail mirror. Same payload
|
|
573
|
+
// shape as the session mirror but flattened so a `jq` query
|
|
574
|
+
// across all sessions for one (tenant, workspace) reads
|
|
575
|
+
// cleanly.
|
|
576
|
+
writeAuditEvent({
|
|
577
|
+
event: 'tool_call',
|
|
578
|
+
sessionId: session.id,
|
|
579
|
+
workspaceRoot: root,
|
|
580
|
+
data: {
|
|
581
|
+
tool: call.name,
|
|
582
|
+
callId: call.id,
|
|
583
|
+
argsPreview: call.arguments.slice(0, 200),
|
|
584
|
+
},
|
|
585
|
+
});
|
|
586
|
+
},
|
|
587
|
+
onToolResult: (call, result) => {
|
|
588
|
+
const auditId = this.engineToolCallIds.get(call.id);
|
|
589
|
+
if (auditId) {
|
|
590
|
+
if (result.ok) {
|
|
591
|
+
recordToolResult(session, auditId, 'success', result.content.slice(0, 200));
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
recordToolResult(session, auditId, 'error', result.error.slice(0, 200));
|
|
595
|
+
}
|
|
596
|
+
this.engineToolCallIds.delete(call.id);
|
|
597
|
+
}
|
|
598
|
+
const pendingPath = pendingMutations.get(call.id);
|
|
599
|
+
if (pendingPath) {
|
|
600
|
+
if (result.ok)
|
|
601
|
+
filesChanged.add(pendingPath);
|
|
602
|
+
pendingMutations.delete(call.id);
|
|
603
|
+
}
|
|
604
|
+
emitStream({
|
|
605
|
+
type: 'tool.end',
|
|
606
|
+
callId: call.id,
|
|
607
|
+
ok: result.ok,
|
|
608
|
+
summary: result.ok
|
|
609
|
+
? result.content.slice(0, 200)
|
|
610
|
+
: result.error.slice(0, 200),
|
|
611
|
+
});
|
|
612
|
+
emitStream({
|
|
613
|
+
type: 'status',
|
|
614
|
+
message: result.ok
|
|
615
|
+
? `tool_result: ${call.name} ok`
|
|
616
|
+
: `tool_result: ${call.name} error: ${result.error.slice(0, 120)}`,
|
|
617
|
+
});
|
|
618
|
+
appendSessionMirror(sessionEventsPath, {
|
|
619
|
+
type: 'tool_result',
|
|
620
|
+
tool: call.name,
|
|
621
|
+
callId: call.id,
|
|
622
|
+
ok: result.ok,
|
|
623
|
+
summary: result.ok ? result.content.slice(0, 200) : result.error.slice(0, 200),
|
|
624
|
+
});
|
|
625
|
+
// #21: tenant-wide audit trail mirror.
|
|
626
|
+
writeAuditEvent({
|
|
627
|
+
event: 'tool_result',
|
|
628
|
+
sessionId: session.id,
|
|
629
|
+
workspaceRoot: root,
|
|
630
|
+
data: {
|
|
631
|
+
tool: call.name,
|
|
632
|
+
callId: call.id,
|
|
633
|
+
ok: result.ok,
|
|
634
|
+
summary: result.ok ? result.content.slice(0, 200) : result.error.slice(0, 200),
|
|
635
|
+
},
|
|
636
|
+
});
|
|
637
|
+
},
|
|
638
|
+
};
|
|
639
|
+
// β1b r1 (--allow-fetch / --allow-search wiring):
|
|
640
|
+
// compute the effective gate as OR of (a) the persisted
|
|
641
|
+
// settings.json opt-in and (b) the runtime CLI flag passed via
|
|
642
|
+
// the constructor. Before this fix the adapter only honored (a),
|
|
643
|
+
// so `pugi code --allow-fetch` against a default-privacy workspace
|
|
644
|
+
// silently fell back to "tool not advertised" even though the
|
|
645
|
+
// operator opted in for one invocation. The CLI flag was wired
|
|
646
|
+
// through to the legacy `pugi web` sub-command but not to the
|
|
647
|
+
// engine adapter — Backend Architect review (PR r1) caught
|
|
648
|
+
// the gap.
|
|
649
|
+
const allowFetchEffective = this.options.allowFetch === true || settings.web?.fetch?.enabled === true;
|
|
650
|
+
const allowSearchEffective = this.options.allowSearch === true || settings.web?.search?.enabled === true;
|
|
651
|
+
// β2 S3 → β2a r1 (Backend Architect P1):
|
|
652
|
+
// expose the `agent` tool to the parent loop ONLY for non-plan
|
|
653
|
+
// commands. `buildToolsSchema` also strips the agent tool from
|
|
654
|
+
// plan-mode schemas, but a model that fabricates an `agent` call
|
|
655
|
+
// would still hit the executor with `agentDispatch` wired and
|
|
656
|
+
// could spawn a coder that mutates the workspace — breaking the
|
|
657
|
+
// plan-mode read-only contract. Hard-gate `allowAgent` on the
|
|
658
|
+
// command kind so plan mode never wires the dispatch block in
|
|
659
|
+
// the first place; tool-bridge.ts also throws ToolRefused on a
|
|
660
|
+
// fabricated `agent` call in plan mode as defense in depth.
|
|
661
|
+
//
|
|
662
|
+
// Why only the top-level parent and not children: the dispatcher-
|
|
663
|
+
// real.ts module builds the CHILD's executor without an
|
|
664
|
+
// `agentDispatch` block so children cannot recursively spawn
|
|
665
|
+
// grandchildren. The isolation-matrix capability set then refuses
|
|
666
|
+
// the `agent` tool for every non-orchestrator role anyway, but
|
|
667
|
+
// the executor-level gate is the load-bearing chokepoint.
|
|
668
|
+
// Pugi backlog — intensity dial gates the `agent` tool surface.
|
|
669
|
+
// Plan-mode hard gate keeps its precedence (read-only contract);
|
|
670
|
+
// the intensity layer OR-s on top so `--intensity quick|standard`
|
|
671
|
+
// suppresses the dispatch block even on non-plan kinds.
|
|
672
|
+
const intensityAllowsAgent = this.options.intensityProfile?.allowParallelAgents ?? true;
|
|
673
|
+
const allowAgent = kind !== 'plan' && intensityAllowsAgent;
|
|
674
|
+
// Pugi backlog — resolve the effective model hint. Operator-
|
|
675
|
+
// pinned `model` option wins outright. Otherwise the intensity
|
|
676
|
+
// profile's `modelTag` resolves to a concrete slug via the
|
|
677
|
+
// `PUGI_INTENSITY_MODEL_<TAG>` env (LIGHT / STANDARD / HEAVY) so
|
|
678
|
+
// ops can pin "what does 'standard' mean on this machine" without
|
|
679
|
+
// a code change. Absent profile + absent env => undefined (legacy
|
|
680
|
+
// per-persona resolution path).
|
|
681
|
+
const effectiveModel = resolveIntensityModel(this.options.model, this.options.intensityProfile);
|
|
682
|
+
// β3 streaming: kick off `runEngineLoop` IN PARALLEL with the queue
|
|
683
|
+
// drain. The loop's hook callbacks push events onto `streamQueue`
|
|
684
|
+
// synchronously; this generator yields them live by awaiting the
|
|
685
|
+
// queue's iterator. When the loop settles (success or crash) we
|
|
686
|
+
// close the queue, which lets the iterator return cleanly and the
|
|
687
|
+
// generator falls through to the terminal `result` frame.
|
|
688
|
+
//
|
|
689
|
+
// Why concurrent instead of serial:
|
|
690
|
+
//
|
|
691
|
+
// The β1 adapter awaited `runEngineLoop` to completion, then
|
|
692
|
+
// drained an in-memory `EngineEvent[]` buffer. Operator saw
|
|
693
|
+
// nothing for 30+ seconds (the full LLM round-trip + tool exec
|
|
694
|
+
// wall time), then the entire log dumped at once. The TUI tool-
|
|
695
|
+
// stream pane was a no-op because no event ever reached it
|
|
696
|
+
// before the loop completed.
|
|
697
|
+
//
|
|
698
|
+
// `Promise.race`-based interleaving lets us yield the next queue
|
|
699
|
+
// event OR detect loop settlement on each tick. The settlement
|
|
700
|
+
// flag (`loopSettled`) gates the final drain so we never miss
|
|
701
|
+
// tail events that the hooks pushed in the same microtask as
|
|
702
|
+
// the loop's terminal `return`.
|
|
703
|
+
// Boxed via single-element tuple so TypeScript does not narrow the
|
|
704
|
+
// outer `outcome` binding to `null` after the closure mutation.
|
|
705
|
+
// Async-closure mutations are invisible to TS control-flow analysis;
|
|
706
|
+
// wrapping in a tuple defeats the narrowing without an unsafe cast.
|
|
707
|
+
const outcomeBox = [null];
|
|
708
|
+
let loopError = null;
|
|
709
|
+
const loopPromise = (async () => {
|
|
710
|
+
try {
|
|
711
|
+
outcomeBox[0] = await runEngineLoop({
|
|
712
|
+
client: this.options.client,
|
|
713
|
+
executor: buildExecutor({
|
|
714
|
+
kind,
|
|
715
|
+
ctx: toolCtx,
|
|
716
|
+
sessionId: session.id,
|
|
717
|
+
workspaceRoot: root,
|
|
718
|
+
// P1 fix (deep audit): forward optional REPL
|
|
719
|
+
// ask-modal bridge. Default `interactive: false` preserves
|
|
720
|
+
// backward compat — non-TTY callers (CI, pipes, scripted
|
|
721
|
+
// CLI runs) keep the `[user_input_required]` envelope path.
|
|
722
|
+
// The REPL layer passes `interactive: true` + a real
|
|
723
|
+
// `askUserBridge` so model-initiated `ask_user_question`
|
|
724
|
+
// calls round-trip to the ink modal and return the
|
|
725
|
+
// operator's choice as a tool result.
|
|
726
|
+
interactive: this.options.interactive === true,
|
|
727
|
+
...(this.options.askUserBridge
|
|
728
|
+
? { askUserBridge: this.options.askUserBridge }
|
|
729
|
+
: {}),
|
|
730
|
+
// P1 fix (deep audit): forward the workspace
|
|
731
|
+
// HookRegistry so `.pugi/hooks/` lifecycle hooks fire for
|
|
732
|
+
// model-initiated tool calls. SECURITY: a `PreToolUse
|
|
733
|
+
// onFailure: 'block'` hook that refuses bash containing
|
|
734
|
+
// `rm` now applies to model dispatch — before this fix
|
|
735
|
+
// such a hook only applied to direct CLI tool calls.
|
|
736
|
+
...(this.options.hooks ? { hooks: this.options.hooks } : {}),
|
|
737
|
+
// β1a r1 (web_fetch gating) + β1b r1 (--allow-fetch wiring):
|
|
738
|
+
// executor allowFetch matches the schema-advertise gate so a
|
|
739
|
+
// settings.json opt-in OR a --allow-fetch flag enables the
|
|
740
|
+
// call. Without this the model would not even see the
|
|
741
|
+
// `web_fetch` tool. `allowSearch` covers the new T4
|
|
742
|
+
// `web_search` tool with the same OR semantics.
|
|
743
|
+
allowFetch: allowFetchEffective,
|
|
744
|
+
allowSearch: allowSearchEffective,
|
|
745
|
+
// β2 S3 → β2a r1 : parent-level agentDispatch
|
|
746
|
+
// wiring. When the model emits a `tool_call: agent(role,
|
|
747
|
+
// brief)`, the executor forwards it to dispatcher-real.ts
|
|
748
|
+
// which spawns a child engine loop against the same Anvil
|
|
749
|
+
// client. Gated by `allowAgent` so plan mode does not even
|
|
750
|
+
// wire the dispatch block — defense in depth on top of the
|
|
751
|
+
// schema-filter and the tool-bridge plan-mode refusal.
|
|
752
|
+
...(allowAgent
|
|
753
|
+
? {
|
|
754
|
+
agentDispatch: {
|
|
755
|
+
parentSession: session,
|
|
756
|
+
engineClient: this.options.client,
|
|
757
|
+
},
|
|
758
|
+
}
|
|
759
|
+
: {}),
|
|
760
|
+
// β4 M1/M3/M5: pass the loaded MCP registry through so the
|
|
761
|
+
// executor can route `mcp__server__tool` calls + run the
|
|
762
|
+
// first-call permission prompt before dispatching upstream.
|
|
763
|
+
...(this.options.mcpRegistry ? { mcpRegistry: this.options.mcpRegistry } : {}),
|
|
764
|
+
...(this.options.mcpPrompt ? { mcpPrompt: this.options.mcpPrompt } : {}),
|
|
765
|
+
// L11 : per-`run()` denial tracker. Every
|
|
766
|
+
// refusal sentinel (PLAN_MODE_REFUSED, HOOK_BLOCKED,
|
|
767
|
+
// OPERATOR_ABORTED, STALE_READ, unknown-tool, plan-mode
|
|
768
|
+
// agent) is fingerprinted by (toolName, sha256(canonical
|
|
769
|
+
// args)) so the model's next-turn reminder surfaces the
|
|
770
|
+
// pattern instead of re-issuing the same refused call.
|
|
771
|
+
denialTracking,
|
|
772
|
+
}),
|
|
773
|
+
// ambient `PUGI.md` hierarchy block
|
|
774
|
+
// prepended once at session boot. When the walk found
|
|
775
|
+
// nothing OR bare mode is on, `ambientContextBlock === ''`
|
|
776
|
+
// and the system prompt is unchanged — no leading blank
|
|
777
|
+
// line, no empty wrapper tag.
|
|
778
|
+
//
|
|
779
|
+
// task #19 : static / dynamic
|
|
780
|
+
// split via `__PUGI_DYNAMIC_BOUNDARY__` sentinel. The persona
|
|
781
|
+
// prompt (`systemPromptFor(kind)`) is byte-stable across
|
|
782
|
+
// sessions of the same command kind — it goes BEFORE the
|
|
783
|
+
// boundary so Anvil's prefix cache hits on the common
|
|
784
|
+
// prefix. Per-workspace blocks (PUGI.md hierarchy, repo
|
|
785
|
+
// map) live AFTER the boundary because they change with
|
|
786
|
+
// the user's checkout state.
|
|
787
|
+
//
|
|
788
|
+
// ORDERING CHANGE — pre-#19 the model saw
|
|
789
|
+
// ambient → repoMap → persona
|
|
790
|
+
// post-#19 the model sees
|
|
791
|
+
// persona → ambient → repoMap
|
|
792
|
+
// This is INTENTIONAL — the cache prefix MUST be byte-stable
|
|
793
|
+
// and the persona is the only byte-stable block. Operators
|
|
794
|
+
// who relied on ambient guidance "fronting" the persona prompt
|
|
795
|
+
// () should now place that guidance inside
|
|
796
|
+
// the persona via `systemPromptFor(kind)` instead of PUGI.md.
|
|
797
|
+
// The empirical impact on model behaviour is bounded: persona
|
|
798
|
+
// prompts are tight directives; ambient PUGI.md is operator
|
|
799
|
+
// context. Either order is interpretable; the cache hit
|
|
800
|
+
// outweighs the front-loading.
|
|
801
|
+
systemPrompt: composeSystemPromptWithBoundary([systemPromptFor(kind)], [ambientContextBlock, repoMapBlock]),
|
|
802
|
+
// β5a R5+R6+P1: per-turn `<context>` prefix + intent marker
|
|
803
|
+
// applied above. Falls back to verbatim `task.prompt` when
|
|
804
|
+
// both the prefix block is empty AND the intent classifier
|
|
805
|
+
// returned ambiguous (the splice + apply functions handle
|
|
806
|
+
// that case as identity).
|
|
807
|
+
userPrompt: finalUserPrompt,
|
|
808
|
+
// β1a r1 (web_fetch gating) + β1b r1 (--allow-fetch wiring):
|
|
809
|
+
// pass the OR of `.pugi/settings.json::web.fetch.enabled` and
|
|
810
|
+
// the runtime `--allow-fetch` flag. When neither is true the
|
|
811
|
+
// `web_fetch` tool is not advertised to the model at all.
|
|
812
|
+
// `allowSearch` does the same for the new `web_search` tool.
|
|
813
|
+
// β2 S3: allowAgent surfaces the `agent` tool in the schema
|
|
814
|
+
// so the model sees it as a valid tool call option; the
|
|
815
|
+
// capability-matrix layer (S4) still gates which roles can
|
|
816
|
+
// actually USE it. Plan mode strips it via β2a r1 gate.
|
|
817
|
+
tools: buildToolsSchema(kind, {
|
|
818
|
+
allowFetch: allowFetchEffective,
|
|
819
|
+
allowSearch: allowSearchEffective,
|
|
820
|
+
allowAgent,
|
|
821
|
+
// β4 M1/M3: same registry the executor saw. Schema +
|
|
822
|
+
// dispatcher must agree on which MCP names are advertised
|
|
823
|
+
// and which are dispatchable; passing identical references
|
|
824
|
+
// makes that invariant impossible to break.
|
|
825
|
+
...(this.options.mcpRegistry ? { mcpRegistry: this.options.mcpRegistry } : {}),
|
|
826
|
+
}),
|
|
827
|
+
budget,
|
|
828
|
+
personaSlug: personaSlugFor(kind),
|
|
829
|
+
hooks,
|
|
830
|
+
temperature: this.options.temperature ?? 0.2,
|
|
831
|
+
signal: ctx.signal,
|
|
832
|
+
// β1 (audit E2): forward CLI sub-command + routing tag +
|
|
833
|
+
// operator-pinned model so the runtime controller's DTO sees
|
|
834
|
+
// all three. `tag` derives 1:1 from `command` for now
|
|
835
|
+
// (`code → code`, `build → build_task`, etc.); future routing
|
|
836
|
+
// changes flip the mapping table without touching the call
|
|
837
|
+
// site. `model` is left undefined here — operator-pinned model
|
|
838
|
+
// pinning ships in β6 with persona routing.
|
|
839
|
+
command: kind,
|
|
840
|
+
tag: dispatchTagFor(kind),
|
|
841
|
+
model: effectiveModel,
|
|
842
|
+
// Task — 1M context tier opt-in. Forwarded к the SDK
|
|
843
|
+
// driver which threads it through every `client.send` call to
|
|
844
|
+
// the runtime gate. `undefined` (the default) preserves
|
|
845
|
+
// legacy routing.
|
|
846
|
+
contextTier: this.options.contextTier,
|
|
847
|
+
// CEO P1 #14 (auto-compact): pluggable compactor
|
|
848
|
+
// hook. The SDK driver invokes this pre-`client.send` on every
|
|
849
|
+
// turn. `maybeCompact` returns `null` below the 75% threshold
|
|
850
|
+
// или when the transcript is too short to drop history — the
|
|
851
|
+
// loop continues unchanged on the cold path. When it returns
|
|
852
|
+
// a result, the driver swaps the transcript + fires the
|
|
853
|
+
// `onAutoCompact` hook above which emits the stderr line.
|
|
854
|
+
autoCompact: ({ transcript, maxTokens }) => maybeCompact(transcript, maxTokens, autoCompactConfig),
|
|
855
|
+
});
|
|
183
856
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (result.ok)
|
|
187
|
-
filesChanged.add(pendingPath);
|
|
188
|
-
pendingMutations.delete(call.id);
|
|
857
|
+
catch (err) {
|
|
858
|
+
loopError = err;
|
|
189
859
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
860
|
+
finally {
|
|
861
|
+
// Close the queue so the iterator below returns `done: true`.
|
|
862
|
+
// Any tail events the hooks pushed in the same microtask still
|
|
863
|
+
// drain because `AsyncEventQueue.close()` only resolves
|
|
864
|
+
// PENDING awaiters — buffered items stay readable.
|
|
865
|
+
streamQueue.close();
|
|
866
|
+
}
|
|
867
|
+
})();
|
|
868
|
+
// Drain the queue live. Each iteration yields one EngineEvent the
|
|
869
|
+
// moment its hook fired. Operator sees `tool.start` within tens of
|
|
870
|
+
// ms of the model emitting it.
|
|
871
|
+
for await (const event of streamQueue) {
|
|
872
|
+
yield streamEventToEngineEvent(event);
|
|
873
|
+
}
|
|
874
|
+
// Loop has settled (queue closed). Surface its outcome — either an
|
|
875
|
+
// unhandled crash from the (rare) executor exception path or the
|
|
876
|
+
// structured EngineLoopOutcome.
|
|
877
|
+
await loopPromise;
|
|
878
|
+
if (loopError !== null) {
|
|
879
|
+
const message = loopError instanceof Error ? loopError.message : String(loopError);
|
|
880
|
+
// #21: surface the crash to the audit trail before
|
|
881
|
+
// returning. Mirrors the `failed` arm of the structured path
|
|
882
|
+
// below so a SOC pipeline sees one `dispatch_end` per dispatch
|
|
883
|
+
// regardless of which code path produced it.
|
|
884
|
+
writeAuditEvent({
|
|
885
|
+
event: 'dispatch_end',
|
|
886
|
+
sessionId: session.id,
|
|
887
|
+
workspaceRoot: root,
|
|
888
|
+
data: {
|
|
889
|
+
status: 'crashed',
|
|
890
|
+
error: message,
|
|
891
|
+
},
|
|
195
892
|
});
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
893
|
+
// #24 (CEO P1): TaskCompleted chain fires
|
|
894
|
+
// even on engine-loop crash so an operator hook can surface the
|
|
895
|
+
// failure to Slack / a dashboard. Best-effort — chain crashes
|
|
896
|
+
// never propagate.
|
|
897
|
+
await fireTaskCompletedOnce(1, 0, []);
|
|
898
|
+
yield {
|
|
899
|
+
type: 'result',
|
|
900
|
+
result: {
|
|
901
|
+
status: 'failed',
|
|
902
|
+
summary: `engine loop crashed: ${message}`,
|
|
903
|
+
filesChanged: [],
|
|
904
|
+
patchRefs: [],
|
|
905
|
+
testsRun: [],
|
|
906
|
+
risks: [`unhandled error in engine adapter: ${message}`],
|
|
907
|
+
eventRefs: [],
|
|
908
|
+
},
|
|
909
|
+
};
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
const finalOutcome = outcomeBox[0];
|
|
913
|
+
if (finalOutcome === null) {
|
|
914
|
+
// Defensive — should never hit. `runEngineLoop` always either
|
|
915
|
+
// resolves with an outcome or throws (and we catch that above).
|
|
916
|
+
writeAuditEvent({
|
|
917
|
+
event: 'dispatch_end',
|
|
918
|
+
sessionId: session.id,
|
|
919
|
+
workspaceRoot: root,
|
|
920
|
+
data: { status: 'no_outcome' },
|
|
202
921
|
});
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
922
|
+
// #24: fire TaskCompleted chain on the defensive path too.
|
|
923
|
+
await fireTaskCompletedOnce(1, 0, []);
|
|
924
|
+
yield {
|
|
925
|
+
type: 'result',
|
|
926
|
+
result: {
|
|
927
|
+
status: 'failed',
|
|
928
|
+
summary: 'engine loop returned no outcome',
|
|
929
|
+
filesChanged: [],
|
|
930
|
+
patchRefs: [],
|
|
931
|
+
testsRun: [],
|
|
932
|
+
risks: ['runEngineLoop resolved without an outcome value'],
|
|
933
|
+
eventRefs: [],
|
|
934
|
+
},
|
|
935
|
+
};
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
// Translate the loop outcome into an EngineResult.
|
|
939
|
+
// `aborted` maps to `blocked`
|
|
940
|
+
// because the operator chose the outcome, same shape as
|
|
941
|
+
// budget_exhausted / tool_refused.
|
|
942
|
+
const status = finalOutcome.status === 'completed'
|
|
943
|
+
? 'done'
|
|
944
|
+
: finalOutcome.status === 'failed'
|
|
945
|
+
? 'failed'
|
|
946
|
+
: 'blocked';
|
|
947
|
+
const summaryPrefix = finalOutcome.status === 'completed'
|
|
948
|
+
? ''
|
|
949
|
+
: finalOutcome.status === 'budget_exhausted'
|
|
950
|
+
? '[budget_exhausted] '
|
|
951
|
+
: finalOutcome.status === 'tool_refused'
|
|
952
|
+
? '[plan_mode_refused] '
|
|
953
|
+
: finalOutcome.status === 'aborted'
|
|
954
|
+
? '[operator_aborted] '
|
|
955
|
+
: '[failed] ';
|
|
956
|
+
const filesChangedList = Array.from(filesChanged).sort();
|
|
957
|
+
appendSessionMirror(sessionEventsPath, {
|
|
958
|
+
type: 'outcome',
|
|
959
|
+
status: finalOutcome.status,
|
|
960
|
+
toolCallCount: finalOutcome.toolCallCount,
|
|
961
|
+
turnsUsed: finalOutcome.turnsUsed,
|
|
962
|
+
tokensUsed: finalOutcome.tokensUsed,
|
|
963
|
+
filesChanged: filesChangedList,
|
|
964
|
+
reason: finalOutcome.reason,
|
|
218
965
|
});
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
966
|
+
// #21: emit `dispatch_end` to the tenant-wide audit trail.
|
|
967
|
+
// When the loop tripped the per-command budget we ALSO emit a
|
|
968
|
+
// dedicated `budget_exhausted` row so a SOC query can filter on
|
|
969
|
+
// event type alone without parsing the `data.status` payload.
|
|
970
|
+
if (finalOutcome.status === 'budget_exhausted') {
|
|
971
|
+
writeAuditEvent({
|
|
972
|
+
event: 'budget_exhausted',
|
|
973
|
+
sessionId: session.id,
|
|
974
|
+
workspaceRoot: root,
|
|
975
|
+
data: {
|
|
976
|
+
toolCallCount: finalOutcome.toolCallCount,
|
|
977
|
+
turnsUsed: finalOutcome.turnsUsed,
|
|
978
|
+
tokensUsed: finalOutcome.tokensUsed,
|
|
979
|
+
reason: finalOutcome.reason ?? null,
|
|
980
|
+
},
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
writeAuditEvent({
|
|
984
|
+
event: 'dispatch_end',
|
|
985
|
+
sessionId: session.id,
|
|
986
|
+
workspaceRoot: root,
|
|
987
|
+
data: {
|
|
988
|
+
status: finalOutcome.status,
|
|
989
|
+
toolCallCount: finalOutcome.toolCallCount,
|
|
990
|
+
turnsUsed: finalOutcome.turnsUsed,
|
|
991
|
+
tokensUsed: finalOutcome.tokensUsed,
|
|
992
|
+
filesChangedCount: filesChangedList.length,
|
|
993
|
+
reason: finalOutcome.reason ?? null,
|
|
994
|
+
},
|
|
995
|
+
});
|
|
996
|
+
// #24 (CEO P1): TaskCompleted chain on the
|
|
997
|
+
// primary success path. `exitCode` maps to 0 for `completed`,
|
|
998
|
+
// 1 otherwise so chain hooks can branch on success vs blocked /
|
|
999
|
+
// failed / aborted via a single integer test.
|
|
1000
|
+
await fireTaskCompletedOnce(finalOutcome.status === 'completed' ? 0 : 1, finalOutcome.toolCallCount, filesChangedList);
|
|
1001
|
+
// PUGI-467: when the model finishes с tool_use-only turns (common
|
|
1002
|
+
// на OSS coder models that emit no final assistant text after the
|
|
1003
|
+
// last edit), `finalText` is empty even though work landed. Fall
|
|
1004
|
+
// back к a synthesised summary derived from `filesChangedList` so
|
|
1005
|
+
// the CLI never reports "no answer returned" when files were
|
|
1006
|
+
// demonstrably modified.
|
|
1007
|
+
//
|
|
1008
|
+
// Order: finalText → reason → file-list synthesis → literal placeholder.
|
|
1009
|
+
// Reason precedes synthesis so failure modes (budget_exhausted,
|
|
1010
|
+
// tool_refused, aborted) preserve their explanation when files were
|
|
1011
|
+
// also touched — operator must see WHY the loop terminated before
|
|
1012
|
+
// the "what landed" hint. Synthesis only kicks in when there is no
|
|
1013
|
+
// reason at all (pure tool_use-only completed turn).
|
|
1014
|
+
const synthesisedFromFiles = finalOutcome.finalText.trim() === '' && filesChangedList.length > 0
|
|
1015
|
+
? `Updated ${filesChangedList.length} file(s): ${filesChangedList.slice(0, 5).join(', ')}${filesChangedList.length > 5 ? ` (+${filesChangedList.length - 5} more)` : ''}`
|
|
1016
|
+
: '';
|
|
226
1017
|
yield {
|
|
227
1018
|
type: 'result',
|
|
228
1019
|
result: {
|
|
229
|
-
status
|
|
230
|
-
summary:
|
|
231
|
-
filesChanged:
|
|
1020
|
+
status,
|
|
1021
|
+
summary: `${summaryPrefix}${finalOutcome.finalText || finalOutcome.reason || synthesisedFromFiles || 'no answer returned'}`,
|
|
1022
|
+
filesChanged: filesChangedList,
|
|
232
1023
|
patchRefs: [],
|
|
233
1024
|
testsRun: [],
|
|
234
|
-
risks:
|
|
235
|
-
|
|
1025
|
+
risks: finalOutcome.status === 'completed'
|
|
1026
|
+
? []
|
|
1027
|
+
: [finalOutcome.reason ?? `outcome=${finalOutcome.status}`],
|
|
1028
|
+
eventRefs: [
|
|
1029
|
+
`tool_calls=${finalOutcome.toolCallCount}`,
|
|
1030
|
+
`turns=${finalOutcome.turnsUsed}`,
|
|
1031
|
+
`tokens=${finalOutcome.tokensUsed}`,
|
|
1032
|
+
// `outcome=<status>` is a machine-readable echo so callers
|
|
1033
|
+
// (cli.ts plan exit code, cabinet UI) can distinguish
|
|
1034
|
+
// `budget_exhausted` from `tool_refused` without parsing
|
|
1035
|
+
// the human-readable summary prefix. Code Reviewer P2
|
|
1036
|
+
// retro: plan exit code previously collapsed
|
|
1037
|
+
// both blocked reasons into 0, which masked budget hits.
|
|
1038
|
+
`outcome=${finalOutcome.status}`,
|
|
1039
|
+
`session=${session.id}`,
|
|
1040
|
+
`ctx=${ctx.sessionId}`,
|
|
1041
|
+
`mirror=${sessionEventsPath}`,
|
|
1042
|
+
],
|
|
236
1043
|
},
|
|
237
1044
|
};
|
|
238
|
-
return;
|
|
239
1045
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
type: '
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
1046
|
+
finally {
|
|
1047
|
+
// r2 (triple-review P1): detach the abort listener so
|
|
1048
|
+
// long REPL sessions sharing one AbortController across many
|
|
1049
|
+
// run() invocations do not accumulate one listener per run on
|
|
1050
|
+
// `ctx.signal`. Called on success, abort, and uncaught throw.
|
|
1051
|
+
detachAbortListener?.();
|
|
1052
|
+
// #24 (CEO P1): safety net — if `run()` threw
|
|
1053
|
+
// BEFORE reaching any yield-result site, the chain still fires.
|
|
1054
|
+
// `fireTaskCompletedOnce` is idempotent so the happy-path fire
|
|
1055
|
+
// above wins. Exit code 1 because the throw path is by
|
|
1056
|
+
// definition non-clean.
|
|
1057
|
+
await fireTaskCompletedOnce(1, 0, []);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* β3 streaming: translate one typed `EngineStreamEvent` from the
|
|
1063
|
+
* adapter's internal queue into the SDK's lossier `EngineEvent` shape
|
|
1064
|
+
* the public adapter contract exposes. The SDK contract only declares
|
|
1065
|
+
* `status | result` today; richer events (`tool.start`, `thinking.delta`,
|
|
1066
|
+
* etc.) collapse to a structured `status` message until the SDK widens
|
|
1067
|
+
* the discriminated union (β3b — paired with an admin-api SSE schema
|
|
1068
|
+
* bump so the wire format stays stable).
|
|
1069
|
+
*
|
|
1070
|
+
* The full typed payload is still available to richer consumers via
|
|
1071
|
+
* `adapter.streamEmitter.on('event', ...)`. The CLI's TUI tool-stream
|
|
1072
|
+
* pane consumes that emitter directly; this function is the safe
|
|
1073
|
+
* bridge for legacy SDK consumers that only know `EngineEvent`.
|
|
1074
|
+
*/
|
|
1075
|
+
function streamEventToEngineEvent(stream) {
|
|
1076
|
+
switch (stream.type) {
|
|
1077
|
+
case 'status':
|
|
1078
|
+
return { type: 'status', message: stream.message };
|
|
1079
|
+
case 'tool.start':
|
|
1080
|
+
return {
|
|
1081
|
+
type: 'status',
|
|
1082
|
+
message: `tool.start ${stream.name} call=${stream.callId} args=${stream.arguments.slice(0, 80)}${stream.arguments.length > 80 ? '...' : ''}`,
|
|
1083
|
+
};
|
|
1084
|
+
case 'tool.delta':
|
|
1085
|
+
return {
|
|
1086
|
+
type: 'status',
|
|
1087
|
+
message: `tool.delta call=${stream.callId} chunk=${stream.chunk.slice(0, 120)}`,
|
|
1088
|
+
};
|
|
1089
|
+
case 'tool.end':
|
|
1090
|
+
return {
|
|
1091
|
+
type: 'status',
|
|
1092
|
+
message: `tool.end call=${stream.callId} ok=${stream.ok} summary=${stream.summary.slice(0, 120)}`,
|
|
1093
|
+
};
|
|
1094
|
+
case 'thinking.start':
|
|
1095
|
+
return { type: 'status', message: `thinking.start block=${stream.blockId}` };
|
|
1096
|
+
case 'thinking.delta':
|
|
1097
|
+
return {
|
|
1098
|
+
type: 'status',
|
|
1099
|
+
message: `thinking.delta block=${stream.blockId} chunk=${stream.chunk.slice(0, 120)}`,
|
|
1100
|
+
};
|
|
1101
|
+
case 'thinking.end':
|
|
1102
|
+
return { type: 'status', message: `thinking.end block=${stream.blockId}` };
|
|
1103
|
+
case 'text.delta':
|
|
1104
|
+
return {
|
|
1105
|
+
type: 'status',
|
|
1106
|
+
message: `text.delta chunk=${stream.chunk.slice(0, 200)}`,
|
|
1107
|
+
};
|
|
1108
|
+
default: {
|
|
1109
|
+
// Exhaustiveness — TS catches a missing variant at compile time.
|
|
1110
|
+
const exhaustive = stream;
|
|
1111
|
+
void exhaustive;
|
|
1112
|
+
return { type: 'status', message: 'unknown stream event' };
|
|
1113
|
+
}
|
|
299
1114
|
}
|
|
300
1115
|
}
|
|
301
1116
|
/**
|
|
@@ -311,7 +1126,14 @@ function extractPathArg(raw) {
|
|
|
311
1126
|
try {
|
|
312
1127
|
const parsed = JSON.parse(raw);
|
|
313
1128
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
314
|
-
const
|
|
1129
|
+
const obj = parsed;
|
|
1130
|
+
// Accept canonical `path` OR the Claude-Code-trained `filePath`
|
|
1131
|
+
// alias so the filesChanged summary captures writes regardless of
|
|
1132
|
+
// which key the model emitted. Without the alias the operator
|
|
1133
|
+
// sees "Files modified: none" even when a write actually landed,
|
|
1134
|
+
// because the dispatcher accepted the alias but the tracker did
|
|
1135
|
+
// not (CEO live smoke).
|
|
1136
|
+
const path = obj['path'] ?? obj['filePath'];
|
|
315
1137
|
if (typeof path === 'string' && path.length > 0)
|
|
316
1138
|
return path;
|
|
317
1139
|
}
|
|
@@ -367,8 +1189,254 @@ function toCommandKind(kind) {
|
|
|
367
1189
|
return 'build';
|
|
368
1190
|
return kind;
|
|
369
1191
|
}
|
|
1192
|
+
/**
|
|
1193
|
+
* β1 (audit E2) → β1a r1 (engine tag contract fix): map a
|
|
1194
|
+
* CLI command kind to its dispatch tag.
|
|
1195
|
+
*
|
|
1196
|
+
* The admin-api controller (`pugi-engine.controller.ts`) routes per-tag
|
|
1197
|
+
* to a model/persona pair via
|
|
1198
|
+
* `apps/admin-api/src/mira/routing/dispatch-tag.ts::DISPATCH_TAGS`. The
|
|
1199
|
+
* closed `EngineChatTag` vocabulary is
|
|
1200
|
+
* `classify | reason | codegen | summarize | vision` — note that
|
|
1201
|
+
* `code`, `fix`, `plan`, `build`, `explain` (CLI command names) are NOT
|
|
1202
|
+
* in this set.
|
|
1203
|
+
*
|
|
1204
|
+
* Before this fix `dispatchTagFor()` returned the CLI command names
|
|
1205
|
+
* as-is and the runtime DTO rejected the payload with HTTP 400
|
|
1206
|
+
* (`tag must be one of: classify, reason, codegen, summarize, vision`)
|
|
1207
|
+
* before ever reaching the routing layer. Every `pugi code/fix/plan/
|
|
1208
|
+
* build/explain` against the live runtime returned `failed: HTTP 400`.
|
|
1209
|
+
*
|
|
1210
|
+
* Mapping rationale (each row keeps the most informative `tag` value
|
|
1211
|
+
* for cost telemetry / model selection):
|
|
1212
|
+
*
|
|
1213
|
+
* - `code`, `fix` → `codegen` (edits / diffs / patches)
|
|
1214
|
+
* - `build_task`/`build` → `codegen` + `budget_hint: 'max'`
|
|
1215
|
+
* (scaffolding hits the 30-call / 80k-token ceiling — give the
|
|
1216
|
+
* router permission to pick the largest model in the tier)
|
|
1217
|
+
* - `plan` → `reason` (no mutations, long-form thought)
|
|
1218
|
+
* - `explain` → `summarize` (read-only walkthrough)
|
|
1219
|
+
*
|
|
1220
|
+
* `priority: 'realtime'` for every command — Pugi is an interactive
|
|
1221
|
+
* CLI; background dispatch is reserved for the cabinet's RAG ingest
|
|
1222
|
+
* cron path. `budget_hint: 'std'` is the default for the cost-balanced
|
|
1223
|
+
* router row; only `build_task` opts up to `'max'`.
|
|
1224
|
+
*/
|
|
1225
|
+
export function dispatchTagFor(kind) {
|
|
1226
|
+
switch (kind) {
|
|
1227
|
+
case 'code':
|
|
1228
|
+
case 'fix':
|
|
1229
|
+
return { tag: 'codegen', priority: 'realtime', budget_hint: 'std' };
|
|
1230
|
+
case 'build':
|
|
1231
|
+
// `build_task` on the engine task kind side is the heavy
|
|
1232
|
+
// scaffolding lane — biggest budget envelope, biggest model
|
|
1233
|
+
// permitted via `budget_hint: 'max'`.
|
|
1234
|
+
return { tag: 'codegen', priority: 'realtime', budget_hint: 'max' };
|
|
1235
|
+
case 'plan':
|
|
1236
|
+
return { tag: 'reason', priority: 'realtime', budget_hint: 'std' };
|
|
1237
|
+
case 'explain':
|
|
1238
|
+
return { tag: 'summarize', priority: 'realtime', budget_hint: 'std' };
|
|
1239
|
+
default: {
|
|
1240
|
+
// Exhaustiveness check — `EngineCommandKind` is a closed union,
|
|
1241
|
+
// so the switch above covers every case. If a new command kind
|
|
1242
|
+
// is added the compiler flags this branch and the map must be
|
|
1243
|
+
// extended. Fall back to `reason` as the most conservative
|
|
1244
|
+
// routing choice so a future kind addition cannot accidentally
|
|
1245
|
+
// unlock a write-heavy model lane.
|
|
1246
|
+
const exhaustive = kind;
|
|
1247
|
+
void exhaustive;
|
|
1248
|
+
return { tag: 'reason', priority: 'realtime', budget_hint: 'std' };
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
370
1252
|
// The per-adapter `engineToolCallIds` Map lives on the
|
|
371
1253
|
// `NativePugiEngineAdapter` instance above — Code Reviewer P2 retro
|
|
372
|
-
//
|
|
1254
|
+
// lifted it off the module scope to prevent collisions
|
|
373
1255
|
// under parallel adapter runs (cabinet UI + CLI sharing one process).
|
|
1256
|
+
/**
|
|
1257
|
+
* β5a R5+R6: render a cwd path as either a workspace-root-relative
|
|
1258
|
+
* string (when cwd is inside the workspace) or a `.` token (when cwd
|
|
1259
|
+
* equals workspaceRoot). Falls back to the absolute cwd if it lives
|
|
1260
|
+
* outside the workspace — the traverse loader already refuses to
|
|
1261
|
+
* read off-tree files so the abs path is purely a breadcrumb for
|
|
1262
|
+
* the SSE status line.
|
|
1263
|
+
*/
|
|
1264
|
+
function relativeOrAbsolute(workspaceRoot, cwd) {
|
|
1265
|
+
const absRoot = resolve(workspaceRoot);
|
|
1266
|
+
const absCwd = resolve(cwd);
|
|
1267
|
+
if (absCwd === absRoot)
|
|
1268
|
+
return '.';
|
|
1269
|
+
const rel = absCwd.startsWith(absRoot + '/') ? absCwd.slice(absRoot.length + 1) : null;
|
|
1270
|
+
return rel ?? absCwd;
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* helper — splice multiple ambient blocks onto a persona
|
|
1274
|
+
* system prompt, dropping empty entries cleanly. The join character
|
|
1275
|
+
* is `\n\n` so each block renders as a discrete paragraph the model
|
|
1276
|
+
* can attend к without bleeding into its neighbour.
|
|
1277
|
+
*
|
|
1278
|
+
* Empty blocks return the base prompt unchanged — no leading
|
|
1279
|
+
* separators, no trailing whitespace. Mirrors the original
|
|
1280
|
+
* `ambientContextBlock ? ... : ...` shape so the single-block path
|
|
1281
|
+
* before L28 stays byte-identical (prompt cache friendliness).
|
|
1282
|
+
*/
|
|
1283
|
+
export function composeSystemPrompt(blocks) {
|
|
1284
|
+
const nonEmpty = blocks.map((b) => b.trim()).filter((b) => b.length > 0);
|
|
1285
|
+
return nonEmpty.join('\n\n');
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* task #19 — boundary marker between cache-friendly
|
|
1289
|
+
* static blocks (persona, capability matrix, tool schema) and dynamic
|
|
1290
|
+
* per-session blocks (ambient PUGI.md, repo map, recent turns). The
|
|
1291
|
+
* marker is a literal sentinel string the Anvil prefix-cache layer
|
|
1292
|
+
* can locate to find the split point without parsing prompt semantics.
|
|
1293
|
+
*
|
|
1294
|
+
* Why: Anthropic's prompt cache works by hashing prefix bytes. Static
|
|
1295
|
+
* content placed BEFORE dynamic content guarantees the cache hits on
|
|
1296
|
+
* the common prefix even when the per-session tail varies. CC's
|
|
1297
|
+
* proven pattern uses a single sentinel; Pugi adopts the same shape
|
|
1298
|
+
* so cache infra is trivially interoperable.
|
|
1299
|
+
*
|
|
1300
|
+
* Output shape:
|
|
1301
|
+
* <staticBlock1>
|
|
1302
|
+
* <staticBlock2>
|
|
1303
|
+
* __PUGI_DYNAMIC_BOUNDARY__
|
|
1304
|
+
* <dynamicBlock1>
|
|
1305
|
+
* <dynamicBlock2>
|
|
1306
|
+
*
|
|
1307
|
+
* Empty blocks drop cleanly. If EITHER side ends up empty after the
|
|
1308
|
+
* filter, the marker is omitted so the prompt has no orphan sentinel
|
|
1309
|
+
* — caches treat "no boundary" as "everything is static / dynamic"
|
|
1310
|
+
* with deterministic behaviour.
|
|
1311
|
+
*/
|
|
1312
|
+
export const PUGI_DYNAMIC_BOUNDARY = '__PUGI_DYNAMIC_BOUNDARY__';
|
|
1313
|
+
/**
|
|
1314
|
+
* Sentinel-injection guard. The Anvil cache layer locates the split
|
|
1315
|
+
* via grep на the literal `__PUGI_DYNAMIC_BOUNDARY__`. If either half
|
|
1316
|
+
* already contains the sentinel — most likely via a PUGI.md fragment
|
|
1317
|
+
* that documents the boundary mechanism itself, or через operator
|
|
1318
|
+
* @import-pulled content — the grep would mis-split and corrupt the
|
|
1319
|
+
* cache key. Hard-fail loud rather than silently emit a poisoned
|
|
1320
|
+
* prompt. Operators who legitimately need the literal string in
|
|
1321
|
+
* prompt context can rename their copy (e.g. `PUGI_DYNAMIC_BOUNDARY_LITERAL`)
|
|
1322
|
+
* or use the runtime constant export directly via code.
|
|
1323
|
+
*/
|
|
1324
|
+
export class SentinelInjectionError extends Error {
|
|
1325
|
+
side;
|
|
1326
|
+
constructor(side) {
|
|
1327
|
+
super(`Refusing to compose system prompt: ${side} side contains the ` +
|
|
1328
|
+
`literal sentinel "${PUGI_DYNAMIC_BOUNDARY}". This would corrupt ` +
|
|
1329
|
+
`the Anvil prefix-cache split. Rename the offending occurrence ` +
|
|
1330
|
+
`или strip it before composing.`);
|
|
1331
|
+
this.side = side;
|
|
1332
|
+
this.name = 'SentinelInjectionError';
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
export function composeSystemPromptWithBoundary(staticBlocks, dynamicBlocks) {
|
|
1336
|
+
const staticPart = composeSystemPrompt(staticBlocks);
|
|
1337
|
+
const dynamicPart = composeSystemPrompt(dynamicBlocks);
|
|
1338
|
+
// Sentinel-injection guard — refuse loud rather than mis-split cache.
|
|
1339
|
+
if (staticPart.includes(PUGI_DYNAMIC_BOUNDARY)) {
|
|
1340
|
+
throw new SentinelInjectionError('static');
|
|
1341
|
+
}
|
|
1342
|
+
if (dynamicPart.includes(PUGI_DYNAMIC_BOUNDARY)) {
|
|
1343
|
+
throw new SentinelInjectionError('dynamic');
|
|
1344
|
+
}
|
|
1345
|
+
if (staticPart.length === 0)
|
|
1346
|
+
return dynamicPart;
|
|
1347
|
+
if (dynamicPart.length === 0)
|
|
1348
|
+
return staticPart;
|
|
1349
|
+
return `${staticPart}\n\n${PUGI_DYNAMIC_BOUNDARY}\n\n${dynamicPart}`;
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Pugi backlog — resolve the effective model hint forwarded to
|
|
1353
|
+
* the runtime. Precedence:
|
|
1354
|
+
*
|
|
1355
|
+
* 1. Operator-pinned `model` option (constructor arg) wins outright.
|
|
1356
|
+
* `pugi code --model foo` always takes precedence over the dial.
|
|
1357
|
+
* 2. Intensity profile's `modelTag` resolves via
|
|
1358
|
+
* `PUGI_INTENSITY_MODEL_<TAG>` env (LIGHT / STANDARD / HEAVY).
|
|
1359
|
+
* Operators pin "what does 'standard' mean on this machine" via
|
|
1360
|
+
* env so the dial stays portable across providers.
|
|
1361
|
+
* 3. Absent both => undefined; the admin-api falls back to the
|
|
1362
|
+
* persona's default model (the legacy pre-#163 path).
|
|
1363
|
+
*
|
|
1364
|
+
* Returns undefined when no hint is available so the runtime sees the
|
|
1365
|
+
* absence of the field rather than an empty string — matches the
|
|
1366
|
+
* `engineLoopServerRequestSchema.model.optional()` contract.
|
|
1367
|
+
*/
|
|
1368
|
+
export function resolveIntensityModel(operatorPin, profile) {
|
|
1369
|
+
if (operatorPin !== undefined && operatorPin !== '')
|
|
1370
|
+
return operatorPin;
|
|
1371
|
+
if (!profile)
|
|
1372
|
+
return undefined;
|
|
1373
|
+
const envKey = `PUGI_INTENSITY_MODEL_${profile.modelTag.toUpperCase()}`;
|
|
1374
|
+
const fromEnv = process.env[envKey];
|
|
1375
|
+
if (fromEnv !== undefined && fromEnv !== '')
|
|
1376
|
+
return fromEnv;
|
|
1377
|
+
return undefined;
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Backlog : expand `@import` directives across every
|
|
1381
|
+
* file the `walkUpPugiMd` walker discovered. Each parent file's body
|
|
1382
|
+
* is replaced with its post-import body (frontmatter stripped, import
|
|
1383
|
+
* lines removed); imported children are appended to the hierarchy at
|
|
1384
|
+
* the same `level` as their parent so the existing render order
|
|
1385
|
+
* (shallow-to-deep) stays intact and the model sees the operator's
|
|
1386
|
+
* `@import`-pulled rules in source order.
|
|
1387
|
+
*
|
|
1388
|
+
* Failures are localised: if a single file's load throws (cycle, hop
|
|
1389
|
+
* cap, byte cap, etc.) we keep the walker's original body for that
|
|
1390
|
+
* level and move on. Ambient context is enrichment, not a gate — one
|
|
1391
|
+
* malformed CLAUDE.md must never break the engine boot.
|
|
1392
|
+
*/
|
|
1393
|
+
async function expandHierarchyWithImports(hierarchy, cwd) {
|
|
1394
|
+
const out = [];
|
|
1395
|
+
const home = osHomedir();
|
|
1396
|
+
for (const file of hierarchy) {
|
|
1397
|
+
try {
|
|
1398
|
+
const rules = await loadRulesFile(file.path, {
|
|
1399
|
+
cwd,
|
|
1400
|
+
homedir: home,
|
|
1401
|
+
});
|
|
1402
|
+
// First rule is always the entry file itself. Replace the body
|
|
1403
|
+
// with the post-expansion body so the rendered ambient block
|
|
1404
|
+
// omits the `@import` directives but keeps everything else.
|
|
1405
|
+
const head = rules[0];
|
|
1406
|
+
if (head) {
|
|
1407
|
+
out.push({
|
|
1408
|
+
...file,
|
|
1409
|
+
content: head.body,
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
else {
|
|
1413
|
+
out.push(file);
|
|
1414
|
+
}
|
|
1415
|
+
// Append imported children at the same level. They are not on
|
|
1416
|
+
// disk in the parent dir, but the operator authored the link so
|
|
1417
|
+
// surfacing them at the parent's specificity matches the
|
|
1418
|
+
// ambient-context render contract.
|
|
1419
|
+
for (let i = 1; i < rules.length; i += 1) {
|
|
1420
|
+
const child = rules[i];
|
|
1421
|
+
if (!child)
|
|
1422
|
+
continue;
|
|
1423
|
+
out.push({
|
|
1424
|
+
path: child.path,
|
|
1425
|
+
content: child.body,
|
|
1426
|
+
level: file.level,
|
|
1427
|
+
source: file.source,
|
|
1428
|
+
truncated: false,
|
|
1429
|
+
rawBytes: Buffer.byteLength(child.body, 'utf8'),
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
catch {
|
|
1434
|
+
// Localised failure: keep the walker's original body for this
|
|
1435
|
+
// file and skip its imports. The next file in the hierarchy is
|
|
1436
|
+
// tried independently.
|
|
1437
|
+
out.push(file);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
return out;
|
|
1441
|
+
}
|
|
374
1442
|
//# sourceMappingURL=native-pugi.js.map
|