@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.
Files changed (45) hide show
  1. package/dist/commands/smoke.js +133 -0
  2. package/dist/core/auth/ensure-authenticated.js +129 -0
  3. package/dist/core/bash-classifier.js +108 -1
  4. package/dist/core/codegraph/decision-store.js +248 -0
  5. package/dist/core/codegraph/detect-repo.js +459 -0
  6. package/dist/core/codegraph/install.js +134 -0
  7. package/dist/core/codegraph/offer-hook.js +220 -0
  8. package/dist/core/diagnostics/probes/status-snapshot.js +50 -4
  9. package/dist/core/mcp/orchestrator-tools.js +595 -0
  10. package/dist/core/onboarding/ensure-initialized.js +133 -0
  11. package/dist/core/repl/session.js +370 -9
  12. package/dist/core/repl/slash-commands.js +68 -5
  13. package/dist/core/smoke/headless-driver.js +174 -0
  14. package/dist/core/smoke/orchestrator.js +194 -0
  15. package/dist/core/smoke/runner.js +238 -0
  16. package/dist/core/smoke/scenario-parser.js +316 -0
  17. package/dist/runtime/cli.js +453 -11
  18. package/dist/runtime/commands/cancel.js +231 -0
  19. package/dist/runtime/commands/codegraph-status.js +227 -0
  20. package/dist/runtime/commands/mcp.js +66 -11
  21. package/dist/runtime/commands/permissions.js +23 -0
  22. package/dist/runtime/commands/redo-blob-store.js +92 -0
  23. package/dist/runtime/commands/redo.js +361 -0
  24. package/dist/runtime/commands/status.js +11 -3
  25. package/dist/runtime/commands/undo.js +32 -0
  26. package/dist/runtime/headless-repl.js +195 -0
  27. package/dist/runtime/version.js +1 -1
  28. package/dist/tui/permissions-picker.js +78 -0
  29. package/dist/tui/render.js +35 -0
  30. package/dist/tui/status-bar.js +1 -1
  31. package/dist/tui/tool-stream-pane.js +45 -3
  32. package/package.json +7 -4
  33. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  34. package/test/scenarios/compact-force.scenario.txt +11 -0
  35. package/test/scenarios/identity.scenario.txt +11 -0
  36. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  37. package/test/scenarios/walkback.scenario.txt +12 -0
  38. package/dist/core/engine/compaction-hook.js +0 -154
  39. package/dist/core/init/scaffold.js +0 -195
  40. package/dist/core/memory/dual-write.spec.js +0 -297
  41. package/dist/core/memory-sync/queue.spec.js +0 -105
  42. package/dist/core/repl/codebase-survey.js +0 -308
  43. package/dist/core/repl/init-interview.js +0 -457
  44. package/dist/core/repl/onboarding-state.js +0 -297
  45. package/dist/runtime/commands/memory.spec.js +0 -174
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Wave 6 UX (2026-05-27) — `ensureInitialized` helper.
3
+ *
4
+ * Auto-init pre-flight for every Pugi command. Before this helper landed,
5
+ * the only entry points that exercised the init flow were:
6
+ *
7
+ * 1. The explicit `pugi init` CLI subcommand.
8
+ * 2. The REPL's `/init` slash (β1a r1).
9
+ * 3. Engine commands (`pugi code`, `pugi build`, `pugi sync`) which
10
+ * called the legacy `ensureInitialized` in `cli.ts` and threw
11
+ * `Error('Run pugi init first')` if the operator ran them in a
12
+ * directory without `.pugi/`.
13
+ *
14
+ * Read-only commands (`pugi explain`, `pugi review`, `pugi plan`,
15
+ * `pugi smoke`, `pugi chain new`, ...) silently no-op'd the `.pugi/`
16
+ * mirror inside the engine adapter, which made early dogfooding
17
+ * confusing — the operator saw a successful command but no session
18
+ * artifacts on disk and no idea why.
19
+ *
20
+ * Auto-init contract (matches CEO directive Wave 6, 2026-05-27):
21
+ *
22
+ * - `.pugi/` already exists → return `{ status: 'already' }` silently.
23
+ * - Interactive TTY + no `.pugi/` → prompt
24
+ * "No Pugi workspace found here. Initialize? (Y/n)".
25
+ * Default Y. On Y: run `scaffoldPugiWorkspace`, return `{ status:
26
+ * 'initialized' }`. On n: return `{ status: 'declined' }` so the
27
+ * caller can bail with a helpful message.
28
+ * - Non-interactive (CI / pipe / --json / --no-tty) + no `.pugi/`:
29
+ * default behaviour is conservative — return `{ status: 'declined',
30
+ * reason: 'non_interactive' }`. The caller decides how to surface
31
+ * this (engine commands bail with a clean error; read-only
32
+ * commands MAY continue with degraded semantics).
33
+ * - `--no-init` flag forces conservative posture even on interactive
34
+ * terminals (operator wants to fail fast).
35
+ *
36
+ * Session cache: a command pre-flight that already prompted for and
37
+ * scaffolded `.pugi/` MUST NOT re-prompt for the same workspace in the
38
+ * same process. The cache key is the absolute workspace root path. The
39
+ * cache is process-local (Map) — it does not persist across `pugi`
40
+ * invocations (a second `pugi code` in the same shell starts fresh and
41
+ * re-checks the filesystem).
42
+ *
43
+ * This module is intentionally framework-free: no Ink, no React, no
44
+ * readline. The prompt reader is injected via the `prompt` callback so
45
+ * the spec can drive the helper deterministically and the CLI can
46
+ * forward to its existing stdin-reader (`readSingleChoice` in cli.ts).
47
+ */
48
+ import { existsSync, statSync } from 'node:fs';
49
+ import { resolve } from 'node:path';
50
+ /**
51
+ * Process-local cache of workspaces that already passed the pre-flight
52
+ * gate. Keyed by absolute root path. The cache is intentionally
53
+ * additive-only — there is no eviction. A long-running REPL session
54
+ * stays in one workspace and we never want to re-prompt within it.
55
+ */
56
+ const initialisedCache = new Set();
57
+ /**
58
+ * Reset the cache. Exported for spec teardown — production callers
59
+ * never need this.
60
+ */
61
+ export function resetInitializedCache() {
62
+ initialisedCache.clear();
63
+ }
64
+ /**
65
+ * Detect `.pugi/` at `root`. Pure filesystem read; swallows permission
66
+ * errors (returns false). Exported so the spec can assert the same
67
+ * detection the helper uses without re-implementing the check.
68
+ */
69
+ export function hasPugiWorkspace(root) {
70
+ const path = resolve(root, '.pugi');
71
+ try {
72
+ if (!existsSync(path))
73
+ return false;
74
+ return statSync(path).isDirectory();
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ /**
81
+ * Auto-init pre-flight. Idempotent and process-cache aware — calling
82
+ * twice in the same process for the same workspace returns `already`
83
+ * the second time even if the filesystem state changed underneath.
84
+ *
85
+ * Implementation notes:
86
+ *
87
+ * - Returns `{ status: 'already' }` when `.pugi/` exists OR the cache
88
+ * remembers this workspace. The cache short-circuit means a second
89
+ * command in the same process never blocks on the prompt.
90
+ * - Interactive + missing → prompt. The default answer (empty input
91
+ * OR a leading `y` / `yes`) maps to scaffold. Anything else
92
+ * (`n`, `no`, `cancel`, whitespace + non-y) maps to declined.
93
+ * - Scaffolder failures propagate to the caller; the helper does
94
+ * NOT swallow them because a failed scaffold means the operator's
95
+ * command cannot continue anyway. Tests assert this.
96
+ */
97
+ export async function ensureInitialized(opts) {
98
+ const root = resolve(opts.cwd ?? process.cwd());
99
+ if (initialisedCache.has(root)) {
100
+ return { status: 'already', root };
101
+ }
102
+ if (hasPugiWorkspace(root)) {
103
+ initialisedCache.add(root);
104
+ return { status: 'already', root };
105
+ }
106
+ if (opts.skip) {
107
+ return { status: 'declined', root, reason: 'disabled' };
108
+ }
109
+ if (!opts.interactive) {
110
+ return { status: 'declined', root, reason: 'non_interactive' };
111
+ }
112
+ if (!opts.prompt) {
113
+ // Defensive — an interactive caller forgot к wire the prompt
114
+ // reader. Treat the same as non-interactive rather than throwing
115
+ // so the surrounding command can degrade gracefully.
116
+ return { status: 'declined', root, reason: 'non_interactive' };
117
+ }
118
+ const write = opts.write ?? ((line) => process.stderr.write(line));
119
+ write(`No Pugi workspace found at ${root}.\n`);
120
+ const answer = (await opts.prompt('Initialize a new Pugi workspace here? (Y/n) ')).trim().toLowerCase();
121
+ // Default = yes (empty input OR leading 'y'). Anything else = no.
122
+ // Mirrors the gh CLI / claude code prompt convention where the upper-
123
+ // case option in `(Y/n)` is the default-on-Enter answer.
124
+ const acceptedShort = answer === '' || answer === 'y' || answer === 'yes';
125
+ if (!acceptedShort) {
126
+ write('Initialization declined.\n');
127
+ return { status: 'declined', root, reason: 'user_declined' };
128
+ }
129
+ await opts.scaffold({ cwd: root });
130
+ initialisedCache.add(root);
131
+ return { status: 'initialized', root };
132
+ }
133
+ //# sourceMappingURL=ensure-initialized.js.map
@@ -47,6 +47,22 @@ import { DispatchFSM } from './dispatch-fsm.js';
47
47
  import { computeCostUsd, formatCostUsd, formatTokens } from './model-pricing.js';
48
48
  const MAX_TRANSCRIPT_ROWS = 500;
49
49
  const MAX_TOOL_CALLS = 200;
50
+ /**
51
+ * Wave 6 small-CC-parity batch (2026-05-27): width cap for the inline
52
+ * `streamingDelta` tail rendered next to the args while the call is
53
+ * `running`. Keeps the tool-stream row single-line on an 80-col
54
+ * terminal even when Bash output blasts through stdout. Exported so the
55
+ * spec can pin the truncation behaviour.
56
+ */
57
+ export const STREAMING_DELTA_MAX_CHARS = 80;
58
+ /**
59
+ * Wave 6 small-CC-parity batch (2026-05-27): character cap for the
60
+ * collapsed `resultPreview` on a completed row. The pane shows
61
+ * `✓ Read(file) OK (2ms) "first 50 chars…"` so the operator sees what
62
+ * the tool produced without expanding. Per CEO spec (50 chars).
63
+ * Exported so the spec + the pane share one source of truth.
64
+ */
65
+ export const RESULT_PREVIEW_MAX_CHARS = 50;
50
66
  const MAX_RECONNECT_ATTEMPTS = 10;
51
67
  const RECONNECT_BASE_MS = 250;
52
68
  const RECONNECT_MAX_MS = 5_000;
@@ -397,6 +413,13 @@ export class ReplSession {
397
413
  // a failed flush leaves the queue intact for the next start.
398
414
  // Never blocks bootstrap.
399
415
  void this.flushFeedbackQueueOnBootstrap().catch(() => undefined);
416
+ // Wave 6 BT 9 Phase 2 (2026-05-27): codegraph cold-start hook.
417
+ // Surfaces ONE of two nudges:
418
+ // - stale-index reminder ("Codegraph index is N days old…")
419
+ // - 30-day post-decline reminder ("Detected medium TS repo…")
420
+ // Skips silently in every other case. Best-effort — a failed
421
+ // detection NEVER blocks bootstrap (the helper itself catches).
422
+ void this.runCodegraphColdStart().catch(() => undefined);
400
423
  }
401
424
  catch (error) {
402
425
  this.appendSystemLine(`Could not open Pugi session: ${this.errorMessage(error)}`);
@@ -455,6 +478,47 @@ export class ReplSession {
455
478
  apiKey: this.options.apiKey,
456
479
  });
457
480
  }
481
+ /**
482
+ * Wave 6 BT 9 Phase 2 (2026-05-27): codegraph cold-start nudge.
483
+ *
484
+ * Surfaces ONE of two nudges on REPL boot when the gate trips:
485
+ * - 30-day post-decline reminder ("Detected medium TS repo…")
486
+ * - stale-index reminder ("Codegraph index is N days old…")
487
+ *
488
+ * The evaluator is pure; we stamp `lastReindexCheckAt` here so the
489
+ * stale-index nudge throttles к once-per-day. The init-flow first-
490
+ * run prompt is handled separately by `pugi init` to avoid double-
491
+ * prompting в the common "init + then code" boot sequence.
492
+ *
493
+ * Best-effort: any error inside the codegraph module is swallowed —
494
+ * a cold-start nudge that breaks the REPL would be worse than no
495
+ * nudge at all.
496
+ */
497
+ async runCodegraphColdStart() {
498
+ try {
499
+ const workspaceRoot = this.options.workspace?.workspaceCwd ?? process.cwd();
500
+ const { evaluateColdStart } = await import('../codegraph/offer-hook.js');
501
+ const verdict = evaluateColdStart({ workspaceRoot });
502
+ if (verdict.kind === 'silent')
503
+ return;
504
+ if (verdict.kind === 'stale-index') {
505
+ this.appendSystemLine(verdict.message);
506
+ const { markReindexChecked } = await import('../codegraph/decision-store.js');
507
+ markReindexChecked(workspaceRoot);
508
+ return;
509
+ }
510
+ // 'remind' — surface the offer copy as a system line. Operator
511
+ // accepts via `/codegraph-status --install` OR explicitly via
512
+ // `pugi mcp install codegraph codegraph serve --mcp`.
513
+ this.appendSystemLine('');
514
+ this.appendSystemLine(verdict.message);
515
+ this.appendSystemLine(' Accept: run `pugi mcp install codegraph codegraph serve --mcp && pugi mcp trust codegraph`');
516
+ this.appendSystemLine(' Skip: /codegraph-status to inspect the decision; the prompt re-appears in 30 days');
517
+ }
518
+ catch {
519
+ // Codegraph nudge is decoration — failure must NEVER surface.
520
+ }
521
+ }
458
522
  /**
459
523
  * Tear down the SSE stream and stop the reconnect timer. The session
460
524
  * id stays valid server-side; `pugi resume <id>` reopens later.
@@ -714,7 +778,43 @@ export class ReplSession {
714
778
  return verdict;
715
779
  }
716
780
  case 'jobs': {
717
- await this.dispatchJobs();
781
+ // Wave 6 cleanup (2026-05-27): `/jobs --watch` mounts the
782
+ // live Ink TUI from inside the REPL. The dispatcher does NOT
783
+ // mount the watcher itself (that would unmount the REPL's
784
+ // own Ink tree) — instead it surfaces the shell command so
785
+ // the operator runs the watcher in a fresh terminal. Bare
786
+ // `/jobs` continues to render the one-shot snapshot.
787
+ if (verdict.watch) {
788
+ this.appendSystemLine('Run `pugi jobs --watch` from a fresh shell — the live TUI cannot share the REPL Ink tree.');
789
+ }
790
+ else {
791
+ await this.dispatchJobs();
792
+ }
793
+ return verdict;
794
+ }
795
+ case 'cancel': {
796
+ // Wave 6 small-CC-parity batch (2026-05-27): forward the parsed
797
+ // mode + dispatchId to `runCancelCommand`. The dispatcher uses
798
+ // a dynamic import so the cancel module's filesystem helpers
799
+ // stay out of the REPL keystroke hot path; same separation as
800
+ // `/redo`, `/prd-check`, `/chain`. The runner writes its
801
+ // output lines through `appendSystemLine` so the verdict
802
+ // lands on the system pane alongside other slash results.
803
+ try {
804
+ const { runCancelCommand } = await import('../../runtime/commands/cancel.js');
805
+ const cancelMode = verdict.mode === 'list'
806
+ ? { kind: 'list' }
807
+ : verdict.mode === 'all'
808
+ ? { kind: 'all' }
809
+ : { kind: 'one', dispatchId: verdict.dispatchId };
810
+ await runCancelCommand(cancelMode, {
811
+ write: (line) => this.appendSystemLine(line),
812
+ });
813
+ }
814
+ catch (err) {
815
+ const message = err instanceof Error ? err.message : String(err);
816
+ this.appendSystemLine(`/cancel failed: ${message}`);
817
+ }
718
818
  return verdict;
719
819
  }
720
820
  case 'diff': {
@@ -1131,6 +1231,38 @@ export class ReplSession {
1131
1231
  }
1132
1232
  return verdict;
1133
1233
  }
1234
+ case 'codegraph-status': {
1235
+ // Wave 6 BT 9 Phase 2 (2026-05-27): forward to the runner. The
1236
+ // bare form renders the four-row status table; flags handle
1237
+ // install / reindex / offer. Dynamic import keeps the
1238
+ // codegraph module out of the REPL hot path until first use.
1239
+ try {
1240
+ const { runCodegraphStatusCommand } = await import('../../runtime/commands/codegraph-status.js');
1241
+ const lines = [];
1242
+ const workspaceRoot = this.options.workspace?.workspaceCwd ?? process.cwd();
1243
+ await runCodegraphStatusCommand(verdict.args, {
1244
+ workspaceRoot,
1245
+ writeOutput: (_payload, text) => {
1246
+ for (const raw of text.split('\n')) {
1247
+ const trimmed = raw.replace(/\s+$/u, '');
1248
+ lines.push(trimmed);
1249
+ }
1250
+ },
1251
+ });
1252
+ if (lines.length === 0) {
1253
+ this.appendSystemLine('/codegraph-status: no output.');
1254
+ }
1255
+ else {
1256
+ for (const line of lines)
1257
+ this.appendSystemLine(line);
1258
+ }
1259
+ }
1260
+ catch (error) {
1261
+ const message = error instanceof Error ? error.message : String(error);
1262
+ this.appendSystemLine(`/codegraph-status failed: ${message}`);
1263
+ }
1264
+ return verdict;
1265
+ }
1134
1266
  case 'permissions': {
1135
1267
  // Leak L6: handle the `/permissions [mode] [--persist]` flow.
1136
1268
  // The session module forwards to the runtime helper so the
@@ -1517,6 +1649,39 @@ export class ReplSession {
1517
1649
  }
1518
1650
  return verdict;
1519
1651
  }
1652
+ case 'redo': {
1653
+ // Wave 6 cleanup (2026-05-27): counterpart к /undo. The runtime
1654
+ // command `runRedoCommand` consumes one entry from the LIFO
1655
+ // undo stack (most recent unconsumed `tool=undo` result), reads
1656
+ // the captured AFTER content from `.pugi/undo-blobs/`, and
1657
+ // re-applies the mutations under the same mtime+hash external-
1658
+ // modification gate the undo runner uses. Same dynamic-import
1659
+ // posture as /undo so the redo + blob-store + git plumbing
1660
+ // stays out of the REPL hot path.
1661
+ try {
1662
+ const [{ runRedoCommand }, { openSession }] = await Promise.all([
1663
+ import('../../runtime/commands/redo.js'),
1664
+ import('../session.js'),
1665
+ ]);
1666
+ const workspaceRoot = process.cwd();
1667
+ const session = openSession(workspaceRoot);
1668
+ this.appendSystemLine('Reapplying last undo...');
1669
+ await runRedoCommand([], {
1670
+ workspaceRoot,
1671
+ session,
1672
+ writeOutput: (_payload, text) => {
1673
+ const trimmed = text.replace(/\n+$/u, '');
1674
+ if (trimmed.length > 0)
1675
+ this.appendSystemLine(trimmed);
1676
+ },
1677
+ });
1678
+ }
1679
+ catch (error) {
1680
+ const message = error instanceof Error ? error.message : String(error);
1681
+ this.appendSystemLine(`/redo failed: ${message}`);
1682
+ }
1683
+ return verdict;
1684
+ }
1520
1685
  case 'stub': {
1521
1686
  this.appendSystemLine(verdict.message);
1522
1687
  return verdict;
@@ -1863,22 +2028,87 @@ export class ReplSession {
1863
2028
  try {
1864
2029
  const registry = getJobRegistry();
1865
2030
  const entries = await registry.list();
1866
- if (entries.length === 0) {
2031
+ // Wave 6 cleanup (2026-05-27): also scan `.pugi/agent-progress/*.json`
2032
+ // so long-running external agents (the JSON pattern from
2033
+ // `feedback_agent_progress_tracking_pattern.md`) show up next к
2034
+ // background-bash entries. The two surfaces are orthogonal — bash
2035
+ // jobs come from the in-process registry, agent-progress comes from
2036
+ // sidecar JSON written by any agent (Pugi-spawned or external) — so
2037
+ // we render both, sorted with running first.
2038
+ const agentProgressRows = await this.collectAgentProgressRows();
2039
+ if (entries.length === 0 && agentProgressRows.length === 0) {
1867
2040
  this.appendSystemLine('No background jobs tracked.');
1868
2041
  return;
1869
2042
  }
1870
- this.appendSystemLine(`Background jobs (${entries.length}):`);
1871
- for (const entry of entries) {
1872
- const id = entry.id.replace(/^pj-/, '').slice(0, 8);
1873
- const status = entry.status;
1874
- const cmd = entry.command.length > 48 ? `${entry.command.slice(0, 47)}…` : entry.command;
1875
- this.appendSystemLine(` ${id} ${status.padEnd(10)} ${cmd}`);
2043
+ if (entries.length > 0) {
2044
+ this.appendSystemLine(`Background jobs (${entries.length}):`);
2045
+ for (const entry of entries) {
2046
+ const id = entry.id.replace(/^pj-/, '').slice(0, 8);
2047
+ const status = entry.status;
2048
+ const cmd = entry.command.length > 48 ? `${entry.command.slice(0, 47)}…` : entry.command;
2049
+ this.appendSystemLine(` ${id} ${status.padEnd(10)} ${cmd}`);
2050
+ }
2051
+ }
2052
+ if (agentProgressRows.length > 0) {
2053
+ this.appendSystemLine(`Agent progress (${agentProgressRows.length}):`);
2054
+ for (const row of agentProgressRows) {
2055
+ this.appendSystemLine(` ${row}`);
2056
+ }
2057
+ this.appendSystemLine('Tip: run `pugi jobs --watch` for the live Ink TUI.');
1876
2058
  }
1877
2059
  }
1878
2060
  catch (error) {
1879
2061
  this.appendSystemLine(`/jobs failed: ${this.errorMessage(error)}`);
1880
2062
  }
1881
2063
  }
2064
+ /**
2065
+ * Wave 6 cleanup (2026-05-27): scan `.pugi/agent-progress/*.json`
2066
+ * for in-flight long-running agent tasks and emit a one-line per
2067
+ * agent for the `/jobs` snapshot. Sorting matches the live TUI's
2068
+ * `sortProgressEntries` (running first, then by lastUpdate desc).
2069
+ *
2070
+ * Best-effort: a missing dir, malformed JSON, or bad permissions
2071
+ * yields an empty list and a swallowed error — the in-process
2072
+ * registry view is the older well-tested surface and must never be
2073
+ * gated behind a sidecar dir's health.
2074
+ */
2075
+ async collectAgentProgressRows() {
2076
+ try {
2077
+ const [{ resolveProgressDir }, { readProgressFile, sortProgressEntries }, fs, path] = await Promise.all([
2078
+ import('../agent-progress/writer.js'),
2079
+ import('../../commands/jobs-watch.js'),
2080
+ import('node:fs'),
2081
+ import('node:path'),
2082
+ ]);
2083
+ const dir = resolveProgressDir();
2084
+ if (!fs.existsSync(dir))
2085
+ return [];
2086
+ const files = fs
2087
+ .readdirSync(dir)
2088
+ .filter((f) => f.endsWith('.json'))
2089
+ .map((f) => path.join(dir, f));
2090
+ const progress = files
2091
+ .map((p) => readProgressFile(p))
2092
+ .filter((p) => p !== undefined);
2093
+ const sorted = sortProgressEntries(progress);
2094
+ return sorted.map((p) => {
2095
+ const id = p.agentId.length > 24 ? `${p.agentId.slice(0, 23)}…` : p.agentId;
2096
+ const pct = `${String(Math.round(p.percentComplete)).padStart(3, ' ')}%`;
2097
+ const elapsedSec = Math.max(0, Math.floor(p.elapsedMs / 1000));
2098
+ const elapsed = elapsedSec >= 60
2099
+ ? `${Math.floor(elapsedSec / 60)}m${String(elapsedSec % 60).padStart(2, '0')}s`
2100
+ : `${elapsedSec}s`;
2101
+ const status = p.status.padEnd(9, ' ');
2102
+ const step = p.stepDescription.length > 36
2103
+ ? `${p.stepDescription.slice(0, 35)}…`
2104
+ : p.stepDescription;
2105
+ return `${id.padEnd(24, ' ')} ${status} ${pct} ${elapsed.padStart(6, ' ')} ${step}`;
2106
+ });
2107
+ }
2108
+ catch {
2109
+ return [];
2110
+ }
2111
+ }
1882
2112
  dispatchDiff() {
1883
2113
  try {
1884
2114
  const artifactsRoot = resolvePath(process.cwd(), '.pugi', 'artifacts');
@@ -2015,14 +2245,25 @@ export class ReplSession {
2015
2245
  ['review', used.review, caps.review],
2016
2246
  ['engine', used.engine, caps.engine],
2017
2247
  ];
2248
+ // Wave 6 cleanup (2026-05-27): color-code each counter row by
2249
+ // utilisation. The thresholds match Claude Code's tier-meter
2250
+ // convention so operators trained on that surface read the same
2251
+ // signal here. ANSI codes wrap the WHOLE row (not just the
2252
+ // percent) so the line wraps as one visual unit; the cost-quota
2253
+ // spec regex still matches because anchors are inside the
2254
+ // wrapped substring.
2018
2255
  for (const [name, value, cap] of counters) {
2019
2256
  const v = typeof value === 'number' ? value : 0;
2020
2257
  if (cap === null || cap === undefined) {
2258
+ // Unlimited counters never trip the gauge — leave them
2259
+ // uncolored so the eye does not register an alarm signal
2260
+ // where there is no cap к exhaust.
2021
2261
  this.appendSystemLine(` ${name.padEnd(7, ' ')} ${v.toLocaleString()} / unlimited`);
2022
2262
  }
2023
2263
  else {
2024
2264
  const pct = cap > 0 ? Math.round((v / cap) * 100) : 0;
2025
- this.appendSystemLine(` ${name.padEnd(7, ' ')} ${v.toLocaleString()} / ${cap.toLocaleString()} (${pct}%)`);
2265
+ const row = ` ${name.padEnd(7, ' ')} ${v.toLocaleString()} / ${cap.toLocaleString()} (${pct}%)`;
2266
+ this.appendSystemLine(colorizeQuotaRow(row, pct));
2026
2267
  }
2027
2268
  }
2028
2269
  }
@@ -2079,6 +2320,17 @@ export class ReplSession {
2079
2320
  liveTokensUsed: liveTokens >= 0 ? liveTokens : 0,
2080
2321
  lastCommand,
2081
2322
  lastCommandAtEpochMs,
2323
+ // Repl-mode context: the session knows both the live
2324
+ // transport URL and the operator's workspace label, so we
2325
+ // forward them as authoritative inputs к the snapshot.
2326
+ // The status snapshot used к infer these from the
2327
+ // credentials file, which was wrong in two cases:
2328
+ // (a) the operator was inside a REPL talking к Anvil dev
2329
+ // (port 4100) but credentials still pointed к
2330
+ // api.pugi.io — the `Backend` row mis-reported;
2331
+ // (b) `workspaceLabel` was никогда rendered at all.
2332
+ liveApiUrl: this.options.apiUrl,
2333
+ workspaceLabel: this.options.workspaceLabel,
2082
2334
  writeOutput: (_payload, text) => {
2083
2335
  for (const line of text.split('\n')) {
2084
2336
  const trimmed = line.replace(/\s+$/u, '');
@@ -3098,6 +3350,73 @@ export class ReplSession {
3098
3350
  const agent = this.state.agents.find((a) => a.taskId === taskId);
3099
3351
  return agent?.personaSlug ?? 'unknown';
3100
3352
  }
3353
+ /**
3354
+ * Wave 6 small-CC-parity batch (2026-05-27): public ingest path for
3355
+ * a backend-driven `tool.call.delta` event. Appends the delta tail
3356
+ * onto the row's `streamingDelta` (capped at
3357
+ * `STREAMING_DELTA_MAX_CHARS` so the row stays single-line) when the
3358
+ * id matches a `running` row. No-op when the id is unknown OR when
3359
+ * the row already transitioned to a terminal status — late deltas
3360
+ * from a completed call must not overwrite the final detail.
3361
+ *
3362
+ * The renderer in `tool-stream-pane.tsx` reads `streamingDelta` to
3363
+ * paint the inline preview after the canonical args. This method is
3364
+ * the seam the future admin-api SSE consumer hooks into; until then
3365
+ * the spec drives it directly so the delta-append branch is locked
3366
+ * down behaviourally.
3367
+ */
3368
+ appendToolCallDelta(id, deltaChunk) {
3369
+ if (!id || !deltaChunk)
3370
+ return;
3371
+ const idx = this.state.toolCalls.findIndex((c) => c.id === id);
3372
+ if (idx < 0)
3373
+ return;
3374
+ const existing = this.state.toolCalls[idx];
3375
+ if (existing.status !== 'running')
3376
+ return;
3377
+ const current = existing.streamingDelta ?? '';
3378
+ let combined = current + deltaChunk;
3379
+ if (combined.length > STREAMING_DELTA_MAX_CHARS) {
3380
+ // Keep the TAIL — the operator wants the freshest bytes (the
3381
+ // line being written right now), not the stale head. The leading
3382
+ // ellipsis signals truncation.
3383
+ combined = `…${combined.slice(combined.length - STREAMING_DELTA_MAX_CHARS + 1)}`;
3384
+ }
3385
+ const next = this.state.toolCalls.slice();
3386
+ next[idx] = { ...existing, streamingDelta: combined };
3387
+ this.patch({ toolCalls: next });
3388
+ }
3389
+ /**
3390
+ * Wave 6 small-CC-parity batch (2026-05-27): public ingest path for
3391
+ * the terminal `tool.call.end` event. Flips the row to `ok` / `error`
3392
+ * with the resolved duration + optional result preview. Cleans up the
3393
+ * transient `streamingDelta` so the completed row renders cleanly
3394
+ * without the live tail. No-op when the id is unknown.
3395
+ */
3396
+ endToolCall(input) {
3397
+ if (!input.id)
3398
+ return;
3399
+ const idx = this.state.toolCalls.findIndex((c) => c.id === input.id);
3400
+ if (idx < 0)
3401
+ return;
3402
+ const existing = this.state.toolCalls[idx];
3403
+ const endedAt = input.endedAtEpochMs ?? Date.now();
3404
+ const durationMs = Math.max(0, endedAt - existing.startedAtEpochMs);
3405
+ const preview = input.resultPreview
3406
+ ? truncatePreview(input.resultPreview, RESULT_PREVIEW_MAX_CHARS)
3407
+ : undefined;
3408
+ const next = this.state.toolCalls.slice();
3409
+ next[idx] = {
3410
+ ...existing,
3411
+ status: input.status,
3412
+ detail: input.detail ?? existing.detail,
3413
+ resultLines: input.resultLines ?? existing.resultLines,
3414
+ durationMs,
3415
+ resultPreview: preview,
3416
+ streamingDelta: undefined,
3417
+ };
3418
+ this.patch({ toolCalls: next });
3419
+ }
3101
3420
  /**
3102
3421
  * Fold a tool call entry into the rolling list. If the entry id
3103
3422
  * already exists, replace it in-place (so a synthesised `running` →
@@ -3671,6 +3990,30 @@ function formatResetWindow(resetAtIso, nowEpochMs) {
3671
3990
  const minutes = Math.max(1, Math.floor(deltaMs / (60 * 1000)));
3672
3991
  return `in ${minutes}m`;
3673
3992
  }
3993
+ /**
3994
+ * Wave 6 cleanup (2026-05-27): wrap a `/quota` counter row in ANSI
3995
+ * color codes by utilisation percent. Thresholds match Claude Code's
3996
+ * tier-meter convention so operators trained on that surface read the
3997
+ * same signal here:
3998
+ *
3999
+ * - 0..70% → green (32m) — comfortable headroom
4000
+ * - 70..90% → yellow (33m) — approaching cap, plan ahead
4001
+ * - 90..100% → red (31m) — burn rate alarm, throttle now
4002
+ *
4003
+ * The wrap is whole-row (not just the percent) so the eye registers
4004
+ * the level on the line, not just the trailing parenthesis. Tests
4005
+ * that match the inner row text via regex are unaffected because the
4006
+ * regex anchors live inside the wrapped substring; the ANSI codes
4007
+ * sit at the boundaries.
4008
+ */
4009
+ export function colorizeQuotaRow(row, pct) {
4010
+ const RESET = '\x1b[0m';
4011
+ if (pct >= 90)
4012
+ return `\x1b[31m${row}${RESET}`;
4013
+ if (pct >= 70)
4014
+ return `\x1b[33m${row}${RESET}`;
4015
+ return `\x1b[32m${row}${RESET}`;
4016
+ }
3674
4017
  /* ------------------------------------------------------------------ */
3675
4018
  /* Tool call synthesiser - α6.12 */
3676
4019
  /* ------------------------------------------------------------------ */
@@ -3722,6 +4065,24 @@ export function synthesiseToolCall(input) {
3722
4065
  startedAtEpochMs: input.now,
3723
4066
  };
3724
4067
  }
4068
+ /**
4069
+ * Wave 6 small-CC-parity batch (2026-05-27): collapse a multi-line
4070
+ * result preview down to a single-line head capped at `max` chars. The
4071
+ * collapsed-result row on a completed tool call uses this so the
4072
+ * preview never expands the row vertically. Exported для the spec so
4073
+ * the truncation behaviour is locked down.
4074
+ */
4075
+ export function truncatePreview(value, max) {
4076
+ if (!value)
4077
+ return '';
4078
+ // Strip CR/LF + tab so the preview stays single-line. Multiple
4079
+ // whitespace runs collapse to single space — operator wants signal,
4080
+ // not formatting noise.
4081
+ const single = value.replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
4082
+ if (single.length <= max)
4083
+ return single;
4084
+ return `${single.slice(0, Math.max(0, max - 1))}…`;
4085
+ }
3725
4086
  function normaliseToolName(raw) {
3726
4087
  const lower = raw.toLowerCase();
3727
4088
  if (lower === 'webfetch' || lower === 'web_fetch')