@pugi/cli 0.1.0-beta.94 → 0.1.0-beta.96

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.
@@ -1,6 +1,8 @@
1
1
  import { AnvilEngineLoopClient } from '../engine/anvil-client.js';
2
2
  import { NativePugiEngineAdapter } from '../engine/native-pugi.js';
3
+ import { loadMcpRegistry as defaultLoadMcpRegistry } from '../mcp/registry.js';
3
4
  import { openSession } from '../session.js';
5
+ import { loadHookRegistryOrExit as defaultLoadHookRegistry } from '../../runtime/load-hooks-or-exit.js';
4
6
  /**
5
7
  * Translate `pugi-tool-route command="..."` into the SDK's
6
8
  * `EngineTaskKind`. `code` and `fix` pass through verbatim; `build`
@@ -83,13 +85,115 @@ function translateStreamEvent(event, names) {
83
85
  */
84
86
  export function createEngineBridge(deps) {
85
87
  const buildClient = deps.clientFactory ?? ((config) => new AnvilEngineLoopClient(config));
88
+ const loadMcp = deps.loadMcpRegistry ?? defaultLoadMcpRegistry;
89
+ const loadHooks = deps.loadHookRegistry ??
90
+ ((opts) => defaultLoadHookRegistry(opts));
91
+ // PR A — per-bridge-instance caches. Spawning MCP child processes
92
+ // (multi-second startup for some servers) and parsing hook configuration
93
+ // are too expensive to repeat per turn AND have no per-turn state. We
94
+ // load once on the first call, reuse across turns. The MCP registry
95
+ // shutdown happens at process exit; a future PR will wire it to the
96
+ // REPL's exit hook so trusted child processes are reaped before the
97
+ // parent terminates.
98
+ //
99
+ // /triple-review P1 round 2: cache stored as in-flight Promise<T>
100
+ // instead of value + boolean flag. Two concurrent first-turn calls
101
+ // would otherwise both observe `mcpLoaded === false`, both spawn the
102
+ // expensive loader, and the second result would overwrite the first —
103
+ // leaking the first registry's child processes. Storing the promise
104
+ // means the second caller awaits the same in-flight load, gets the
105
+ // same instance, and there is no double-spawn. On loader failure the
106
+ // promise resolves to `undefined` (mirrors the value-cache fallback);
107
+ // a future retry on transient ENOENT would mint a NEW promise after
108
+ // explicit invalidation (out of scope for this PR — tracked as P2 fu).
109
+ let mcpRegistryPromise = null;
110
+ let hooksPromise = null;
111
+ const securityHooksParseFailed = { value: false };
112
+ async function resolveMcpRegistry(root) {
113
+ if (mcpRegistryPromise === null) {
114
+ mcpRegistryPromise = (async () => {
115
+ try {
116
+ return await loadMcp(root);
117
+ }
118
+ catch (error) {
119
+ // PR A — mirror cli.ts:6394 failure mode. A bad `.pugi/mcp.json`
120
+ // must not crash the REPL; the operator sees a stderr warning
121
+ // and continues without MCP tools. They can fix the file and
122
+ // the next REPL launch picks up the new registry.
123
+ const msg = error.message;
124
+ process.stderr.write(`pugi REPL engine bridge: MCP registry load failed — ${msg}. ` +
125
+ `Continuing without MCP tools. Fix .pugi/mcp.json to enable.\n`);
126
+ return undefined;
127
+ }
128
+ })();
129
+ }
130
+ return mcpRegistryPromise;
131
+ }
132
+ async function resolveHooks(root, session) {
133
+ if (hooksPromise === null) {
134
+ hooksPromise = (async () => {
135
+ try {
136
+ const outcome = await loadHooks({
137
+ workspaceRoot: root,
138
+ session,
139
+ label: 'repl',
140
+ });
141
+ if (outcome.kind === 'parse-failure-refused') {
142
+ // /triple-review P1 round 2: hooks fail-open is a security
143
+ // regression vs cli.ts:6440 (hard-exit on parse failure).
144
+ // SECURITY: a workspace operator who configured a `PreToolUse
145
+ // onFailure: 'block'` rule that refuses bash containing `rm`
146
+ // loses that protection the moment the JSON file gets a
147
+ // typo. Stderr-only warning scrolls off the TUI.
148
+ //
149
+ // Mitigation: do NOT load hooks for this REPL session (so
150
+ // the operator's mental model — "I have hooks configured" —
151
+ // does not silently break), AND mark the bridge as "hooks
152
+ // parse failed" so every turn surfaces the warning until
153
+ // the file is fixed. The PUGI_HOOKS_BYPASS=1 env var
154
+ // (loadHookRegistryOrExit:97-107) is still the explicit
155
+ // escape hatch when the operator is mid-edit and acknowledges
156
+ // the risk.
157
+ securityHooksParseFailed.value = true;
158
+ process.stderr.write('pugi REPL engine bridge: SECURITY — hooks parse failed. ' +
159
+ 'PreToolUse/PostToolUse rules are NOT active for this REPL ' +
160
+ 'session. Fix .pugi/hooks.json and restart REPL, OR set ' +
161
+ 'PUGI_HOOKS_BYPASS=1 to acknowledge and continue.\n');
162
+ return undefined;
163
+ }
164
+ return outcome.hooks;
165
+ }
166
+ catch (error) {
167
+ const msg = error.message;
168
+ process.stderr.write(`pugi REPL engine bridge: hooks loader threw — ${msg}. ` +
169
+ `Continuing without hooks.\n`);
170
+ return undefined;
171
+ }
172
+ })();
173
+ }
174
+ return hooksPromise;
175
+ }
86
176
  return async (input) => {
87
177
  const root = deps.cwd();
88
178
  const session = openSession(root);
89
179
  const client = buildClient(deps.config);
180
+ const [cachedMcpRegistry, cachedHooks] = await Promise.all([
181
+ resolveMcpRegistry(root),
182
+ resolveHooks(root, session),
183
+ ]);
184
+ if (securityHooksParseFailed.value) {
185
+ // /triple-review P1 round 2: re-emit the security warning on every
186
+ // bridged turn so the operator does not miss it after scrollback.
187
+ // Cheap one-liner, hard to miss in TUI status pane.
188
+ process.stderr.write('pugi REPL engine bridge: SECURITY reminder — hooks still NOT ' +
189
+ 'loaded for this session (.pugi/hooks.json parse failure).\n');
190
+ }
90
191
  const adapter = new NativePugiEngineAdapter({
91
192
  client,
92
193
  session,
194
+ ...(cachedMcpRegistry ? { mcpRegistry: cachedMcpRegistry } : {}),
195
+ ...(cachedHooks ? { hooks: cachedHooks } : {}),
196
+ ...(deps.intensityProfile ? { intensityProfile: deps.intensityProfile } : {}),
93
197
  });
94
198
  // Per-call name map for matching `tool.end` -> `tool.start`. New
95
199
  // every invocation so concurrent bridges never cross-pollute.
@@ -40,7 +40,8 @@ import { applyRewindMask } from '../checkpoint/rewinder.js';
40
40
  import { evaluateAutoCompact } from '../compact/auto-trigger.js';
41
41
  import { estimateTokensInMany } from '../compact/token-counter.js';
42
42
  import { extractAskTags, extractPlanReviewTags, signatureForAsk, } from './ask.js';
43
- import { extractToolRouteTags, } from './tool-route.js';
43
+ import { extractToolRouteTags, signatureForToolRoute, } from './tool-route.js';
44
+ import { personaSlugFor } from '../engine/prompts.js';
44
45
  import { existsSync, readdirSync, statSync } from 'node:fs';
45
46
  import { resolve as resolvePath } from 'node:path';
46
47
  import { CancellationToken } from './cancellation.js';
@@ -2788,13 +2789,70 @@ export class ReplSession {
2788
2789
  if (!this.streamHandle && !this.closed) {
2789
2790
  this.openStream();
2790
2791
  }
2792
+ // PR A (PUGI-538-FU) — REPL becomes a first-class engine
2793
+ // path. When the CLI REPL has an engine bridge wired the brief is
2794
+ // dispatched DIRECTLY to the inproc engine adapter via
2795
+ // `runEngineBridge` instead of POSTing к admin-api `/sessions/:id/brief`.
2796
+ //
2797
+ // Why this matters:
2798
+ // - The server-side bypass () had to fabricate a synthetic
2799
+ // `<pugi-tool-route>` envelope SSE event so the CLI parser would
2800
+ // fire `runEngineBridge`. That worked but cost one full HTTP
2801
+ // round-trip + SSE latency per turn — and required `cliVersion`
2802
+ // to thread correctly через the session-create + header pipe
2803
+ // (which broke in production: CEO smoke 2026-06-05 showed
2804
+ // `envelope=delegate` instead of `tool-route` because the version
2805
+ // header was missing on his customer-installed beta.95 client,
2806
+ // so the bypass branch never matched и the coordinator chat
2807
+ // ceremony ran anyway).
2808
+ // - Going direct removes that whole class of bug: the CLI knows
2809
+ // it is the CLI, it has the engine bridge in hand, it skips the
2810
+ // server entirely и calls the adapter inproc. Matches Claude
2811
+ // Code / Codex / Aider tools-first loop architecture.
2812
+ //
2813
+ // Personas survive: `personaSlugFor('code')` returns 'dev' (Hiroshi),
2814
+ // the engine adapter renders the persona system prompt + memory
2815
+ // recall just like `pugi code` direct CLI. The synthetic agent-tree
2816
+ // node inside `runEngineBridge` carries `personaName` so the TUI
2817
+ // shows "Hiroshi" the same way it did before.
2818
+ //
2819
+ // Server-side bypass от remains в place для non-CLI surfaces
2820
+ // (cabinet BFF, telegram bot) — they have no engine adapter wired,
2821
+ // so the server still needs to fabricate the dispatch on their behalf.
2822
+ //
2823
+ // Env opt-out: `PUGI_REPL_DIRECT_ENGINE=0` falls back к the HTTP
2824
+ // path for regression debugging. cliVersion presence is the CLI
2825
+ // signal — REPL embedded inside cabinet BFF mounts without that
2826
+ // field и continues к hit the server route.
2827
+ const useDirectEngine = this.options.engineBridge !== undefined &&
2828
+ typeof this.options.cliVersion === 'string' &&
2829
+ this.options.cliVersion.length > 0 &&
2830
+ (this.options.env ?? process.env).PUGI_REPL_DIRECT_ENGINE !== '0';
2791
2831
  try {
2792
- await this.options.transport.postBrief({
2793
- apiUrl: this.options.apiUrl,
2794
- apiKey: this.options.apiKey,
2795
- sessionId,
2796
- brief,
2797
- });
2832
+ if (useDirectEngine) {
2833
+ const persona = personaSlugFor('code');
2834
+ const tag = {
2835
+ command: 'code',
2836
+ brief,
2837
+ persona,
2838
+ // Direct-dispatch tags do not flow through the parser, so the
2839
+ // start/end byte offsets are inapplicable. Keep `signatureForToolRoute`
2840
+ // so the seen-tag rolling set still de-dupes a brief that the
2841
+ // operator submits twice in a row by accident.
2842
+ signature: signatureForToolRoute('code', persona, brief),
2843
+ start: 0,
2844
+ end: 0,
2845
+ };
2846
+ await this.runEngineBridge(tag);
2847
+ }
2848
+ else {
2849
+ await this.options.transport.postBrief({
2850
+ apiUrl: this.options.apiUrl,
2851
+ apiKey: this.options.apiKey,
2852
+ sessionId,
2853
+ brief,
2854
+ });
2855
+ }
2798
2856
  }
2799
2857
  catch (error) {
2800
2858
  this.appendSystemLine(`Brief dispatch refused: ${this.errorMessage(error)}`);
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
44
44
  * during import). When bumping the CLI version BOTH literals must be
45
45
  * updated; the release smoke-test (`pack:smoke`) verifies they agree.
46
46
  */
47
- export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.94');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.96');
48
48
  /**
49
49
  * Outbound: the CLI's installed semver. Read at request time by
50
50
  * `version-interceptor.ts` and injected on every `fetch` call.
@@ -28,6 +28,7 @@ import { resolveTheme } from '../core/theme/state.js';
28
28
  import { ReplSession, } from '../core/repl/session.js';
29
29
  import { resolveWorkspaceContext } from '../core/repl/workspace-context.js';
30
30
  import { createEngineBridge } from '../core/repl/engine-bridge.js';
31
+ import { profileFor, resolveIntensity } from '../core/engine/intensity.js';
31
32
  import { SqliteSessionStore } from '../core/repl/store/index.js';
32
33
  import { slugForCwd } from '../core/repl/history.js';
33
34
  import { loadSettings } from '../core/settings.js';
@@ -162,12 +163,21 @@ export async function renderRepl(options) {
162
163
  // the same UX as a pre-PUGI-538c CLI build.
163
164
  let engineBridge;
164
165
  try {
166
+ // PR A (2026-06-05): resolve intensity profile once at bridge
167
+ // construction time. The bridge stays pure and re-uses the resolved
168
+ // profile across every routed brief. `resolveIntensity` reads env
169
+ // (`PUGI_INTENSITY`) и settings.json; the REPL launch lifecycle is
170
+ // the natural binding point. A future `/intensity` REPL command
171
+ // will rebuild the bridge to swap the profile mid-session.
172
+ const intensityLevel = resolveIntensity({});
173
+ const intensityProfile = profileFor(intensityLevel);
165
174
  engineBridge = createEngineBridge({
166
175
  config: buildRuntimeConfig({
167
176
  apiUrl: options.apiUrl,
168
177
  apiKey: options.apiKey,
169
178
  }),
170
179
  cwd: () => process.cwd(),
180
+ intensityProfile,
171
181
  });
172
182
  }
173
183
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-beta.94",
3
+ "version": "0.1.0-beta.96",
4
4
  "description": "Pugi CLI - terminal-native software execution system",
5
5
  "homepage": "https://pugi.io",
6
6
  "repository": {
@@ -63,7 +63,7 @@
63
63
  "which": "^6.0.0",
64
64
  "zod": "^3.23.0",
65
65
  "@pugi/personas": "0.1.2",
66
- "@pugi/sdk": "0.1.0-beta.94"
66
+ "@pugi/sdk": "0.1.0-beta.96"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@types/node": "^22.0.0",