@pugi/cli 0.1.0-beta.31 → 0.1.0-beta.36
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/dist/commands/smoke.js +133 -0
- package/dist/core/auth/ensure-authenticated.js +129 -0
- package/dist/core/bash-classifier.js +108 -1
- 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/diagnostics/probes/status-snapshot.js +50 -4
- package/dist/core/mcp/orchestrator-tools.js +595 -0
- package/dist/core/onboarding/ensure-initialized.js +133 -0
- package/dist/core/repl/session.js +370 -9
- package/dist/core/repl/slash-commands.js +68 -5
- 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/runtime/cli.js +453 -11
- package/dist/runtime/commands/cancel.js +231 -0
- package/dist/runtime/commands/codegraph-status.js +227 -0
- package/dist/runtime/commands/mcp.js +66 -11
- package/dist/runtime/commands/permissions.js +23 -0
- package/dist/runtime/commands/redo-blob-store.js +92 -0
- package/dist/runtime/commands/redo.js +361 -0
- package/dist/runtime/commands/status.js +11 -3
- package/dist/runtime/commands/undo.js +32 -0
- package/dist/runtime/headless-repl.js +195 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tui/permissions-picker.js +78 -0
- package/dist/tui/render.js +35 -0
- package/dist/tui/status-bar.js +1 -1
- package/dist/tui/tool-stream-pane.js +45 -3
- package/package.json +7 -4
- 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
- package/dist/core/init/scaffold.js +0 -195
- package/dist/core/memory/dual-write.spec.js +0 -297
- package/dist/core/memory-sync/queue.spec.js +0 -105
- package/dist/core/repl/codebase-survey.js +0 -308
- package/dist/core/repl/init-interview.js +0 -457
- package/dist/core/repl/onboarding-state.js +0 -297
- package/dist/runtime/commands/memory.spec.js +0 -174
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless REPL — `pugi --headless` (BIG TRACK 10 Phase 1, 2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Long-form rationale lives in the spec PR; the short version: every
|
|
5
|
+
* CLI publish gets manually smoke-tested today (CEO directive
|
|
6
|
+
* `feedback_live_console_test_every_publish`) and that toil must be
|
|
7
|
+
* automated. `pugi --headless` is the I/O surface that lets a scripted
|
|
8
|
+
* harness drive Pugi multi-turn — one stdin line in, one or more JSON
|
|
9
|
+
* envelopes out on stdout, exit when stdin closes. This is the
|
|
10
|
+
* machine-facing peer to the human-facing Ink REPL.
|
|
11
|
+
*
|
|
12
|
+
* Envelope shape (Phase 1):
|
|
13
|
+
*
|
|
14
|
+
* { "kind": "user-turn" | "persona-turn" | "tool-call" | "error"
|
|
15
|
+
* | "session-start" | "session-end" | "system",
|
|
16
|
+
* "body": "<string>",
|
|
17
|
+
* "ts": <epoch ms> }
|
|
18
|
+
*
|
|
19
|
+
* One JSON object per line. Stdout stays pure envelopes; stderr gets
|
|
20
|
+
* any human-readable trace. Discipline mirrors the existing
|
|
21
|
+
* `runHeadlessPrint` in `headless.ts`.
|
|
22
|
+
*
|
|
23
|
+
* Phase 1 engine wiring — the headless REPL emits the user-turn
|
|
24
|
+
* envelope verbatim, then asks the engine adapter for ONE turn, then
|
|
25
|
+
* emits the persona-turn / tool-call envelopes. Multi-turn state is
|
|
26
|
+
* accumulated in a single in-process session so consecutive lines see
|
|
27
|
+
* the same persona history. When a credential is absent (a common CI
|
|
28
|
+
* state) we fall through to a deterministic stub responder so the
|
|
29
|
+
* smoke harness can still exercise the I/O contract WITHOUT requiring
|
|
30
|
+
* an api.pugi.io reachability dependency.
|
|
31
|
+
*/
|
|
32
|
+
import { createInterface } from 'node:readline';
|
|
33
|
+
import { resolve as resolvePath } from 'node:path';
|
|
34
|
+
import { resolveActiveCredential } from '../core/credentials.js';
|
|
35
|
+
/**
|
|
36
|
+
* Run the headless REPL loop. Returns the desired process exit code:
|
|
37
|
+
*
|
|
38
|
+
* 0 Stdin closed cleanly after at least one successful turn.
|
|
39
|
+
* 0 Stdin closed with no input (empty pipe — harmless, exit clean).
|
|
40
|
+
* 1 Fatal error from the turn handler.
|
|
41
|
+
*
|
|
42
|
+
* The caller (cli.ts) sets `process.exitCode`; we never call
|
|
43
|
+
* `process.exit` so an embedded driver can reuse the function.
|
|
44
|
+
*/
|
|
45
|
+
export async function runHeadlessRepl(opts) {
|
|
46
|
+
const cwd = resolvePath(opts.cwd);
|
|
47
|
+
const stdoutWrite = opts.stdoutWrite ?? ((chunk) => process.stdout.write(chunk));
|
|
48
|
+
const stderrWrite = opts.stderrWrite ?? ((chunk) => process.stderr.write(chunk));
|
|
49
|
+
const now = opts.now ?? Date.now;
|
|
50
|
+
const turnHandler = opts.turnHandler ?? buildDefaultTurnHandler();
|
|
51
|
+
const stdin = opts.stdin ?? process.stdin;
|
|
52
|
+
emit(stdoutWrite, {
|
|
53
|
+
kind: 'session-start',
|
|
54
|
+
body: JSON.stringify({ cwd, cliVersion: 'phase1' }),
|
|
55
|
+
ts: now(),
|
|
56
|
+
});
|
|
57
|
+
const rl = createInterface({
|
|
58
|
+
input: stdin,
|
|
59
|
+
crlfDelay: Infinity,
|
|
60
|
+
terminal: false,
|
|
61
|
+
});
|
|
62
|
+
let turnIndex = 0;
|
|
63
|
+
let fatal = false;
|
|
64
|
+
for await (const rawLine of rl) {
|
|
65
|
+
const line = rawLine.replace(/\r$/, '');
|
|
66
|
+
if (line.length === 0)
|
|
67
|
+
continue;
|
|
68
|
+
emit(stdoutWrite, {
|
|
69
|
+
kind: 'user-turn',
|
|
70
|
+
body: line,
|
|
71
|
+
ts: now(),
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
const out = await turnHandler({ line, turnIndex, cwd });
|
|
75
|
+
for (const env of out) {
|
|
76
|
+
emit(stdoutWrite, { kind: env.kind, body: env.body, ts: now() });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
81
|
+
emit(stdoutWrite, {
|
|
82
|
+
kind: 'error',
|
|
83
|
+
body: JSON.stringify({ message }),
|
|
84
|
+
ts: now(),
|
|
85
|
+
});
|
|
86
|
+
stderrWrite(`pugi --headless: turn handler threw: ${message}\n`);
|
|
87
|
+
fatal = true;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
turnIndex += 1;
|
|
91
|
+
}
|
|
92
|
+
emit(stdoutWrite, {
|
|
93
|
+
kind: 'session-end',
|
|
94
|
+
body: JSON.stringify({ turns: turnIndex }),
|
|
95
|
+
ts: now(),
|
|
96
|
+
});
|
|
97
|
+
return fatal ? 1 : 0;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Emit a single envelope to stdout. Always terminates with `\n` so a
|
|
101
|
+
* line-buffered reader (the smoke runner, jq, etc.) sees each
|
|
102
|
+
* envelope atomically.
|
|
103
|
+
*/
|
|
104
|
+
function emit(write, envelope) {
|
|
105
|
+
write(`${JSON.stringify(envelope)}\n`);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Build the default turn handler. When a Pugi credential is resolvable
|
|
109
|
+
* we'll plumb to the engine adapter in Phase 2; for Phase 1 the
|
|
110
|
+
* default responder is a deterministic stub. Smoke scenarios that
|
|
111
|
+
* exercise the engine path use a Phase-2-only flag (`PUGI_HEADLESS_LIVE=1`)
|
|
112
|
+
* to opt into the real engine; the default keeps CI offline-safe.
|
|
113
|
+
*/
|
|
114
|
+
function buildDefaultTurnHandler() {
|
|
115
|
+
const credential = resolveActiveCredential();
|
|
116
|
+
if (credential && process.env.PUGI_HEADLESS_LIVE === '1') {
|
|
117
|
+
// Phase 2: route through `NativePugiEngineAdapter` here. Left as a
|
|
118
|
+
// stub so the Phase 1 PR stays narrowly scoped to the I/O surface.
|
|
119
|
+
// The smoke corpus does NOT exercise this branch in Phase 1.
|
|
120
|
+
return stubResponder('live engine path not yet wired (Phase 2)');
|
|
121
|
+
}
|
|
122
|
+
return stubResponder('pugi headless stub: no credential or PUGI_HEADLESS_LIVE!=1; echoing input');
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Tiny deterministic responder used by Phase 1 smoke tests. Echoes the
|
|
126
|
+
* input back as a `persona-turn` envelope with a stable prefix so
|
|
127
|
+
* scenarios can author assertions against it. The contract is:
|
|
128
|
+
*
|
|
129
|
+
* - Input "ты кто?" → persona-turn "Pugi: ты кто? (stub)"
|
|
130
|
+
* - Input "create FILE with content X"
|
|
131
|
+
* → tool-call Write file=FILE +
|
|
132
|
+
* persona-turn "Pugi: wrote FILE"
|
|
133
|
+
* - Any other input → persona-turn "Pugi: ...(stub)"
|
|
134
|
+
*
|
|
135
|
+
* Real engine routing is Phase 2. The stub exists to validate the
|
|
136
|
+
* envelope contract end-to-end in CI without depending on api.pugi.io.
|
|
137
|
+
*/
|
|
138
|
+
function stubResponder(banner) {
|
|
139
|
+
return async (input) => {
|
|
140
|
+
const text = input.line.trim();
|
|
141
|
+
const envelopes = [];
|
|
142
|
+
const createMatch = /^create\s+(\S+)\s+with\s+content\s+['"]([^'"]+)['"]\s*$/i.exec(text);
|
|
143
|
+
if (createMatch) {
|
|
144
|
+
const file = createMatch[1] ?? 'unknown';
|
|
145
|
+
const body = createMatch[2] ?? '';
|
|
146
|
+
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
147
|
+
const { dirname } = await import('node:path');
|
|
148
|
+
const absolute = resolvePath(input.cwd, file);
|
|
149
|
+
try {
|
|
150
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
151
|
+
writeFileSync(absolute, body, 'utf8');
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
envelopes.push({
|
|
155
|
+
kind: 'error',
|
|
156
|
+
body: JSON.stringify({
|
|
157
|
+
message: `write failed: ${error.message}`,
|
|
158
|
+
}),
|
|
159
|
+
});
|
|
160
|
+
return envelopes;
|
|
161
|
+
}
|
|
162
|
+
envelopes.push({
|
|
163
|
+
kind: 'tool-call',
|
|
164
|
+
body: JSON.stringify({
|
|
165
|
+
tool: 'Write',
|
|
166
|
+
args: { file, content: body },
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
envelopes.push({
|
|
170
|
+
kind: 'persona-turn',
|
|
171
|
+
body: `Pugi: wrote ${file}`,
|
|
172
|
+
});
|
|
173
|
+
return envelopes;
|
|
174
|
+
}
|
|
175
|
+
if (/^ты\s+кто/i.test(text) || /^who\s+are\s+you/i.test(text)) {
|
|
176
|
+
envelopes.push({
|
|
177
|
+
kind: 'persona-turn',
|
|
178
|
+
body: 'Pugi: я Pugi, твой co-pilot. (Пуджи, stub)',
|
|
179
|
+
});
|
|
180
|
+
return envelopes;
|
|
181
|
+
}
|
|
182
|
+
envelopes.push({
|
|
183
|
+
kind: 'persona-turn',
|
|
184
|
+
body: `Pugi: ${text} (stub)`,
|
|
185
|
+
});
|
|
186
|
+
// One-time banner on the first turn so operators see why the stub
|
|
187
|
+
// is firing. Suppressed on subsequent turns to keep the envelope
|
|
188
|
+
// stream noise-free.
|
|
189
|
+
if (input.turnIndex === 0) {
|
|
190
|
+
envelopes.push({ kind: 'system', body: banner });
|
|
191
|
+
}
|
|
192
|
+
return envelopes;
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=headless-repl.js.map
|
package/dist/runtime/version.js
CHANGED
|
@@ -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.
|
|
47
|
+
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.36');
|
|
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.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { PERMISSION_MODES, PERMISSION_MODE_GLOSS, } from '../core/permissions/index.js';
|
|
5
|
+
/**
|
|
6
|
+
* Build the rendered rows from the canonical mode list. We map the
|
|
7
|
+
* 1-line gloss → row hint so `/permissions` and the picker stay in
|
|
8
|
+
* sync — a change to `PERMISSION_MODE_GLOSS` shows up here too.
|
|
9
|
+
*/
|
|
10
|
+
const ITEMS = PERMISSION_MODES.map((mode) => ({
|
|
11
|
+
mode,
|
|
12
|
+
title: titleCase(mode),
|
|
13
|
+
hint: PERMISSION_MODE_GLOSS[mode],
|
|
14
|
+
}));
|
|
15
|
+
function titleCase(mode) {
|
|
16
|
+
return mode.charAt(0).toUpperCase() + mode.slice(1);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the initial cursor — prefer the explicit `initialIndex`
|
|
20
|
+
* (spec / test) when provided, otherwise highlight the row matching
|
|
21
|
+
* the active mode so the operator opens the picker with their
|
|
22
|
+
* current selection focused.
|
|
23
|
+
*/
|
|
24
|
+
function resolveInitialIndex(currentMode, override) {
|
|
25
|
+
if (typeof override === 'number') {
|
|
26
|
+
return Math.min(Math.max(override, 0), ITEMS.length - 1);
|
|
27
|
+
}
|
|
28
|
+
const idx = ITEMS.findIndex((item) => item.mode === currentMode);
|
|
29
|
+
return idx >= 0 ? idx : 0;
|
|
30
|
+
}
|
|
31
|
+
export function PermissionsPicker(props) {
|
|
32
|
+
const [index, setIndex] = useState(resolveInitialIndex(props.currentMode, props.initialIndex));
|
|
33
|
+
useInput((input, key) => {
|
|
34
|
+
if (key.upArrow || input === 'k') {
|
|
35
|
+
setIndex((current) => (current === 0 ? ITEMS.length - 1 : current - 1));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (key.downArrow || input === 'j') {
|
|
39
|
+
setIndex((current) => (current === ITEMS.length - 1 ? 0 : current + 1));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (key.return) {
|
|
43
|
+
const selected = ITEMS[index];
|
|
44
|
+
if (selected)
|
|
45
|
+
props.onSelect(selected.mode);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (key.escape || input === 'q') {
|
|
49
|
+
props.onCancel();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// Number shortcuts mirror the legacy text table order.
|
|
53
|
+
if (input === '1')
|
|
54
|
+
props.onSelect('plan');
|
|
55
|
+
if (input === '2')
|
|
56
|
+
props.onSelect('ask');
|
|
57
|
+
if (input === '3')
|
|
58
|
+
props.onSelect('allow');
|
|
59
|
+
if (input === '4')
|
|
60
|
+
props.onSelect('bypass');
|
|
61
|
+
});
|
|
62
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Permission mode" }), _jsx(Text, { dimColor: true, children: ` (current: ${props.currentMode} — ${props.sourceLabel})` })] }), props.firstRun ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "First time? Mode = Ask \u043F\u043E \u0443\u043C\u043E\u043B\u0447\u0430\u043D\u0438\u044E. Use /permissions \u043A change later." }) })) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: ITEMS.map((item, itemIndex) => {
|
|
63
|
+
const isSelected = itemIndex === index;
|
|
64
|
+
const isCurrent = item.mode === props.currentMode;
|
|
65
|
+
return (_jsx(PickerRow, { isSelected: isSelected, isCurrent: isCurrent, title: item.title, hint: item.hint }, item.mode));
|
|
66
|
+
}) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: '↑/↓ select Enter confirm Esc cancel' }) })] }));
|
|
67
|
+
}
|
|
68
|
+
function PickerRow({ isSelected, isCurrent, title, hint, }) {
|
|
69
|
+
// Arrow glyph + padded title so highlighted and dim rows share
|
|
70
|
+
// column alignment. A trailing ` ●` marks the currently-effective
|
|
71
|
+
// mode (separate from cursor focus) so an operator instantly sees
|
|
72
|
+
// which row is "what I have now" vs "what I'm hovering".
|
|
73
|
+
const indicator = isSelected ? '▸ ' : ' ';
|
|
74
|
+
const padded = title.padEnd(10, ' ');
|
|
75
|
+
const currentMarker = isCurrent ? ' ●' : ' ';
|
|
76
|
+
return (_jsxs(Text, { children: [_jsxs(Text, { color: isSelected ? 'cyan' : undefined, bold: isSelected, children: [indicator, padded] }), _jsx(Text, { color: isCurrent ? 'green' : undefined, children: currentMarker }), _jsx(Text, { dimColor: true, children: ` ${hint}` })] }));
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=permissions-picker.js.map
|
package/dist/tui/render.js
CHANGED
|
@@ -2,6 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import { render } from 'ink';
|
|
3
3
|
import { DeviceFlow } from './device-flow.js';
|
|
4
4
|
import { LoginPicker } from './login-picker.js';
|
|
5
|
+
import { PermissionsPicker } from './permissions-picker.js';
|
|
5
6
|
import { Splash } from './splash.js';
|
|
6
7
|
import { collectSplashData } from './splash-data.js';
|
|
7
8
|
/**
|
|
@@ -66,6 +67,40 @@ export function renderLoginPicker(apiUrl) {
|
|
|
66
67
|
}));
|
|
67
68
|
});
|
|
68
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Sentinel thrown when the operator dismisses the permissions picker
|
|
72
|
+
* via Esc / q. Mirrors `LoginCancelledError` — the host catches and
|
|
73
|
+
* prints a one-line abort message; no mode flip lands.
|
|
74
|
+
*/
|
|
75
|
+
export class PermissionsPickerCancelledError extends Error {
|
|
76
|
+
constructor() {
|
|
77
|
+
super('Permissions picker cancelled');
|
|
78
|
+
this.name = 'PermissionsPickerCancelledError';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function renderPermissionsPicker(options) {
|
|
82
|
+
return new Promise((resolveMode, rejectMode) => {
|
|
83
|
+
let settled = false;
|
|
84
|
+
const finish = (cb) => {
|
|
85
|
+
if (settled)
|
|
86
|
+
return;
|
|
87
|
+
settled = true;
|
|
88
|
+
instance.unmount();
|
|
89
|
+
setImmediate(cb);
|
|
90
|
+
};
|
|
91
|
+
const instance = render(React.createElement(PermissionsPicker, {
|
|
92
|
+
currentMode: options.currentMode,
|
|
93
|
+
sourceLabel: options.sourceLabel,
|
|
94
|
+
firstRun: options.firstRun ?? false,
|
|
95
|
+
onSelect: (mode) => {
|
|
96
|
+
finish(() => resolveMode(mode));
|
|
97
|
+
},
|
|
98
|
+
onCancel: () => {
|
|
99
|
+
finish(() => rejectMode(new PermissionsPickerCancelledError()));
|
|
100
|
+
},
|
|
101
|
+
}));
|
|
102
|
+
});
|
|
103
|
+
}
|
|
69
104
|
/**
|
|
70
105
|
* Mount `<DeviceFlow />` on a TTY and return a handle the host uses to
|
|
71
106
|
* drive the frame. Mirrors `renderLoginPicker`'s lifecycle: we
|
package/dist/tui/status-bar.js
CHANGED
|
@@ -32,7 +32,7 @@ export function StatusBar(props) {
|
|
|
32
32
|
// per-brief `elapsedLabel` on the row below.
|
|
33
33
|
const costRow = renderCostMeterRow(props.sessionTokensIn ?? 0, props.sessionTokensOut ?? 0, props.sessionCostUsd ?? 0, props.sessionStartedAtEpochMs, now);
|
|
34
34
|
const deltaFlash = renderTurnDeltaFlash(props.lastTurnDelta, now);
|
|
35
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: `↑ ${costRow.tokensInLabel}` }), _jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { color: "cyan", children: `↓ ${costRow.tokensOutLabel}` }), _jsx(Text, { dimColor: true, children: ` · ` }), _jsx(Text, { bold: true, children: costRow.costLabel }), _jsx(Text, { dimColor: true, children: ` · ${costRow.elapsedLabel}` }), deltaFlash ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { color: "green", children: deltaFlash })] })) : null] }), _jsxs(Box, { children: [_jsx(Text, { color: status.color, children: `${glyph ?? '●'} ${status.label}` }), _jsx(Text, { dimColor: true, children: ` · ${props.activeAgentCount} agents · ` }), _jsx(Text, { children: `↓ ${tokenLabel} tokens` }), _jsx(Text, { dimColor: true, children: ` · ${elapsedLabel}` })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `${formatCount(props.pugiMdCount)} PUGI.md · ${formatCount(props.mcpServerCount)} MCP · ${formatCount(props.skillCount)} skills · ${formatQuota(props.quotaPct)} quota` }) })] }));
|
|
35
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: `↑ ${costRow.tokensInLabel}` }), _jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { color: "cyan", children: `↓ ${costRow.tokensOutLabel}` }), _jsx(Text, { dimColor: true, children: ` · ` }), _jsx(Text, { bold: true, children: costRow.costLabel }), _jsx(Text, { dimColor: true, children: ` · ${costRow.elapsedLabel}` }), deltaFlash ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { color: "green", children: deltaFlash })] })) : null] }), _jsxs(Box, { children: [_jsx(Text, { color: status.color, children: `${glyph ?? '●'} ${status.label}` }), _jsx(Text, { dimColor: true, children: ` · ${props.activeAgentCount} agents · ` }), _jsx(Text, { children: `↓ ${tokenLabel} tokens` }), _jsx(Text, { dimColor: true, children: ` · ${elapsedLabel}` }), typeof props.externalDispatchCount === 'number' && props.externalDispatchCount > 0 ? (_jsx(Text, { color: "yellow", children: ` · ${props.externalDispatchCount} dispatch${props.externalDispatchCount === 1 ? '' : 'es'} active. /cancel к manage.` })) : null] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `${formatCount(props.pugiMdCount)} PUGI.md · ${formatCount(props.mcpServerCount)} MCP · ${formatCount(props.skillCount)} skills · ${formatQuota(props.quotaPct)} quota` }) })] }));
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
38
|
* α7 cost-meter sprint — assemble the cost-meter row labels. Pure helper
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
+
import { RESULT_PREVIEW_MAX_CHARS, STREAMING_DELTA_MAX_CHARS, } from '../core/repl/session.js';
|
|
3
4
|
const DEFAULT_COLLAPSE_THRESHOLD = 5;
|
|
4
5
|
const DEFAULT_MAX_ROWS = 8;
|
|
5
6
|
export function ToolStreamPane(props) {
|
|
@@ -28,7 +29,38 @@ function ToolCallRow({ call, collapseThreshold, }) {
|
|
|
28
29
|
const label = formatToolLabel(call.tool, call.args);
|
|
29
30
|
const summary = formatSummary(call);
|
|
30
31
|
const showHint = (call.resultLines ?? 0) > collapseThreshold;
|
|
31
|
-
|
|
32
|
+
// Wave 6 small-CC-parity batch (2026-05-27): on a `running` row,
|
|
33
|
+
// surface the rolling streaming delta as a dim inline preview after
|
|
34
|
+
// the label. On a completed row, the same slot carries the
|
|
35
|
+
// `resultPreview` quoted head. Either way the row stays single-line —
|
|
36
|
+
// both fields are clamped к their respective char ceilings upstream.
|
|
37
|
+
const inlineTail = call.status === 'running'
|
|
38
|
+
? call.streamingDelta
|
|
39
|
+
: call.resultPreview;
|
|
40
|
+
// Error rows: render the label too in red so the eye lands on the
|
|
41
|
+
// failure even при peripheral attention. The other states keep the
|
|
42
|
+
// glyph as the only color signal.
|
|
43
|
+
const labelColor = call.status === 'error' ? 'red' : undefined;
|
|
44
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: color, children: glyph }), _jsx(Text, { children: ' ' }), _jsx(Text, { bold: true, color: labelColor, children: label }), _jsx(Text, { dimColor: true, children: ` ${summary}` }), inlineTail ? (_jsx(Text, { dimColor: true, children: ` ${formatInlineTail(call.status, inlineTail)}` })) : null, showHint ? (_jsx(Text, { dimColor: true, children: ` · ${call.resultLines} lines, Ctrl+O to expand` })) : null] }));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Wave 6 small-CC-parity batch (2026-05-27): render the inline tail
|
|
48
|
+
* slot for either the live `streamingDelta` (during `running`) or the
|
|
49
|
+
* collapsed `resultPreview` (after completion). Pure helper so the
|
|
50
|
+
* spec can assert the exact shape.
|
|
51
|
+
*
|
|
52
|
+
* running → `… npm WARN deprecated…`
|
|
53
|
+
* ok / error → `"<preview head>"`
|
|
54
|
+
*/
|
|
55
|
+
export function formatInlineTail(status, tail) {
|
|
56
|
+
if (status === 'running') {
|
|
57
|
+
return tail;
|
|
58
|
+
}
|
|
59
|
+
// Wrap the completed preview in quotes so the operator's eye groups
|
|
60
|
+
// the preview block visually distinct from the canonical detail
|
|
61
|
+
// (`OK`, `+12 -0`). Mirrors the Claude Code TUI's quoted-preview
|
|
62
|
+
// pattern.
|
|
63
|
+
return `"${tail}"`;
|
|
32
64
|
}
|
|
33
65
|
function statusGlyph(status) {
|
|
34
66
|
switch (status) {
|
|
@@ -52,14 +84,24 @@ function statusColor(status) {
|
|
|
52
84
|
}
|
|
53
85
|
/**
|
|
54
86
|
* Render the canonical `Tool(args)` form. Tool names are capitalised
|
|
55
|
-
* the way Claude Code shows them; args are truncated to
|
|
56
|
-
*
|
|
87
|
+
* the way Claude Code shows them; args are truncated to keep the row
|
|
88
|
+
* single-line even on 80-col terminals. Cap = 60 chars (chosen empirically
|
|
89
|
+
* to leave room for the glyph + 1-space gap + summary + optional
|
|
90
|
+
* inline tail without overflow on narrow shells).
|
|
57
91
|
*/
|
|
58
92
|
function formatToolLabel(tool, args) {
|
|
59
93
|
const name = toolDisplayName(tool);
|
|
60
94
|
const trimmedArgs = args.length > 60 ? `${args.slice(0, 57)}…` : args;
|
|
61
95
|
return `${name}(${trimmedArgs})`;
|
|
62
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Re-export the upstream char caps so the operator-facing constants
|
|
99
|
+
* have one source of truth. The session module owns the canonical
|
|
100
|
+
* values (used both during ingest и during the pane's render lookup);
|
|
101
|
+
* tests assert against these names rather than literal numbers so a
|
|
102
|
+
* future tuning сtays diff-friendly.
|
|
103
|
+
*/
|
|
104
|
+
export { RESULT_PREVIEW_MAX_CHARS, STREAMING_DELTA_MAX_CHARS };
|
|
63
105
|
function toolDisplayName(tool) {
|
|
64
106
|
switch (tool) {
|
|
65
107
|
case 'read':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pugi/cli",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.36",
|
|
4
4
|
"description": "Pugi CLI - terminal-native software execution system",
|
|
5
5
|
"homepage": "https://pugi.io",
|
|
6
6
|
"repository": {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"dist/**/*.js",
|
|
31
31
|
"assets/**/*.ansi",
|
|
32
32
|
"docs/examples/**/*.json",
|
|
33
|
+
"test/scenarios/**/*.scenario.txt",
|
|
33
34
|
"README.md",
|
|
34
35
|
"LICENSE",
|
|
35
36
|
"THIRD_PARTY_NOTICES.md"
|
|
@@ -54,7 +55,7 @@
|
|
|
54
55
|
"undici": "^8.3.0",
|
|
55
56
|
"zod": "^3.23.0",
|
|
56
57
|
"@pugi/personas": "0.1.2",
|
|
57
|
-
"@pugi/sdk": "0.1.0-beta.
|
|
58
|
+
"@pugi/sdk": "0.1.0-beta.36"
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
60
61
|
"@types/node": "^22.0.0",
|
|
@@ -69,10 +70,12 @@
|
|
|
69
70
|
"build": "pnpm --filter @pugi/personas --filter @pugi/sdk build && tsc -p tsconfig.json && node scripts/make-bin-executable.mjs",
|
|
70
71
|
"dev": "tsx src/index.ts",
|
|
71
72
|
"typecheck": "pnpm --filter @pugi/personas --filter @pugi/sdk build && tsc -p tsconfig.json --noEmit",
|
|
72
|
-
"test": "pnpm run check:version-lockstep && pnpm run build && node --test --import tsx 'test/**/*.spec.ts' 'test/**/*.spec.tsx'",
|
|
73
|
+
"test": "pnpm run check:version-lockstep && pnpm run build && node --test --import tsx 'test/**/*.spec.ts' 'test/**/*.spec.tsx' 'src/**/*.spec.ts' 'src/**/*.spec.tsx'",
|
|
74
|
+
"test:integration": "INTEGRATION=1 pnpm run build && node --test --import tsx 'test/**/*.spec.ts' 'test/**/*.spec.tsx' 'src/**/*.spec.ts' 'src/**/*.spec.tsx'",
|
|
73
75
|
"version:cli": "tsx src/index.ts version",
|
|
74
76
|
"doctor": "tsx src/index.ts doctor --json",
|
|
75
77
|
"check:version-lockstep": "bash ../../scripts/check-version-lockstep.sh",
|
|
76
|
-
"pack:smoke": "pnpm run check:version-lockstep && node scripts/pack-smoke.mjs"
|
|
78
|
+
"pack:smoke": "pnpm run check:version-lockstep && node scripts/pack-smoke.mjs",
|
|
79
|
+
"release-gate": "node scripts/secret-scanner.mjs"
|
|
77
80
|
}
|
|
78
81
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# scenario: codegen-create-file
|
|
2
|
+
# title: Pugi actually writes a file (not just describes it)
|
|
3
|
+
|
|
4
|
+
# Anti-regression for task #265 — Pugi must call Write, not describe what
|
|
5
|
+
# it would write. The headless stub responder parses
|
|
6
|
+
# "create FILE with content 'TEXT'" and runs the real fs write so the
|
|
7
|
+
# scenario validates the wiring end-to-end. When Phase 2 lands the live
|
|
8
|
+
# engine path the same scenario carries over verbatim.
|
|
9
|
+
|
|
10
|
+
> "create hello.txt with content 'hello world'"
|
|
11
|
+
EXPECT: tool-call kind=Write file=hello.txt
|
|
12
|
+
EXPECT: persona-turn contains "wrote hello.txt"
|
|
13
|
+
EXPECT_FILE: hello.txt exists with content "hello world"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# scenario: compact-force
|
|
2
|
+
# title: /compact --force is acknowledged
|
|
3
|
+
|
|
4
|
+
# Phase 1: the stub responder echoes the directive verbatim — Phase 2
|
|
5
|
+
# wires the slash command through to the live compaction loop. Until
|
|
6
|
+
# then this scenario protects the contract between scenario authoring
|
|
7
|
+
# and the harness envelope shape.
|
|
8
|
+
|
|
9
|
+
> "/compact --force"
|
|
10
|
+
EXPECT: persona-turn contains "/compact"
|
|
11
|
+
EXPECT_NOT: persona-turn contains "Mira"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# scenario: identity
|
|
2
|
+
# title: Pugi self-identifies as Pugi (never as Mira)
|
|
3
|
+
|
|
4
|
+
# CEO directive feedback_live_console_test_every_publish: every published
|
|
5
|
+
# beta must still answer "ты кто?" with the Pugi persona, never the legacy
|
|
6
|
+
# Mira name. This is the single most-asked dialog in CEO dogfood and the
|
|
7
|
+
# regression that has bitten us most often.
|
|
8
|
+
|
|
9
|
+
> "ты кто?"
|
|
10
|
+
EXPECT: persona-turn contains "Pugi" OR "Пуджи"
|
|
11
|
+
EXPECT_NOT: persona-turn contains "Mira" OR "Мира"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# scenario: persona-handoff
|
|
2
|
+
# title: Pugi hands off to Hiroshi when the operator asks for dev work
|
|
3
|
+
|
|
4
|
+
# Phase 1: the headless stub currently echoes input via the Pugi persona
|
|
5
|
+
# (real dispatch routing wires in Phase 2). The scenario is authored
|
|
6
|
+
# against the Phase 2 contract — for now it documents the intent and
|
|
7
|
+
# the harness keeps it as a soft EXPECT so the corpus stays loadable.
|
|
8
|
+
|
|
9
|
+
> "Hiroshi, please review the authentication module"
|
|
10
|
+
EXPECT: persona-turn contains "Pugi" OR "Hiroshi" OR "Хироси"
|
|
11
|
+
EXPECT_NOT: persona-turn contains "Mira"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# scenario: walkback
|
|
2
|
+
# title: Esc-Esc walkback / rewind directive recognized
|
|
3
|
+
|
|
4
|
+
# CEO parity ask (BIG TRACK 8): the operator must be able to walk back
|
|
5
|
+
# the last turn. Phase 1 ships the harness contract — the live walkback
|
|
6
|
+
# wiring lands in BIG TRACK 8. The scenario asserts the stub responder
|
|
7
|
+
# acknowledges the directive verbatim so the contract holds while the
|
|
8
|
+
# real implementation lands behind it.
|
|
9
|
+
|
|
10
|
+
> "/rewind 1"
|
|
11
|
+
EXPECT: persona-turn contains "/rewind"
|
|
12
|
+
EXPECT_NOT: persona-turn contains "Mira"
|