@pugi/cli 0.1.0-beta.4 → 0.1.0-beta.40
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/THIRD_PARTY_NOTICES.md +40 -0
- package/assets/pugi-mascot.ansi +15 -25
- package/bin/run.js +33 -1
- package/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +15 -0
- 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/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/auth/ensure-authenticated.js +129 -0
- package/dist/core/auth/env-provider.js +238 -0
- 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-classifier.js +108 -1
- package/dist/core/checkpoint/resumer.js +149 -0
- package/dist/core/checkpoint/rewinder.js +291 -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/diff-capture.js +73 -0
- package/dist/core/context/index.js +7 -0
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/cost/rate-card.js +129 -0
- package/dist/core/cost/tracker.js +221 -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 +86 -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/git.js +65 -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/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/dispatch.js +218 -2
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/edits/worktree.js +322 -0
- package/dist/core/engine/anvil-client.js +115 -5
- package/dist/core/engine/budgets.js +98 -0
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +860 -211
- package/dist/core/engine/prompts.js +88 -2
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +992 -36
- 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/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/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/mcp/client.js +75 -6
- 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 +24 -2
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/memory/dual-write.js +416 -0
- package/dist/core/memory/phase1-kinds.js +20 -0
- package/dist/core/memory-sync/queue.js +158 -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/permissions/auto-classifier.js +124 -0
- package/dist/core/permissions/circuit-breaker.js +83 -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/state.js +241 -0
- package/dist/core/permissions/tool-class.js +93 -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/pugi-md/context-injector.js +76 -0
- package/dist/core/pugi-md/walk-up.js +207 -0
- package/dist/core/release-notes/parser.js +241 -0
- package/dist/core/release-notes/state.js +116 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/session.js +1899 -38
- package/dist/core/repl/slash-commands.js +406 -21
- package/dist/core/repl/store/session-store.js +31 -2
- package/dist/core/repl/workspace-context.js +22 -0
- 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/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/session.js +92 -0
- package/dist/core/settings.js +80 -0
- 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/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/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +113 -24
- package/dist/core/subagents/index.js +18 -5
- 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/transport/version-interceptor.js +166 -0
- package/dist/core/vim/keymap.js +288 -0
- package/dist/core/vim/state.js +92 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +3073 -321
- 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/cost.js +199 -0
- package/dist/runtime/commands/delegate.js +242 -11
- package/dist/runtime/commands/dispatch.js +126 -0
- package/dist/runtime/commands/doctor.js +390 -0
- package/dist/runtime/commands/feedback.js +184 -0
- package/dist/runtime/commands/hooks.js +184 -0
- package/dist/runtime/commands/lsp.js +368 -0
- package/dist/runtime/commands/mcp.js +879 -0
- package/dist/runtime/commands/memory.js +508 -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/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 +17 -2
- package/dist/runtime/commands/rewind.js +333 -0
- package/dist/runtime/commands/sessions.js +163 -0
- package/dist/runtime/commands/share.js +316 -0
- 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 +32 -0
- 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/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/version.js +65 -0
- package/dist/tools/agent-tool.js +229 -0
- package/dist/tools/apply-patch.js +556 -0
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/file-tools.js +85 -14
- 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/registry.js +46 -0
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tools/todo-write.js +184 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/compact-banner.js +81 -0
- package/dist/tui/conversation-pane.js +82 -8
- package/dist/tui/cost-table.js +111 -0
- package/dist/tui/doctor-table.js +46 -0
- package/dist/tui/feedback-prompt.js +156 -0
- package/dist/tui/input-box.js +69 -2
- package/dist/tui/markdown-render.js +4 -4
- 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 +303 -13
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +72 -14
- package/dist/tui/splash.js +1 -1
- package/dist/tui/status-bar.js +94 -16
- 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/tool-stream-pane.js +52 -3
- package/dist/tui/update-banner.js +20 -2
- package/dist/tui/vim-input.js +267 -0
- package/docs/examples/codegraph.mcp.json +10 -0
- package/package.json +12 -6
- package/test/scenarios/codegen-create-file.scenario.txt +13 -0
- package/test/scenarios/compact-force.scenario.txt +11 -0
- package/test/scenarios/identity.scenario.txt +11 -0
- package/test/scenarios/persona-handoff.scenario.txt +11 -0
- package/test/scenarios/walkback.scenario.txt +12 -0
- package/dist/core/engine/compaction-hook.js +0 -154
|
@@ -1,11 +1,29 @@
|
|
|
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 { resolveBudget } from './budgets.js';
|
|
7
10
|
import { buildExecutor, buildToolsSchema } from './tool-bridge.js';
|
|
8
11
|
import { personaSlugFor, systemPromptFor } from './prompts.js';
|
|
12
|
+
import { CancellationToken } from '../repl/cancellation.js';
|
|
13
|
+
// β5a R5+R6 + P1 (2026-05-26): per-turn `<context>` prefix + intent
|
|
14
|
+
// classifier marker. Both pure functions, no fs cost at adapter init.
|
|
15
|
+
// Per-dir markdown traverse fires once per `run()`; budget capped so
|
|
16
|
+
// it never dominates the prompt budget.
|
|
17
|
+
import { buildContextPrefix, spliceContextPrefix } from './context-prefix.js';
|
|
18
|
+
import { applyIntentMarker, classifyIntent } from './intent.js';
|
|
19
|
+
import { loadTraversedMarkdown } from '../context/markdown-traverse.js';
|
|
20
|
+
import { isBareMode } from '../bare-mode/index.js';
|
|
21
|
+
import { walkUpPugiMd } from '../pugi-md/walk-up.js';
|
|
22
|
+
import { renderAmbientContext } from '../pugi-md/context-injector.js';
|
|
23
|
+
// α7 L11 (2026-05-27): per-session DenialTrackingState. One instance
|
|
24
|
+
// per `run()` so denials cluster by (tool, args) within the same
|
|
25
|
+
// command but do NOT leak across CLI invocations.
|
|
26
|
+
import { DenialTrackingState } from '../denial-tracking/state.js';
|
|
9
27
|
/**
|
|
10
28
|
* Real `NativePugiEngineAdapter`. Drives the Pugi CLI's tool-use loop:
|
|
11
29
|
*
|
|
@@ -50,8 +68,30 @@ export class NativePugiEngineAdapter {
|
|
|
50
68
|
* to a single `run()` invocation.
|
|
51
69
|
*/
|
|
52
70
|
engineToolCallIds = new Map();
|
|
71
|
+
/**
|
|
72
|
+
* β3 streaming additive: optional typed event emitter that mirrors
|
|
73
|
+
* every async-queue event so external consumers (admin-api SSE
|
|
74
|
+
* controller, future cabinet WebSocket relay) can attach without
|
|
75
|
+
* holding the async iterator. The CLI itself only consumes the
|
|
76
|
+
* `AsyncIterable<EngineEvent>` returned by `run()`; the emitter is
|
|
77
|
+
* a fan-out point for additional subscribers.
|
|
78
|
+
*/
|
|
79
|
+
streamEmitter = new EngineEventEmitter();
|
|
53
80
|
constructor(options) {
|
|
54
81
|
this.options = options;
|
|
82
|
+
// β2a r1 (Backend Architect P1, 2026-05-26): kick off the real
|
|
83
|
+
// dispatcher's module import at adapter init so the first
|
|
84
|
+
// `agent` tool call does not pay 50-200ms cold-start. We fire
|
|
85
|
+
// the promise without awaiting — by the time the engine loop
|
|
86
|
+
// runs and the model issues an `agent` call, the import has
|
|
87
|
+
// resolved. The promise is swallowed because a failed prewarm
|
|
88
|
+
// would surface again at dispatch time with the real error.
|
|
89
|
+
void prewarmRealDispatch().catch(() => {
|
|
90
|
+
// Intentional no-op: the actual dispatch call will surface
|
|
91
|
+
// the import failure (if any) with the right call stack. A
|
|
92
|
+
// prewarm-time failure is just a missed optimization, not a
|
|
93
|
+
// correctness issue.
|
|
94
|
+
});
|
|
55
95
|
}
|
|
56
96
|
async capabilities() {
|
|
57
97
|
return {
|
|
@@ -59,7 +99,13 @@ export class NativePugiEngineAdapter {
|
|
|
59
99
|
supportsFileEdits: true,
|
|
60
100
|
supportsShell: true,
|
|
61
101
|
supportsLsp: false,
|
|
62
|
-
|
|
102
|
+
// β2 S2 (2026-05-26): real subagent dispatch shipped via the
|
|
103
|
+
// `agent` tool (apps/pugi-cli/src/tools/agent-tool.ts) plus the
|
|
104
|
+
// genuine `runEngineLoop`-backed dispatcher
|
|
105
|
+
// (apps/pugi-cli/src/core/subagents/dispatcher-real.ts). The
|
|
106
|
+
// capability flag flips after S1 + S3 + S4 land so cabinet UI +
|
|
107
|
+
// remote orchestrators can rely on the advertised contract.
|
|
108
|
+
supportsSubagents: true,
|
|
63
109
|
};
|
|
64
110
|
}
|
|
65
111
|
async *run(task, ctx) {
|
|
@@ -67,235 +113,740 @@ export class NativePugiEngineAdapter {
|
|
|
67
113
|
const root = task.workspaceRoot;
|
|
68
114
|
const session = this.options.session ?? openSession(root);
|
|
69
115
|
const settings = loadSettings(root);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
116
|
+
// P1 fix (deep audit 2026-05-26): wire ctx.signal (AbortSignal) into
|
|
117
|
+
// a CancellationToken so the tool-bridge cancellation gate
|
|
118
|
+
// (`ctx.cancellation?.isAborted` check at tool-bridge.ts:656 +
|
|
119
|
+
// file-tools `gateOnCancellation` calls) fires when the operator
|
|
120
|
+
// aborts mid-tool. Before this fix `toolCtx` carried no cancellation
|
|
121
|
+
// field — only the next runEngineLoop iteration via `ctx.signal`
|
|
122
|
+
// aborted at the turn boundary, so a long-running tool (a sleeping
|
|
123
|
+
// bash command, a slow grep across the repo) could not be cancelled
|
|
124
|
+
// mid-call.
|
|
125
|
+
//
|
|
126
|
+
// The token is wired one-way: ctx.signal -> token. Aborting the
|
|
127
|
+
// token directly does NOT propagate back to the AbortSignal; the
|
|
128
|
+
// engine's own cancellation already lives upstream via the signal
|
|
129
|
+
// so the back-edge is unnecessary.
|
|
130
|
+
//
|
|
131
|
+
// r2 fix (triple-review 2026-05-26 P1): the abort listener was
|
|
132
|
+
// registered with `{ once: true }` — on actual abort it auto-detaches
|
|
133
|
+
// and disappears, but on the (common) NON-abort path where `run()`
|
|
134
|
+
// completes cleanly the listener stays attached to `ctx.signal`
|
|
135
|
+
// forever. Over a long REPL session (one shared AbortController per
|
|
136
|
+
// session, many run() invocations) listeners accumulate one per
|
|
137
|
+
// run, leaking memory and CPU on `dispatchEvent`. We now track the
|
|
138
|
+
// detach handle and call it unconditionally in the run()'s finally
|
|
139
|
+
// block so cleanup happens on both the success and abort paths.
|
|
140
|
+
const cancellation = new CancellationToken();
|
|
141
|
+
let detachAbortListener;
|
|
142
|
+
if (ctx.signal) {
|
|
143
|
+
if (ctx.signal.aborted) {
|
|
144
|
+
cancellation.abort();
|
|
83
145
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
146
|
+
else {
|
|
147
|
+
const handler = () => cancellation.abort();
|
|
148
|
+
ctx.signal.addEventListener('abort', handler, { once: true });
|
|
149
|
+
detachAbortListener = () => {
|
|
150
|
+
ctx.signal.removeEventListener('abort', handler);
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// r2 (triple-review 2026-05-26 P1): everything below runs inside a
|
|
155
|
+
// try/finally so the AbortSignal listener detaches on BOTH the
|
|
156
|
+
// success and abort paths. Without this wrap a long REPL session
|
|
157
|
+
// (one persistent AbortController, many run() invocations) leaked
|
|
158
|
+
// one abort listener per non-aborted run.
|
|
159
|
+
try {
|
|
160
|
+
const toolCtx = {
|
|
161
|
+
root,
|
|
162
|
+
settings,
|
|
163
|
+
session,
|
|
164
|
+
readCache: new FileReadCache(),
|
|
165
|
+
cancellation,
|
|
166
|
+
};
|
|
167
|
+
// α7 L11 (2026-05-27): instantiate per-`run()` denial tracker. The
|
|
168
|
+
// executor records every refusal (PLAN_MODE_REFUSED, HOOK_BLOCKED,
|
|
169
|
+
// OPERATOR_ABORTED, STALE_READ, unknown-tool, plan-mode agent) and
|
|
170
|
+
// the user-prompt assembler below splices a compact reminder when
|
|
171
|
+
// the same (tool, args) pair has been denied twice or more. The
|
|
172
|
+
// tracker is in-memory only — the audit ledger at
|
|
173
|
+
// `.pugi/events.jsonl` already captures the full per-event log for
|
|
174
|
+
// forensic replay; this surface is the model-facing aggregate.
|
|
175
|
+
const denialTracking = new DenialTrackingState();
|
|
176
|
+
// β1a r1 (budget wiring, 2026-05-26): swap the legacy SDK per-
|
|
177
|
+
// command budget lookup for the Pl9 `resolveBudget()` pipeline so
|
|
178
|
+
// `.pugi/settings.json::budgets.<command>` overrides actually take
|
|
179
|
+
// effect at runtime + the HARD_MAX_* caps guard misconfigured
|
|
180
|
+
// envelopes pre-flight. Before this fix the β1 Pl9 module
|
|
181
|
+
// (`core/engine/budgets.ts`) was dead code — the adapter still
|
|
182
|
+
// read the per-command defaults from the SDK, so operators who
|
|
183
|
+
// set `budgets.code.maxTokens = 50000` in settings.json got the
|
|
184
|
+
// legacy 30k anyway and `assertBudgetWithinTier` never ran.
|
|
185
|
+
//
|
|
186
|
+
// Task-level token override (e.g. CLI `--max-tokens`) keeps
|
|
187
|
+
// precedence; tool-call ceiling falls through to the resolved
|
|
188
|
+
// budget so a careless caller cannot disable the call-count
|
|
189
|
+
// guard by setting only token count.
|
|
190
|
+
const budget = resolveBudget(kind, settings, task.budget?.tokens ? { maxTokens: task.budget.tokens } : undefined);
|
|
191
|
+
// β3 streaming: pre-build the typed stream event queue so the hook
|
|
192
|
+
// callbacks below can push live events that this async generator
|
|
193
|
+
// yields IMMEDIATELY (instead of buffering until `runEngineLoop`
|
|
194
|
+
// completes). Operator now sees the first `tool.start` within
|
|
195
|
+
// ~tens of ms of the model emitting it, not 30+ s after the loop
|
|
196
|
+
// settles.
|
|
197
|
+
const streamQueue = new AsyncEventQueue();
|
|
198
|
+
const emitter = this.streamEmitter;
|
|
199
|
+
const supportsThinking = modelSupportsThinking(this.options.model);
|
|
200
|
+
/**
|
|
201
|
+
* Push one typed stream event into BOTH the per-run async queue
|
|
202
|
+
* (the CLI's iterator) and the long-lived emitter (the multiplex
|
|
203
|
+
* fan-out for admin-api SSE / cabinet WebSocket subscribers).
|
|
204
|
+
* The function stamps `timestamp` once so both consumers see the
|
|
205
|
+
* same wall clock.
|
|
206
|
+
*/
|
|
207
|
+
const emitStream = (event) => {
|
|
208
|
+
const stamped = {
|
|
209
|
+
...event,
|
|
210
|
+
timestamp: new Date().toISOString(),
|
|
211
|
+
};
|
|
212
|
+
streamQueue.push(stamped);
|
|
213
|
+
emitter.emit('event', stamped);
|
|
214
|
+
};
|
|
215
|
+
// r1 fix per triple-review Backend Architect P1: unify yield path via
|
|
216
|
+
// emitStream + streamQueue drain so the iterator consumer does NOT
|
|
217
|
+
// see this status frame twice. Pre-fix did both bare yield + emitStream
|
|
218
|
+
// → iterator got 2 copies, emitter got 1.
|
|
219
|
+
emitStream({
|
|
220
|
+
type: 'status',
|
|
221
|
+
message: `Pugi engine starting: kind=${kind} budget=${budget.maxToolCalls} calls / ${budget.maxTokens} tokens`,
|
|
222
|
+
});
|
|
223
|
+
// β5a R1+R4+R5+R6+P1 (2026-05-26): build the per-turn `<context>`
|
|
224
|
+
// prefix and apply the intent marker so the model sees:
|
|
225
|
+
// 1. cwd + open-files + per-dir-conventions block (R5+R6)
|
|
226
|
+
// 2. a `<intent kind="definitional">` wrapper when the operator
|
|
227
|
+
// asked a knowledge question (P1) — fixes the "What is grep?
|
|
228
|
+
// → bash man grep" loss mode flagged by the α7.X eval.
|
|
229
|
+
//
|
|
230
|
+
// All caps enforced inside the builders (5 KB block + 50 entries
|
|
231
|
+
// + top-3 markdown). Worst-case prompt growth is ~5 KB, well
|
|
232
|
+
// inside any per-command token budget.
|
|
233
|
+
//
|
|
234
|
+
// cwd is sourced from `process.cwd()` — the operator's shell pwd
|
|
235
|
+
// when they invoked `pugi`. For non-REPL CLI paths this is
|
|
236
|
+
// accurate; the REPL session retains the launch cwd for the
|
|
237
|
+
// lifetime of the session which is what the operator expects.
|
|
238
|
+
const cwdForTraverse = process.cwd();
|
|
239
|
+
// Leak L32 (2026-05-27): cwd → homedir walk-up that picks up every
|
|
240
|
+
// ambient `PUGI.md` (or `CLAUDE.md` as a fallback) the operator
|
|
241
|
+
// has placed above their workspace. This is the cross-project
|
|
242
|
+
// hierarchy walk — distinct from the workspace-bounded
|
|
243
|
+
// `loadTraversedMarkdown` below which only sees files INSIDE the
|
|
244
|
+
// workspace root. Render the concatenation once at session boot
|
|
245
|
+
// and prepend to the system prompt so the model treats the
|
|
246
|
+
// operator's personal guidance as ambient context for the whole
|
|
247
|
+
// session. `--bare` (Leak L22) skips this walk entirely.
|
|
248
|
+
let ambientContextBlock = '';
|
|
249
|
+
if (!isBareMode()) {
|
|
250
|
+
try {
|
|
251
|
+
const hierarchy = walkUpPugiMd(cwdForTraverse);
|
|
252
|
+
ambientContextBlock = renderAmbientContext(hierarchy);
|
|
132
253
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
254
|
+
catch {
|
|
255
|
+
// Pure FS surface — if it throws (programmer error in the
|
|
256
|
+
// walker, not a per-file fs error which is already swallowed
|
|
257
|
+
// inside) we drop ambient context for this session rather
|
|
258
|
+
// than crashing the engine loop. Doctor probe still surfaces
|
|
259
|
+
// the hierarchy state for operator triage.
|
|
260
|
+
ambientContextBlock = '';
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Leak L28 (2026-05-27): AST-light repo-map injection. We build a
|
|
264
|
+
// compact `## Repo map` block (capped at the formatter's default
|
|
265
|
+
// 8 KB ≈ 2K tokens) from the workspace source tree + splice it
|
|
266
|
+
// onto the system prompt alongside the ambient PUGI.md block.
|
|
267
|
+
// `--bare` skips this exactly like the PUGI.md walk — the engine
|
|
268
|
+
// sees nothing the operator did not explicitly hand it. The build
|
|
269
|
+
// is deferred к `setImmediate` semantics by being a sync call
|
|
270
|
+
// AFTER the boot probes; the cost is one stat per source file
|
|
271
|
+
// (the cache catches mtime-unchanged files и skips re-extraction).
|
|
272
|
+
// Failures are swallowed: repo-map is enrichment, never a gate.
|
|
273
|
+
let repoMapBlock = '';
|
|
274
|
+
if (!isBareMode()) {
|
|
275
|
+
try {
|
|
276
|
+
const { buildAndFormatRepoMap } = await import('../repo-map/build.js');
|
|
277
|
+
const verdict = buildAndFormatRepoMap({
|
|
278
|
+
root,
|
|
279
|
+
// Boot path is best-effort: never refresh during engine boot
|
|
280
|
+
// (the operator can `pugi repo-map --refresh` manually). The
|
|
281
|
+
// cache freshness check catches every realistic edit pattern
|
|
282
|
+
// and avoids walking the tree on every engine invocation.
|
|
283
|
+
refresh: false,
|
|
284
|
+
// Persist the cache so the next boot reuses extracts. Engine
|
|
285
|
+
// boot runs on every command, so missing the persist would
|
|
286
|
+
// hot-loop the extractor on each invocation.
|
|
287
|
+
writeCache: true,
|
|
288
|
+
// Omit the formatter's section header — the system prompt
|
|
289
|
+
// already structures the ambient blocks, и a second `##`
|
|
290
|
+
// would fragment the prompt cache на a model-by-model basis.
|
|
291
|
+
omitHeader: false,
|
|
137
292
|
});
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
293
|
+
if (verdict.build.ok && verdict.format && verdict.format.bytes > 0) {
|
|
294
|
+
repoMapBlock = verdict.format.text;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// Any failure in the repo-map pipeline drops the block. The
|
|
299
|
+
// engine continues without enrichment — the failure mode is
|
|
300
|
+
// identical to the cold-boot path before L28 landed.
|
|
301
|
+
repoMapBlock = '';
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
let traverseResult;
|
|
305
|
+
// Leak L22 (2026-05-27): `--bare` skips the parent-dir PUGI.md /
|
|
306
|
+
// AGENTS.md / CLAUDE.md / GEMINI.md walk-up. The engine sees only
|
|
307
|
+
// the operator's prompt + working-set + intent marker, with no
|
|
308
|
+
// ambient project context injection. Mirrors Claude Code's
|
|
309
|
+
// --bare semantics.
|
|
310
|
+
if (isBareMode()) {
|
|
311
|
+
traverseResult = { loaded: [], warnings: [], totalBytes: 0 };
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
try {
|
|
315
|
+
traverseResult = await loadTraversedMarkdown({
|
|
316
|
+
cwd: cwdForTraverse,
|
|
317
|
+
workspaceRoot: root,
|
|
144
318
|
});
|
|
145
319
|
}
|
|
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);
|
|
320
|
+
catch {
|
|
321
|
+
// Per-dir markdown is a NICE-TO-HAVE; a fs error here must
|
|
322
|
+
// never break the engine loop. Fall back to an empty result
|
|
323
|
+
// so the prefix block still surfaces cwd + working set.
|
|
324
|
+
traverseResult = { loaded: [], warnings: [], totalBytes: 0 };
|
|
161
325
|
}
|
|
162
|
-
|
|
326
|
+
}
|
|
327
|
+
const intentClassification = classifyIntent(task.prompt);
|
|
328
|
+
const intentHint = intentClassification.intent !== 'ambiguous' ? intentClassification.intent : undefined;
|
|
329
|
+
const cwdRelative = relativeOrAbsolute(root, cwdForTraverse);
|
|
330
|
+
const prefix = buildContextPrefix({
|
|
331
|
+
cwdRelative,
|
|
332
|
+
// β5a defers wiring the live WorkingSet snapshot to the REPL
|
|
333
|
+
// session integration (R5+R6 here only covers the engine-side
|
|
334
|
+
// builder). When the REPL passes its working set down, the
|
|
335
|
+
// engine surface fills in. For now the prefix carries cwd +
|
|
336
|
+
// per-dir conventions + intent which are the two biggest
|
|
337
|
+
// win-rate moves per the α7.X eval.
|
|
338
|
+
traversedMarkdown: traverseResult.loaded,
|
|
339
|
+
intentHint,
|
|
340
|
+
});
|
|
341
|
+
if (prefix.bytes > 0 || intentClassification.intent === 'definitional') {
|
|
342
|
+
emitStream({
|
|
163
343
|
type: 'status',
|
|
164
|
-
message: `
|
|
344
|
+
message: `context: cwd=${cwdRelative} per-dir-md=${prefix.counts.markdownIncluded}/${prefix.counts.markdownTotal} intent=${intentClassification.intent}`,
|
|
165
345
|
});
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
346
|
+
}
|
|
347
|
+
const decoratedPrompt = applyIntentMarker(task.prompt, intentClassification.intent);
|
|
348
|
+
const finalUserPrompt = spliceContextPrefix(prefix.block, decoratedPrompt);
|
|
349
|
+
// Track files mutated by the loop. We extract the path from the JSON
|
|
350
|
+
// arguments of every successful write/edit tool call; `bash` is left
|
|
351
|
+
// out because its filesystem footprint is opaque (a single command
|
|
352
|
+
// can touch dozens of paths via `make`, `pnpm build`, etc). The
|
|
353
|
+
// per-session events.jsonl already carries every file_mutation event
|
|
354
|
+
// for replay; this set is only the headline summary the CLI prints.
|
|
355
|
+
const filesChanged = new Set();
|
|
356
|
+
// Pending lookup: call.id → path extracted from arguments. We only
|
|
357
|
+
// commit to `filesChanged` when the corresponding onToolResult fires
|
|
358
|
+
// with `ok: true`, so a refused or failed edit does not surface as
|
|
359
|
+
// a phantom change in the operator summary.
|
|
360
|
+
const pendingMutations = new Map();
|
|
361
|
+
// Per-session events mirror — `.pugi/sessions/<id>/events.jsonl`.
|
|
362
|
+
// The existing global log at `.pugi/events.jsonl` is preserved as
|
|
363
|
+
// the audit-replay source of truth; this mirror is the easy-to-find
|
|
364
|
+
// per-run log for operators and the cabinet UI (Sprint 2B).
|
|
365
|
+
const sessionEventsPath = openSessionMirror(root, session.id);
|
|
366
|
+
const hooks = {
|
|
367
|
+
onTurnStart: (turnIndex, messageCount) => {
|
|
368
|
+
const msg = `turn ${turnIndex + 1}: requesting model (transcript=${messageCount} messages)`;
|
|
369
|
+
emitStream({ type: 'status', message: msg });
|
|
370
|
+
appendSessionMirror(sessionEventsPath, { type: 'turn_start', turn: turnIndex + 1, transcript: messageCount });
|
|
371
|
+
},
|
|
372
|
+
onTurnComplete: (turnIndex, response) => {
|
|
373
|
+
if (response.stop === 'tool_use') {
|
|
374
|
+
const calls = response.assistantMessage.toolCalls ?? [];
|
|
375
|
+
emitStream({
|
|
376
|
+
type: 'status',
|
|
377
|
+
message: `turn ${turnIndex + 1}: model requested ${calls.length} tool call(s)`,
|
|
378
|
+
});
|
|
379
|
+
appendSessionMirror(sessionEventsPath, {
|
|
380
|
+
type: 'turn_complete',
|
|
381
|
+
turn: turnIndex + 1,
|
|
382
|
+
stop: 'tool_use',
|
|
383
|
+
toolCalls: calls.length,
|
|
384
|
+
tokensUsed: response.tokensUsed,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
else if (response.stop === 'text') {
|
|
388
|
+
emitStream({
|
|
389
|
+
type: 'status',
|
|
390
|
+
message: `turn ${turnIndex + 1}: model returned final text (${response.content.length} chars)`,
|
|
391
|
+
});
|
|
392
|
+
appendSessionMirror(sessionEventsPath, {
|
|
393
|
+
type: 'turn_complete',
|
|
394
|
+
turn: turnIndex + 1,
|
|
395
|
+
stop: 'text',
|
|
396
|
+
contentLength: response.content.length,
|
|
397
|
+
tokensUsed: response.tokensUsed,
|
|
398
|
+
});
|
|
399
|
+
// β3 E4 thinking-block surface: only Claude / Gemini families
|
|
400
|
+
// advertise structured thinking today. The model resolver may
|
|
401
|
+
// return a slug we don't recognise; in that case we skip the
|
|
402
|
+
// split silently. When we DO recognise it, every `<thinking>`
|
|
403
|
+
// / `<thought>` block becomes a separate `thinking.start`/
|
|
404
|
+
// `thinking.delta`/`thinking.end` triplet so the TUI can
|
|
405
|
+
// render one collapsed pane row per block. The visible text
|
|
406
|
+
// (post-strip) flows to the regular `text.delta` channel so
|
|
407
|
+
// the conversation pane never shows raw <thinking> markup.
|
|
408
|
+
if (supportsThinking && response.content.length > 0) {
|
|
409
|
+
const split = splitThinkingBlocks(response.content);
|
|
410
|
+
for (const block of split.thinkingBlocks) {
|
|
411
|
+
const blockId = `think-${randomUUID().slice(0, 8)}`;
|
|
412
|
+
emitStream({ type: 'thinking.start', blockId });
|
|
413
|
+
emitStream({ type: 'thinking.delta', blockId, chunk: block });
|
|
414
|
+
emitStream({ type: 'thinking.end', blockId });
|
|
415
|
+
}
|
|
416
|
+
if (split.visibleText.length > 0) {
|
|
417
|
+
emitStream({ type: 'text.delta', chunk: split.visibleText });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
else if (response.content.length > 0) {
|
|
421
|
+
emitStream({ type: 'text.delta', chunk: response.content });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
onToolCall: (call) => {
|
|
426
|
+
// Record under an `engine_tool` prefix so the audit log can
|
|
427
|
+
// distinguish loop-driven calls from direct CLI tool calls.
|
|
428
|
+
const id = recordToolCall(session, `engine:${call.name}`, call.arguments.slice(0, 200));
|
|
429
|
+
// Stash the audit id on the call for `onToolResult` to close.
|
|
430
|
+
this.engineToolCallIds.set(call.id, id);
|
|
431
|
+
// Extract a candidate path for write/edit so we can build the
|
|
432
|
+
// filesChanged summary if (and only if) the call succeeds. Bad
|
|
433
|
+
// JSON is harmless here — we ignore it and the executor surfaces
|
|
434
|
+
// the actual parse error to the model.
|
|
435
|
+
if (call.name === 'write' || call.name === 'edit') {
|
|
436
|
+
const path = extractPathArg(call.arguments);
|
|
437
|
+
if (path)
|
|
438
|
+
pendingMutations.set(call.id, path);
|
|
439
|
+
}
|
|
440
|
+
emitStream({
|
|
441
|
+
type: 'tool.start',
|
|
442
|
+
callId: call.id,
|
|
443
|
+
name: call.name,
|
|
444
|
+
arguments: call.arguments,
|
|
445
|
+
});
|
|
446
|
+
emitStream({
|
|
447
|
+
type: 'status',
|
|
448
|
+
message: `tool_call: ${call.name}(${call.arguments.slice(0, 80)}${call.arguments.length > 80 ? '...' : ''})`,
|
|
449
|
+
});
|
|
450
|
+
appendSessionMirror(sessionEventsPath, {
|
|
451
|
+
type: 'tool_call',
|
|
452
|
+
tool: call.name,
|
|
453
|
+
callId: call.id,
|
|
454
|
+
argsPreview: call.arguments.slice(0, 200),
|
|
455
|
+
});
|
|
456
|
+
},
|
|
457
|
+
onToolResult: (call, result) => {
|
|
458
|
+
const auditId = this.engineToolCallIds.get(call.id);
|
|
459
|
+
if (auditId) {
|
|
460
|
+
if (result.ok) {
|
|
461
|
+
recordToolResult(session, auditId, 'success', result.content.slice(0, 200));
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
recordToolResult(session, auditId, 'error', result.error.slice(0, 200));
|
|
465
|
+
}
|
|
466
|
+
this.engineToolCallIds.delete(call.id);
|
|
178
467
|
}
|
|
179
|
-
|
|
180
|
-
|
|
468
|
+
const pendingPath = pendingMutations.get(call.id);
|
|
469
|
+
if (pendingPath) {
|
|
470
|
+
if (result.ok)
|
|
471
|
+
filesChanged.add(pendingPath);
|
|
472
|
+
pendingMutations.delete(call.id);
|
|
181
473
|
}
|
|
182
|
-
|
|
474
|
+
emitStream({
|
|
475
|
+
type: 'tool.end',
|
|
476
|
+
callId: call.id,
|
|
477
|
+
ok: result.ok,
|
|
478
|
+
summary: result.ok
|
|
479
|
+
? result.content.slice(0, 200)
|
|
480
|
+
: result.error.slice(0, 200),
|
|
481
|
+
});
|
|
482
|
+
emitStream({
|
|
483
|
+
type: 'status',
|
|
484
|
+
message: result.ok
|
|
485
|
+
? `tool_result: ${call.name} ok`
|
|
486
|
+
: `tool_result: ${call.name} error: ${result.error.slice(0, 120)}`,
|
|
487
|
+
});
|
|
488
|
+
appendSessionMirror(sessionEventsPath, {
|
|
489
|
+
type: 'tool_result',
|
|
490
|
+
tool: call.name,
|
|
491
|
+
callId: call.id,
|
|
492
|
+
ok: result.ok,
|
|
493
|
+
summary: result.ok ? result.content.slice(0, 200) : result.error.slice(0, 200),
|
|
494
|
+
});
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
// β1b r1 (--allow-fetch / --allow-search wiring, 2026-05-26):
|
|
498
|
+
// compute the effective gate as OR of (a) the persisted
|
|
499
|
+
// settings.json opt-in and (b) the runtime CLI flag passed via
|
|
500
|
+
// the constructor. Before this fix the adapter only honored (a),
|
|
501
|
+
// so `pugi code --allow-fetch` against a default-privacy workspace
|
|
502
|
+
// silently fell back to "tool not advertised" even though the
|
|
503
|
+
// operator opted in for one invocation. The CLI flag was wired
|
|
504
|
+
// through to the legacy `pugi web` sub-command but not to the
|
|
505
|
+
// engine adapter — Backend Architect review (PR #425 r1) caught
|
|
506
|
+
// the gap.
|
|
507
|
+
const allowFetchEffective = this.options.allowFetch === true || settings.web?.fetch?.enabled === true;
|
|
508
|
+
const allowSearchEffective = this.options.allowSearch === true || settings.web?.search?.enabled === true;
|
|
509
|
+
// β2 S3 (2026-05-26) → β2a r1 (Backend Architect P1, 2026-05-26):
|
|
510
|
+
// expose the `agent` tool to the parent loop ONLY for non-plan
|
|
511
|
+
// commands. `buildToolsSchema` also strips the agent tool from
|
|
512
|
+
// plan-mode schemas, but a model that fabricates an `agent` call
|
|
513
|
+
// would still hit the executor with `agentDispatch` wired and
|
|
514
|
+
// could spawn a coder that mutates the workspace — breaking the
|
|
515
|
+
// plan-mode read-only contract. Hard-gate `allowAgent` on the
|
|
516
|
+
// command kind so plan mode never wires the dispatch block in
|
|
517
|
+
// the first place; tool-bridge.ts also throws ToolRefused on a
|
|
518
|
+
// fabricated `agent` call in plan mode as defense in depth.
|
|
519
|
+
//
|
|
520
|
+
// Why only the top-level parent and not children: the dispatcher-
|
|
521
|
+
// real.ts module builds the CHILD's executor without an
|
|
522
|
+
// `agentDispatch` block so children cannot recursively spawn
|
|
523
|
+
// grandchildren. The isolation-matrix capability set then refuses
|
|
524
|
+
// the `agent` tool for every non-orchestrator role anyway, but
|
|
525
|
+
// the executor-level gate is the load-bearing chokepoint.
|
|
526
|
+
const allowAgent = kind !== 'plan';
|
|
527
|
+
// β3 streaming: kick off `runEngineLoop` IN PARALLEL with the queue
|
|
528
|
+
// drain. The loop's hook callbacks push events onto `streamQueue`
|
|
529
|
+
// synchronously; this generator yields them live by awaiting the
|
|
530
|
+
// queue's iterator. When the loop settles (success or crash) we
|
|
531
|
+
// close the queue, which lets the iterator return cleanly and the
|
|
532
|
+
// generator falls through to the terminal `result` frame.
|
|
533
|
+
//
|
|
534
|
+
// Why concurrent instead of serial:
|
|
535
|
+
//
|
|
536
|
+
// The β1 adapter awaited `runEngineLoop` to completion, then
|
|
537
|
+
// drained an in-memory `EngineEvent[]` buffer. Operator saw
|
|
538
|
+
// nothing for 30+ seconds (the full LLM round-trip + tool exec
|
|
539
|
+
// wall time), then the entire log dumped at once. The TUI tool-
|
|
540
|
+
// stream pane was a no-op because no event ever reached it
|
|
541
|
+
// before the loop completed.
|
|
542
|
+
//
|
|
543
|
+
// `Promise.race`-based interleaving lets us yield the next queue
|
|
544
|
+
// event OR detect loop settlement on each tick. The settlement
|
|
545
|
+
// flag (`loopSettled`) gates the final drain so we never miss
|
|
546
|
+
// tail events that the hooks pushed in the same microtask as
|
|
547
|
+
// the loop's terminal `return`.
|
|
548
|
+
// Boxed via single-element tuple so TypeScript does not narrow the
|
|
549
|
+
// outer `outcome` binding to `null` after the closure mutation.
|
|
550
|
+
// Async-closure mutations are invisible to TS control-flow analysis;
|
|
551
|
+
// wrapping in a tuple defeats the narrowing without an unsafe cast.
|
|
552
|
+
const outcomeBox = [null];
|
|
553
|
+
let loopError = null;
|
|
554
|
+
const loopPromise = (async () => {
|
|
555
|
+
try {
|
|
556
|
+
outcomeBox[0] = await runEngineLoop({
|
|
557
|
+
client: this.options.client,
|
|
558
|
+
executor: buildExecutor({
|
|
559
|
+
kind,
|
|
560
|
+
ctx: toolCtx,
|
|
561
|
+
sessionId: session.id,
|
|
562
|
+
workspaceRoot: root,
|
|
563
|
+
// P1 fix (deep audit 2026-05-26): forward optional REPL
|
|
564
|
+
// ask-modal bridge. Default `interactive: false` preserves
|
|
565
|
+
// backward compat — non-TTY callers (CI, pipes, scripted
|
|
566
|
+
// CLI runs) keep the `[user_input_required]` envelope path.
|
|
567
|
+
// The REPL layer passes `interactive: true` + a real
|
|
568
|
+
// `askUserBridge` so model-initiated `ask_user_question`
|
|
569
|
+
// calls round-trip to the ink modal and return the
|
|
570
|
+
// operator's choice as a tool result.
|
|
571
|
+
interactive: this.options.interactive === true,
|
|
572
|
+
...(this.options.askUserBridge
|
|
573
|
+
? { askUserBridge: this.options.askUserBridge }
|
|
574
|
+
: {}),
|
|
575
|
+
// P1 fix (deep audit 2026-05-26): forward the workspace
|
|
576
|
+
// HookRegistry so `.pugi/hooks/` lifecycle hooks fire for
|
|
577
|
+
// model-initiated tool calls. SECURITY: a `PreToolUse
|
|
578
|
+
// onFailure: 'block'` hook that refuses bash containing
|
|
579
|
+
// `rm` now applies to model dispatch — before this fix
|
|
580
|
+
// such a hook only applied to direct CLI tool calls.
|
|
581
|
+
...(this.options.hooks ? { hooks: this.options.hooks } : {}),
|
|
582
|
+
// β1a r1 (web_fetch gating) + β1b r1 (--allow-fetch wiring):
|
|
583
|
+
// executor allowFetch matches the schema-advertise gate so a
|
|
584
|
+
// settings.json opt-in OR a --allow-fetch flag enables the
|
|
585
|
+
// call. Without this the model would not even see the
|
|
586
|
+
// `web_fetch` tool. `allowSearch` covers the new T4
|
|
587
|
+
// `web_search` tool with the same OR semantics.
|
|
588
|
+
allowFetch: allowFetchEffective,
|
|
589
|
+
allowSearch: allowSearchEffective,
|
|
590
|
+
// β2 S3 → β2a r1 (2026-05-26): parent-level agentDispatch
|
|
591
|
+
// wiring. When the model emits a `tool_call: agent(role,
|
|
592
|
+
// brief)`, the executor forwards it to dispatcher-real.ts
|
|
593
|
+
// which spawns a child engine loop against the same Anvil
|
|
594
|
+
// client. Gated by `allowAgent` so plan mode does not even
|
|
595
|
+
// wire the dispatch block — defense in depth on top of the
|
|
596
|
+
// schema-filter and the tool-bridge plan-mode refusal.
|
|
597
|
+
...(allowAgent
|
|
598
|
+
? {
|
|
599
|
+
agentDispatch: {
|
|
600
|
+
parentSession: session,
|
|
601
|
+
engineClient: this.options.client,
|
|
602
|
+
},
|
|
603
|
+
}
|
|
604
|
+
: {}),
|
|
605
|
+
// β4 M1/M3/M5: pass the loaded MCP registry through so the
|
|
606
|
+
// executor can route `mcp__server__tool` calls + run the
|
|
607
|
+
// first-call permission prompt before dispatching upstream.
|
|
608
|
+
...(this.options.mcpRegistry ? { mcpRegistry: this.options.mcpRegistry } : {}),
|
|
609
|
+
...(this.options.mcpPrompt ? { mcpPrompt: this.options.mcpPrompt } : {}),
|
|
610
|
+
// α7 L11 (2026-05-27): per-`run()` denial tracker. Every
|
|
611
|
+
// refusal sentinel (PLAN_MODE_REFUSED, HOOK_BLOCKED,
|
|
612
|
+
// OPERATOR_ABORTED, STALE_READ, unknown-tool, plan-mode
|
|
613
|
+
// agent) is fingerprinted by (toolName, sha256(canonical
|
|
614
|
+
// args)) so the model's next-turn reminder surfaces the
|
|
615
|
+
// pattern instead of re-issuing the same refused call.
|
|
616
|
+
denialTracking,
|
|
617
|
+
}),
|
|
618
|
+
// Leak L32 (2026-05-27): ambient `PUGI.md` hierarchy block
|
|
619
|
+
// prepended once at session boot. When the walk found
|
|
620
|
+
// nothing OR bare mode is on, `ambientContextBlock === ''`
|
|
621
|
+
// and the system prompt is unchanged — no leading blank
|
|
622
|
+
// line, no empty wrapper tag.
|
|
623
|
+
//
|
|
624
|
+
// Leak L28 (2026-05-27): the `repoMapBlock` is splice'd
|
|
625
|
+
// between the ambient PUGI.md and the persona prompt so
|
|
626
|
+
// the model sees the workspace structure WITH the operator's
|
|
627
|
+
// ambient guidance fronting it. Empty blocks drop cleanly:
|
|
628
|
+
// `composeSystemPrompt` filters falsy entries before joining.
|
|
629
|
+
systemPrompt: composeSystemPrompt([
|
|
630
|
+
ambientContextBlock,
|
|
631
|
+
repoMapBlock,
|
|
632
|
+
systemPromptFor(kind),
|
|
633
|
+
]),
|
|
634
|
+
// β5a R5+R6+P1: per-turn `<context>` prefix + intent marker
|
|
635
|
+
// applied above. Falls back to verbatim `task.prompt` when
|
|
636
|
+
// both the prefix block is empty AND the intent classifier
|
|
637
|
+
// returned ambiguous (the splice + apply functions handle
|
|
638
|
+
// that case as identity).
|
|
639
|
+
userPrompt: finalUserPrompt,
|
|
640
|
+
// β1a r1 (web_fetch gating) + β1b r1 (--allow-fetch wiring):
|
|
641
|
+
// pass the OR of `.pugi/settings.json::web.fetch.enabled` and
|
|
642
|
+
// the runtime `--allow-fetch` flag. When neither is true the
|
|
643
|
+
// `web_fetch` tool is not advertised to the model at all.
|
|
644
|
+
// `allowSearch` does the same for the new `web_search` tool.
|
|
645
|
+
// β2 S3: allowAgent surfaces the `agent` tool in the schema
|
|
646
|
+
// so the model sees it as a valid tool call option; the
|
|
647
|
+
// capability-matrix layer (S4) still gates which roles can
|
|
648
|
+
// actually USE it. Plan mode strips it via β2a r1 gate.
|
|
649
|
+
tools: buildToolsSchema(kind, {
|
|
650
|
+
allowFetch: allowFetchEffective,
|
|
651
|
+
allowSearch: allowSearchEffective,
|
|
652
|
+
allowAgent,
|
|
653
|
+
// β4 M1/M3: same registry the executor saw. Schema +
|
|
654
|
+
// dispatcher must agree on which MCP names are advertised
|
|
655
|
+
// and which are dispatchable; passing identical references
|
|
656
|
+
// makes that invariant impossible to break.
|
|
657
|
+
...(this.options.mcpRegistry ? { mcpRegistry: this.options.mcpRegistry } : {}),
|
|
658
|
+
}),
|
|
659
|
+
budget,
|
|
660
|
+
personaSlug: personaSlugFor(kind),
|
|
661
|
+
hooks,
|
|
662
|
+
temperature: this.options.temperature ?? 0.2,
|
|
663
|
+
signal: ctx.signal,
|
|
664
|
+
// β1 (audit E2): forward CLI sub-command + α6.10 routing tag +
|
|
665
|
+
// operator-pinned model so the runtime controller's DTO sees
|
|
666
|
+
// all three. `tag` derives 1:1 from `command` for now
|
|
667
|
+
// (`code → code`, `build → build_task`, etc.); future routing
|
|
668
|
+
// changes flip the mapping table without touching the call
|
|
669
|
+
// site. `model` is left undefined here — operator-pinned model
|
|
670
|
+
// pinning ships in β6 with persona routing.
|
|
671
|
+
command: kind,
|
|
672
|
+
tag: dispatchTagFor(kind),
|
|
673
|
+
model: this.options.model,
|
|
674
|
+
});
|
|
183
675
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (result.ok)
|
|
187
|
-
filesChanged.add(pendingPath);
|
|
188
|
-
pendingMutations.delete(call.id);
|
|
676
|
+
catch (err) {
|
|
677
|
+
loopError = err;
|
|
189
678
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
679
|
+
finally {
|
|
680
|
+
// Close the queue so the iterator below returns `done: true`.
|
|
681
|
+
// Any tail events the hooks pushed in the same microtask still
|
|
682
|
+
// drain because `AsyncEventQueue.close()` only resolves
|
|
683
|
+
// PENDING awaiters — buffered items stay readable.
|
|
684
|
+
streamQueue.close();
|
|
685
|
+
}
|
|
686
|
+
})();
|
|
687
|
+
// Drain the queue live. Each iteration yields one EngineEvent the
|
|
688
|
+
// moment its hook fired. Operator sees `tool.start` within tens of
|
|
689
|
+
// ms of the model emitting it.
|
|
690
|
+
for await (const event of streamQueue) {
|
|
691
|
+
yield streamEventToEngineEvent(event);
|
|
692
|
+
}
|
|
693
|
+
// Loop has settled (queue closed). Surface its outcome — either an
|
|
694
|
+
// unhandled crash from the (rare) executor exception path or the
|
|
695
|
+
// structured EngineLoopOutcome.
|
|
696
|
+
await loopPromise;
|
|
697
|
+
if (loopError !== null) {
|
|
698
|
+
const message = loopError instanceof Error ? loopError.message : String(loopError);
|
|
699
|
+
yield {
|
|
700
|
+
type: 'result',
|
|
701
|
+
result: {
|
|
702
|
+
status: 'failed',
|
|
703
|
+
summary: `engine loop crashed: ${message}`,
|
|
704
|
+
filesChanged: [],
|
|
705
|
+
patchRefs: [],
|
|
706
|
+
testsRun: [],
|
|
707
|
+
risks: [`unhandled error in engine adapter: ${message}`],
|
|
708
|
+
eventRefs: [],
|
|
709
|
+
},
|
|
710
|
+
};
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
const finalOutcome = outcomeBox[0];
|
|
714
|
+
if (finalOutcome === null) {
|
|
715
|
+
// Defensive — should never hit. `runEngineLoop` always either
|
|
716
|
+
// resolves with an outcome or throws (and we catch that above).
|
|
717
|
+
yield {
|
|
718
|
+
type: 'result',
|
|
719
|
+
result: {
|
|
720
|
+
status: 'failed',
|
|
721
|
+
summary: 'engine loop returned no outcome',
|
|
722
|
+
filesChanged: [],
|
|
723
|
+
patchRefs: [],
|
|
724
|
+
testsRun: [],
|
|
725
|
+
risks: ['runEngineLoop resolved without an outcome value'],
|
|
726
|
+
eventRefs: [],
|
|
727
|
+
},
|
|
728
|
+
};
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
// Translate the loop outcome into an EngineResult.
|
|
732
|
+
// `aborted` (α6.9: operator cancelled mid-tool) maps to `blocked`
|
|
733
|
+
// because the operator chose the outcome, same shape as
|
|
734
|
+
// budget_exhausted / tool_refused.
|
|
735
|
+
const status = finalOutcome.status === 'completed'
|
|
736
|
+
? 'done'
|
|
737
|
+
: finalOutcome.status === 'failed'
|
|
738
|
+
? 'failed'
|
|
739
|
+
: 'blocked';
|
|
740
|
+
const summaryPrefix = finalOutcome.status === 'completed'
|
|
741
|
+
? ''
|
|
742
|
+
: finalOutcome.status === 'budget_exhausted'
|
|
743
|
+
? '[budget_exhausted] '
|
|
744
|
+
: finalOutcome.status === 'tool_refused'
|
|
745
|
+
? '[plan_mode_refused] '
|
|
746
|
+
: finalOutcome.status === 'aborted'
|
|
747
|
+
? '[operator_aborted] '
|
|
748
|
+
: '[failed] ';
|
|
749
|
+
const filesChangedList = Array.from(filesChanged).sort();
|
|
750
|
+
appendSessionMirror(sessionEventsPath, {
|
|
751
|
+
type: 'outcome',
|
|
752
|
+
status: finalOutcome.status,
|
|
753
|
+
toolCallCount: finalOutcome.toolCallCount,
|
|
754
|
+
turnsUsed: finalOutcome.turnsUsed,
|
|
755
|
+
tokensUsed: finalOutcome.tokensUsed,
|
|
756
|
+
filesChanged: filesChangedList,
|
|
757
|
+
reason: finalOutcome.reason,
|
|
218
758
|
});
|
|
219
|
-
}
|
|
220
|
-
catch (error) {
|
|
221
|
-
// Defensive — runEngineLoop wraps errors into status: failed, so
|
|
222
|
-
// this branch is only hit if the executor or hooks themselves
|
|
223
|
-
// throw uncaught. Surface as a failed result so the CLI exits
|
|
224
|
-
// non-zero rather than hanging.
|
|
225
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
226
759
|
yield {
|
|
227
760
|
type: 'result',
|
|
228
761
|
result: {
|
|
229
|
-
status
|
|
230
|
-
summary:
|
|
231
|
-
filesChanged:
|
|
762
|
+
status,
|
|
763
|
+
summary: `${summaryPrefix}${finalOutcome.finalText || finalOutcome.reason || 'no answer returned'}`,
|
|
764
|
+
filesChanged: filesChangedList,
|
|
232
765
|
patchRefs: [],
|
|
233
766
|
testsRun: [],
|
|
234
|
-
risks:
|
|
235
|
-
|
|
767
|
+
risks: finalOutcome.status === 'completed'
|
|
768
|
+
? []
|
|
769
|
+
: [finalOutcome.reason ?? `outcome=${finalOutcome.status}`],
|
|
770
|
+
eventRefs: [
|
|
771
|
+
`tool_calls=${finalOutcome.toolCallCount}`,
|
|
772
|
+
`turns=${finalOutcome.turnsUsed}`,
|
|
773
|
+
`tokens=${finalOutcome.tokensUsed}`,
|
|
774
|
+
// `outcome=<status>` is a machine-readable echo so callers
|
|
775
|
+
// (cli.ts plan exit code, cabinet UI) can distinguish
|
|
776
|
+
// `budget_exhausted` from `tool_refused` without parsing
|
|
777
|
+
// the human-readable summary prefix. Code Reviewer P2
|
|
778
|
+
// retro 2026-05-23: plan exit code previously collapsed
|
|
779
|
+
// both blocked reasons into 0, which masked budget hits.
|
|
780
|
+
`outcome=${finalOutcome.status}`,
|
|
781
|
+
`session=${session.id}`,
|
|
782
|
+
`ctx=${ctx.sessionId}`,
|
|
783
|
+
`mirror=${sessionEventsPath}`,
|
|
784
|
+
],
|
|
236
785
|
},
|
|
237
786
|
};
|
|
238
|
-
return;
|
|
239
787
|
}
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
status,
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
788
|
+
finally {
|
|
789
|
+
// r2 (triple-review 2026-05-26 P1): detach the abort listener so
|
|
790
|
+
// long REPL sessions sharing one AbortController across many
|
|
791
|
+
// run() invocations do not accumulate one listener per run on
|
|
792
|
+
// `ctx.signal`. Called on success, abort, and uncaught throw.
|
|
793
|
+
detachAbortListener?.();
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* β3 streaming: translate one typed `EngineStreamEvent` from the
|
|
799
|
+
* adapter's internal queue into the SDK's lossier `EngineEvent` shape
|
|
800
|
+
* the public adapter contract exposes. The SDK contract only declares
|
|
801
|
+
* `status | result` today; richer events (`tool.start`, `thinking.delta`,
|
|
802
|
+
* etc.) collapse to a structured `status` message until the SDK widens
|
|
803
|
+
* the discriminated union (β3b — paired with an admin-api SSE schema
|
|
804
|
+
* bump so the wire format stays stable).
|
|
805
|
+
*
|
|
806
|
+
* The full typed payload is still available to richer consumers via
|
|
807
|
+
* `adapter.streamEmitter.on('event', ...)`. The CLI's TUI tool-stream
|
|
808
|
+
* pane consumes that emitter directly; this function is the safe
|
|
809
|
+
* bridge for legacy SDK consumers that only know `EngineEvent`.
|
|
810
|
+
*/
|
|
811
|
+
function streamEventToEngineEvent(stream) {
|
|
812
|
+
switch (stream.type) {
|
|
813
|
+
case 'status':
|
|
814
|
+
return { type: 'status', message: stream.message };
|
|
815
|
+
case 'tool.start':
|
|
816
|
+
return {
|
|
817
|
+
type: 'status',
|
|
818
|
+
message: `tool.start ${stream.name} call=${stream.callId} args=${stream.arguments.slice(0, 80)}${stream.arguments.length > 80 ? '...' : ''}`,
|
|
819
|
+
};
|
|
820
|
+
case 'tool.delta':
|
|
821
|
+
return {
|
|
822
|
+
type: 'status',
|
|
823
|
+
message: `tool.delta call=${stream.callId} chunk=${stream.chunk.slice(0, 120)}`,
|
|
824
|
+
};
|
|
825
|
+
case 'tool.end':
|
|
826
|
+
return {
|
|
827
|
+
type: 'status',
|
|
828
|
+
message: `tool.end call=${stream.callId} ok=${stream.ok} summary=${stream.summary.slice(0, 120)}`,
|
|
829
|
+
};
|
|
830
|
+
case 'thinking.start':
|
|
831
|
+
return { type: 'status', message: `thinking.start block=${stream.blockId}` };
|
|
832
|
+
case 'thinking.delta':
|
|
833
|
+
return {
|
|
834
|
+
type: 'status',
|
|
835
|
+
message: `thinking.delta block=${stream.blockId} chunk=${stream.chunk.slice(0, 120)}`,
|
|
836
|
+
};
|
|
837
|
+
case 'thinking.end':
|
|
838
|
+
return { type: 'status', message: `thinking.end block=${stream.blockId}` };
|
|
839
|
+
case 'text.delta':
|
|
840
|
+
return {
|
|
841
|
+
type: 'status',
|
|
842
|
+
message: `text.delta chunk=${stream.chunk.slice(0, 200)}`,
|
|
843
|
+
};
|
|
844
|
+
default: {
|
|
845
|
+
// Exhaustiveness — TS catches a missing variant at compile time.
|
|
846
|
+
const exhaustive = stream;
|
|
847
|
+
void exhaustive;
|
|
848
|
+
return { type: 'status', message: 'unknown stream event' };
|
|
849
|
+
}
|
|
299
850
|
}
|
|
300
851
|
}
|
|
301
852
|
/**
|
|
@@ -311,7 +862,14 @@ function extractPathArg(raw) {
|
|
|
311
862
|
try {
|
|
312
863
|
const parsed = JSON.parse(raw);
|
|
313
864
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
314
|
-
const
|
|
865
|
+
const obj = parsed;
|
|
866
|
+
// Accept canonical `path` OR the Claude-Code-trained `filePath`
|
|
867
|
+
// alias so the filesChanged summary captures writes regardless of
|
|
868
|
+
// which key the model emitted. Without the alias the operator
|
|
869
|
+
// sees "Files modified: none" even when a write actually landed,
|
|
870
|
+
// because the dispatcher accepted the alias but the tracker did
|
|
871
|
+
// not (CEO live smoke 2026-05-28).
|
|
872
|
+
const path = obj['path'] ?? obj['filePath'];
|
|
315
873
|
if (typeof path === 'string' && path.length > 0)
|
|
316
874
|
return path;
|
|
317
875
|
}
|
|
@@ -367,8 +925,99 @@ function toCommandKind(kind) {
|
|
|
367
925
|
return 'build';
|
|
368
926
|
return kind;
|
|
369
927
|
}
|
|
928
|
+
/**
|
|
929
|
+
* β1 (audit E2) → β1a r1 (engine tag contract fix, 2026-05-26): map a
|
|
930
|
+
* CLI command kind to its α6.10 dispatch tag.
|
|
931
|
+
*
|
|
932
|
+
* The admin-api controller (`pugi-engine.controller.ts`) routes per-tag
|
|
933
|
+
* to a model/persona pair via
|
|
934
|
+
* `apps/admin-api/src/mira/routing/dispatch-tag.ts::DISPATCH_TAGS`. The
|
|
935
|
+
* closed `EngineChatTag` vocabulary is
|
|
936
|
+
* `classify | reason | codegen | summarize | vision` — note that
|
|
937
|
+
* `code`, `fix`, `plan`, `build`, `explain` (CLI command names) are NOT
|
|
938
|
+
* in this set.
|
|
939
|
+
*
|
|
940
|
+
* Before this fix `dispatchTagFor()` returned the CLI command names
|
|
941
|
+
* as-is and the runtime DTO rejected the payload with HTTP 400
|
|
942
|
+
* (`tag must be one of: classify, reason, codegen, summarize, vision`)
|
|
943
|
+
* before ever reaching the routing layer. Every `pugi code/fix/plan/
|
|
944
|
+
* build/explain` against the live runtime returned `failed: HTTP 400`.
|
|
945
|
+
*
|
|
946
|
+
* Mapping rationale (each row keeps the most informative `tag` value
|
|
947
|
+
* for cost telemetry / model selection):
|
|
948
|
+
*
|
|
949
|
+
* - `code`, `fix` → `codegen` (edits / diffs / patches)
|
|
950
|
+
* - `build_task`/`build` → `codegen` + `budget_hint: 'max'`
|
|
951
|
+
* (scaffolding hits the 30-call / 80k-token ceiling — give the
|
|
952
|
+
* router permission to pick the largest model in the tier)
|
|
953
|
+
* - `plan` → `reason` (no mutations, long-form thought)
|
|
954
|
+
* - `explain` → `summarize` (read-only walkthrough)
|
|
955
|
+
*
|
|
956
|
+
* `priority: 'realtime'` for every command — Pugi is an interactive
|
|
957
|
+
* CLI; background dispatch is reserved for the cabinet's RAG ingest
|
|
958
|
+
* cron path. `budget_hint: 'std'` is the default for the cost-balanced
|
|
959
|
+
* router row; only `build_task` opts up to `'max'`.
|
|
960
|
+
*/
|
|
961
|
+
export function dispatchTagFor(kind) {
|
|
962
|
+
switch (kind) {
|
|
963
|
+
case 'code':
|
|
964
|
+
case 'fix':
|
|
965
|
+
return { tag: 'codegen', priority: 'realtime', budget_hint: 'std' };
|
|
966
|
+
case 'build':
|
|
967
|
+
// `build_task` on the engine task kind side is the heavy
|
|
968
|
+
// scaffolding lane — biggest budget envelope, biggest model
|
|
969
|
+
// permitted via `budget_hint: 'max'`.
|
|
970
|
+
return { tag: 'codegen', priority: 'realtime', budget_hint: 'max' };
|
|
971
|
+
case 'plan':
|
|
972
|
+
return { tag: 'reason', priority: 'realtime', budget_hint: 'std' };
|
|
973
|
+
case 'explain':
|
|
974
|
+
return { tag: 'summarize', priority: 'realtime', budget_hint: 'std' };
|
|
975
|
+
default: {
|
|
976
|
+
// Exhaustiveness check — `EngineCommandKind` is a closed union,
|
|
977
|
+
// so the switch above covers every case. If a new command kind
|
|
978
|
+
// is added the compiler flags this branch and the map must be
|
|
979
|
+
// extended. Fall back to `reason` as the most conservative
|
|
980
|
+
// routing choice so a future kind addition cannot accidentally
|
|
981
|
+
// unlock a write-heavy model lane.
|
|
982
|
+
const exhaustive = kind;
|
|
983
|
+
void exhaustive;
|
|
984
|
+
return { tag: 'reason', priority: 'realtime', budget_hint: 'std' };
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
370
988
|
// The per-adapter `engineToolCallIds` Map lives on the
|
|
371
989
|
// `NativePugiEngineAdapter` instance above — Code Reviewer P2 retro
|
|
372
990
|
// 2026-05-23 lifted it off the module scope to prevent collisions
|
|
373
991
|
// under parallel adapter runs (cabinet UI + CLI sharing one process).
|
|
992
|
+
/**
|
|
993
|
+
* β5a R5+R6: render a cwd path as either a workspace-root-relative
|
|
994
|
+
* string (when cwd is inside the workspace) or a `.` token (when cwd
|
|
995
|
+
* equals workspaceRoot). Falls back to the absolute cwd if it lives
|
|
996
|
+
* outside the workspace — the traverse loader already refuses to
|
|
997
|
+
* read off-tree files so the abs path is purely a breadcrumb for
|
|
998
|
+
* the SSE status line.
|
|
999
|
+
*/
|
|
1000
|
+
function relativeOrAbsolute(workspaceRoot, cwd) {
|
|
1001
|
+
const absRoot = resolve(workspaceRoot);
|
|
1002
|
+
const absCwd = resolve(cwd);
|
|
1003
|
+
if (absCwd === absRoot)
|
|
1004
|
+
return '.';
|
|
1005
|
+
const rel = absCwd.startsWith(absRoot + '/') ? absCwd.slice(absRoot.length + 1) : null;
|
|
1006
|
+
return rel ?? absCwd;
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Leak L28 helper — splice multiple ambient blocks onto a persona
|
|
1010
|
+
* system prompt, dropping empty entries cleanly. The join character
|
|
1011
|
+
* is `\n\n` so each block renders as a discrete paragraph the model
|
|
1012
|
+
* can attend к without bleeding into its neighbour.
|
|
1013
|
+
*
|
|
1014
|
+
* Empty blocks return the base prompt unchanged — no leading
|
|
1015
|
+
* separators, no trailing whitespace. Mirrors the original
|
|
1016
|
+
* `ambientContextBlock ? ... : ...` shape so the single-block path
|
|
1017
|
+
* before L28 stays byte-identical (prompt cache friendliness).
|
|
1018
|
+
*/
|
|
1019
|
+
export function composeSystemPrompt(blocks) {
|
|
1020
|
+
const nonEmpty = blocks.map((b) => b.trim()).filter((b) => b.length > 0);
|
|
1021
|
+
return nonEmpty.join('\n\n');
|
|
1022
|
+
}
|
|
374
1023
|
//# sourceMappingURL=native-pugi.js.map
|