@pugi/cli 0.1.0-beta.93 → 0.1.0-beta.95

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.
Files changed (35) hide show
  1. package/dist/commands/retro.js +210 -0
  2. package/dist/core/diagnostics/probes/sandbox.js +65 -33
  3. package/dist/core/engine/native-pugi.js +184 -10
  4. package/dist/core/engine/tool-bridge.js +35 -0
  5. package/dist/core/engine/verification-patterns.js +9 -9
  6. package/dist/core/mcp/orchestrator-config.js +192 -0
  7. package/dist/core/mcp/orchestrator-tools.js +147 -3
  8. package/dist/core/pugi-gitignore.js +52 -0
  9. package/dist/core/repl/engine-bridge.js +199 -0
  10. package/dist/core/repl/session.js +395 -6
  11. package/dist/core/repl/tool-route.js +382 -0
  12. package/dist/core/retro/git-collector.js +251 -0
  13. package/dist/core/retro/health-card.js +25 -0
  14. package/dist/core/retro/metrics.js +342 -0
  15. package/dist/core/retro/narrative.js +249 -0
  16. package/dist/core/retro/plane-collector.js +274 -0
  17. package/dist/core/retro/pr-issue-link.js +65 -0
  18. package/dist/core/retro/types.js +16 -0
  19. package/dist/core/sandboxing/adapter.js +29 -0
  20. package/dist/core/sandboxing/index.js +49 -0
  21. package/dist/core/sandboxing/none.js +19 -0
  22. package/dist/core/sandboxing/seatbelt.js +183 -0
  23. package/dist/core/session.js +27 -0
  24. package/dist/core/settings.js +22 -0
  25. package/dist/runtime/cli.js +167 -33
  26. package/dist/runtime/commands/mcp.js +64 -8
  27. package/dist/runtime/deprecation-warning.js +69 -0
  28. package/dist/runtime/headless.js +8 -3
  29. package/dist/runtime/stream-renderer.js +195 -0
  30. package/dist/runtime/version.js +1 -1
  31. package/dist/tui/agent-tree.js +11 -0
  32. package/dist/tui/ask-user-question-chips.js +1 -1
  33. package/dist/tui/multi-file-diff-approval.js +3 -3
  34. package/dist/tui/repl-render.js +42 -0
  35. package/package.json +2 -2
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Streaming event renderer (Trust Sprint item 5).
3
+ *
4
+ * Subscribes to the engine adapter's `streamEmitter` and writes
5
+ * human-readable progress lines to stderr while the dispatch is in
6
+ * flight. Solves Codex dogfood 2026-06-04 finding: `pugi
7
+ * code/fix/build/plan` were silent until the full dispatch
8
+ * completed, which produced an empty terminal for 20-30s and made
9
+ * customers think the CLI had hung.
10
+ *
11
+ * Design constraints:
12
+ *
13
+ * 1. STDERR ONLY. The CLI's machine-readable contract is "JSON on
14
+ * stdout, prose on stderr". Streaming on stdout would break the
15
+ * JSON envelope for `pugi code --json`.
16
+ *
17
+ * 2. No engine modifications. We attach a listener to the existing
18
+ * `streamEmitter` that the engine already emits to (used by the
19
+ * headless mode). The engine adapter file is owned by the other
20
+ * agent on PUGI-VERIFY-GATE and intentionally NOT touched here.
21
+ *
22
+ * 3. TTY-only by default. CI / pipes / JSON consumers get a clean
23
+ * stream. Explicit `--stream` overrides to force progress lines
24
+ * on; `--no-stream` overrides off.
25
+ *
26
+ * 4. Compact. One line per tool call (start) and one line per
27
+ * finish. Text deltas are aggregated to a single "thinking..."
28
+ * pulse so we don't paint the terminal with token-level noise.
29
+ */
30
+ /**
31
+ * Decide whether to enable the streaming renderer.
32
+ *
33
+ * - explicit=true → ON regardless of TTY.
34
+ * - explicit=false → OFF regardless of TTY.
35
+ * - explicit=null → ON when stderr is a TTY, OFF otherwise.
36
+ *
37
+ * Centralised so the CLI dispatcher and tests share one rule.
38
+ */
39
+ export function shouldStream(opts) {
40
+ if (opts.explicit === true)
41
+ return true;
42
+ if (opts.explicit === false)
43
+ return false;
44
+ return opts.isTty;
45
+ }
46
+ /**
47
+ * Attach a stderr-based event renderer to the engine emitter.
48
+ *
49
+ * Returns a handle whose `enabled` field is true when the renderer
50
+ * actually attached. When disabled (non-TTY or explicit-off) the
51
+ * returned handle is a no-op so callers don't need to branch.
52
+ */
53
+ export function attachStreamRenderer(emitter, opts) {
54
+ const enabled = shouldStream({ isTty: opts.isTty, explicit: opts.explicit });
55
+ if (!enabled) {
56
+ return { enabled: false, detach: () => { } };
57
+ }
58
+ const write = opts.write ?? ((line) => process.stderr.write(line));
59
+ // Aggregate text deltas so we don't paint a line per token. We
60
+ // print one "thinking..." pulse every ~500ms while text is
61
+ // streaming so the operator sees the loop is alive without a wall
62
+ // of partial sentences.
63
+ let pendingTextChars = 0;
64
+ let lastTextPulseAt = 0;
65
+ // Aggregate thinking deltas the same way. Separate budget so a
66
+ // chatty reasoning trace doesn't blow out the visible-text counter.
67
+ let pendingThinkingChars = 0;
68
+ let lastThinkingPulseAt = 0;
69
+ // Cache the running tool calls so the "end" line can report a
70
+ // duration without forcing the producer to round-trip the start
71
+ // timestamp through the discriminated union.
72
+ const toolStartedAt = new Map();
73
+ const handler = (event) => {
74
+ switch (event.type) {
75
+ case 'status': {
76
+ // Only surface the high-signal status frames. Turn-boundary
77
+ // frames are noise for a live operator; the tool-level lines
78
+ // already tell them what's happening. Dispatch start /
79
+ // budget / abort frames are worth one line each.
80
+ const msg = event.message;
81
+ if (msg.startsWith('dispatch_start') ||
82
+ msg.startsWith('budget') ||
83
+ msg.startsWith('aborted') ||
84
+ msg.startsWith('cancelled')) {
85
+ write(`pugi: ${msg}\n`);
86
+ }
87
+ return;
88
+ }
89
+ case 'tool.start': {
90
+ toolStartedAt.set(event.callId, Date.now());
91
+ const argsBrief = briefArgs(event.arguments);
92
+ write(`pugi: tool ${event.name}(${argsBrief}) running\n`);
93
+ return;
94
+ }
95
+ case 'tool.end': {
96
+ const startedAt = toolStartedAt.get(event.callId);
97
+ toolStartedAt.delete(event.callId);
98
+ const ms = startedAt ? Date.now() - startedAt : null;
99
+ const glyph = event.ok ? 'ok' : 'fail';
100
+ const summary = (event.summary ?? '').slice(0, 80).replace(/\s+/g, ' ').trim();
101
+ const tail = ms !== null ? ` (${ms}ms)` : '';
102
+ write(`pugi: tool ${glyph}${tail}${summary ? `: ${summary}` : ''}\n`);
103
+ return;
104
+ }
105
+ case 'tool.delta': {
106
+ // Most bash tools emit a single delta with the entire stdout
107
+ // tail. We surface the first non-empty chunk only, capped to
108
+ // a single line, so the operator gets a peek without
109
+ // flooding.
110
+ const chunk = (event.chunk ?? '').replace(/\s+/g, ' ').trim();
111
+ if (chunk.length === 0)
112
+ return;
113
+ const oneLine = chunk.slice(0, 80);
114
+ write(`pugi: stdout: ${oneLine}\n`);
115
+ return;
116
+ }
117
+ case 'text.delta': {
118
+ pendingTextChars += (event.chunk ?? '').length;
119
+ const now = Date.now();
120
+ if (now - lastTextPulseAt >= 500 && pendingTextChars >= 40) {
121
+ lastTextPulseAt = now;
122
+ pendingTextChars = 0;
123
+ write(`pugi: writing reply...\n`);
124
+ }
125
+ return;
126
+ }
127
+ case 'thinking.start':
128
+ write(`pugi: thinking...\n`);
129
+ lastThinkingPulseAt = Date.now();
130
+ return;
131
+ case 'thinking.delta': {
132
+ pendingThinkingChars += (event.chunk ?? '').length;
133
+ const now = Date.now();
134
+ if (now - lastThinkingPulseAt >= 1500 && pendingThinkingChars >= 80) {
135
+ lastThinkingPulseAt = now;
136
+ pendingThinkingChars = 0;
137
+ write(`pugi: still thinking...\n`);
138
+ }
139
+ return;
140
+ }
141
+ case 'thinking.end':
142
+ // Quiet — text.delta will pick up the visible answer next.
143
+ return;
144
+ default:
145
+ return;
146
+ }
147
+ };
148
+ emitter.on('event', handler);
149
+ return {
150
+ enabled: true,
151
+ detach: () => {
152
+ emitter.off('event', handler);
153
+ },
154
+ };
155
+ }
156
+ /**
157
+ * Render a tool's JSON-serialised arguments to a single-line brief.
158
+ * Keep this very cheap — runs on every tool.start event.
159
+ */
160
+ function briefArgs(serialised) {
161
+ if (!serialised || serialised === '{}')
162
+ return '';
163
+ try {
164
+ const parsed = JSON.parse(serialised);
165
+ // Prioritise the most signal-bearing field name for known tools.
166
+ const path = pickStringField(parsed, ['path', 'file', 'file_path', 'target', 'cwd']);
167
+ if (path)
168
+ return path.slice(0, 60);
169
+ const command = pickStringField(parsed, ['command', 'cmd', 'query', 'pattern']);
170
+ if (command)
171
+ return command.slice(0, 60);
172
+ // Fallback — surface the first scalar field so we surface
173
+ // something rather than an empty string.
174
+ for (const [, value] of Object.entries(parsed)) {
175
+ if (typeof value === 'string')
176
+ return value.slice(0, 60);
177
+ if (typeof value === 'number' || typeof value === 'boolean') {
178
+ return String(value);
179
+ }
180
+ }
181
+ return '';
182
+ }
183
+ catch {
184
+ return serialised.slice(0, 60);
185
+ }
186
+ }
187
+ function pickStringField(obj, keys) {
188
+ for (const key of keys) {
189
+ const value = obj[key];
190
+ if (typeof value === 'string' && value.length > 0)
191
+ return value;
192
+ }
193
+ return null;
194
+ }
195
+ //# sourceMappingURL=stream-renderer.js.map
@@ -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.93');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.95');
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.
@@ -33,6 +33,12 @@ function statusGlyph(status) {
33
33
  // anti-pattern (memory feedback_no_fake_dispatch_promises).
34
34
  case 'replied':
35
35
  return '→';
36
+ // `unverified` (PUGI-538c-FU-OUTCOME) — files landed on disk but
37
+ // the verify-gate did not certify the run (no test command in
38
+ // the workspace). Tilde glyph reads as "approximately done"
39
+ // without claiming the verified-shipped guarantee.
40
+ case 'unverified':
41
+ return '~';
36
42
  case 'blocked':
37
43
  return '✗';
38
44
  case 'failed':
@@ -50,6 +56,11 @@ function statusColor(status) {
50
56
  // Dim/grey-ish — succeeded technically but no work was shipped.
51
57
  case 'replied':
52
58
  return 'gray';
59
+ // Yellow — work landed but the verify-gate could not certify it.
60
+ // Same hue as `blocked` because both are "advisory, not green";
61
+ // the glyph distinguishes them (`~` vs `✗`).
62
+ case 'unverified':
63
+ return 'yellow';
53
64
  case 'blocked':
54
65
  return 'yellow';
55
66
  case 'failed':
@@ -76,7 +76,7 @@ export const ASK_CHIPS_PREVIEW_CHAR_CAP = 5000;
76
76
  * `preview` field. The renderer uses this gate to switch к side-by-side
77
77
  * layout (vertical option list + preview pane). When false, the legacy
78
78
  * horizontal chip layout is preserved (zero regression for callers
79
- * shipping previewless payloads — PR #851 contract intact).
79
+ * shipping previewless payloads — contract intact).
80
80
  */
81
81
  export function hasAnyPreview(questions) {
82
82
  return questions.some((q) => q.options.some((opt) => typeof opt.preview === 'string' && opt.preview.length > 0));
@@ -192,7 +192,7 @@ function buildResult(entries, verdicts, cancelled) {
192
192
  };
193
193
  }
194
194
  export function MultiFileDiffApproval(props) {
195
- // FIX (PR #876 triple-review): the previous `useMemo(() => props.entries,
195
+ // FIX ( triple-review): the previous `useMemo(() => props.entries,
196
196
  // [props.entries])` was a no-op — it returned the same reference it
197
197
  // depended on, so the memo never produced a stable identity gain. We
198
198
  // reference `props.entries` directly now; downstream useEffect /
@@ -202,7 +202,7 @@ export function MultiFileDiffApproval(props) {
202
202
  const [cursor, setCursor] = useState(0);
203
203
  const [verdicts, setVerdicts] = useState(() => entries.map(() => 'pending'));
204
204
  const [scrollOffset, setScrollOffset] = useState(0);
205
- // FIX (PR #876 triple-review, P1): the lazy useState initialiser
205
+ // FIX ( triple-review, P1): the lazy useState initialiser
206
206
  // captures `entries.length` ONCE at mount. If the caller swaps in
207
207
  // a different entries array (length mismatch), the verdicts vector
208
208
  // would drift — `setVerdictAt` could index out of range or
@@ -222,7 +222,7 @@ export function MultiFileDiffApproval(props) {
222
222
  return Math.min(c, entries.length - 1);
223
223
  });
224
224
  }, [entries.length, entries]);
225
- // FIX (PR #876 triple-review, P1): `commit()` is called from inside
225
+ // FIX ( triple-review, P1): `commit()` is called from inside
226
226
  // the useInput closure, which captures the `verdicts` value at the
227
227
  // render time the closure was created. With rapid keypresses (e.g.
228
228
  // `a` then Enter on the same React tick), React has scheduled the
@@ -27,9 +27,11 @@ import { ThemeProvider } from '../core/theme/context.js';
27
27
  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
+ import { createEngineBridge } from '../core/repl/engine-bridge.js';
30
31
  import { SqliteSessionStore } from '../core/repl/store/index.js';
31
32
  import { slugForCwd } from '../core/repl/history.js';
32
33
  import { loadSettings } from '../core/settings.js';
34
+ import { buildRuntimeConfig } from '@pugi/sdk';
33
35
  import { WorkingSet, buildRepoSkeleton, loadPugiIgnore, PugiWatcher, } from '../core/context/index.js';
34
36
  /**
35
37
  * Mount the REPL and resolve when the user exits via Ctrl+C × 2 or
@@ -136,12 +138,52 @@ export async function renderRepl(options) {
136
138
  cwd: process.cwd(),
137
139
  env: process.env,
138
140
  });
141
+ // PUGI-538c () -- wire the production engine bridge so
142
+ // `<pugi-tool-route>` envelopes emitted by Pugi's coordinator
143
+ // actually drive a local `NativePugiEngineAdapter` run instead of
144
+ // surfacing "Engine bridge not configured" on the system line. The
145
+ // factory closure resolves `cwd` per invocation so an operator who
146
+ // `cd`-ed mid-session writes files into the new directory (matches
147
+ // `pugi code` direct-path behaviour).
148
+ //
149
+ // The bridge runtime config reuses the REPL transport credentials so
150
+ // a single token authenticates both the coordinator brief (via
151
+ // admin-api /api/pugi/sessions) AND the bridged engine call (via
152
+ // /api/pugi/engine). No new credential surface, no double login.
153
+ //
154
+ // Fail-safe construction: `buildRuntimeConfig` validates apiUrl via
155
+ // `z.string().url()` and throws on a malformed value. We must not
156
+ // crash REPL launch on that path -- every other bootstrap step in
157
+ // this file (auto-init, openLocalStore, bootstrapContext, watcher)
158
+ // is fail-safe. On construction failure we surface a one-line stderr
159
+ // diagnostic under PUGI_DEBUG=1 and pass `engineBridge: undefined`
160
+ // so the REPL launches; the operator sees the legacy "Engine bridge
161
+ // not configured" system line on the first routed brief, which is
162
+ // the same UX as a pre-PUGI-538c CLI build.
163
+ let engineBridge;
164
+ try {
165
+ engineBridge = createEngineBridge({
166
+ config: buildRuntimeConfig({
167
+ apiUrl: options.apiUrl,
168
+ apiKey: options.apiKey,
169
+ }),
170
+ cwd: () => process.cwd(),
171
+ });
172
+ }
173
+ catch (err) {
174
+ if (process.env.PUGI_DEBUG === '1') {
175
+ const msg = err instanceof Error ? err.message : String(err);
176
+ process.stderr.write(`[pugi-debug] engine bridge bootstrap failed: ${msg}\n`);
177
+ }
178
+ engineBridge = undefined;
179
+ }
139
180
  const session = new ReplSession({
140
181
  apiUrl: options.apiUrl,
141
182
  apiKey: options.apiKey,
142
183
  workspaceLabel: options.workspaceLabel,
143
184
  cliVersion: options.cliVersion,
144
185
  transport,
186
+ ...(engineBridge !== undefined ? { engineBridge } : {}),
145
187
  workspace,
146
188
  cyberZoo,
147
189
  store,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-beta.93",
3
+ "version": "0.1.0-beta.95",
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.93"
66
+ "@pugi/sdk": "0.1.0-beta.95"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@types/node": "^22.0.0",