@pugi/cli 0.1.0-alpha.16 → 0.1.0-alpha.18

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.
@@ -42,14 +42,33 @@ const MAX_TOOL_CALLS = 200;
42
42
  const MAX_RECONNECT_ATTEMPTS = 10;
43
43
  const RECONNECT_BASE_MS = 250;
44
44
  const RECONNECT_MAX_MS = 5_000;
45
+ /**
46
+ * α6.5 filewatch throttle: minimum gap between two file-change
47
+ * system lines surfaced in the conversation pane. Per the sprint
48
+ * spec, a noisy save burst should not flood the transcript - we
49
+ * coalesce all chokidar batches that arrive inside the window into
50
+ * a single "file changed: ..." line.
51
+ */
52
+ const FILEWATCH_SYSTEM_LINE_GAP_MS = 5_000;
53
+ /**
54
+ * Hard cap on the size of `pendingFilewatchBatches`. The throttle window
55
+ * is 5s, but a `tsc --watch` style tool can fire dozens of `change`
56
+ * events per second for hours on end if the operator leaves the REPL
57
+ * up. Without a cap, every batch arriving inside the throttle window
58
+ * would accumulate forever, holding refs to thousands of FilewatchBatch
59
+ * objects (each carrying its own events array). On overflow we drop
60
+ * the OLDEST batch and surface a one-shot system warning so the
61
+ * operator knows the buffer is shedding. triple-review P1 (PR #380).
62
+ */
63
+ const PENDING_FILEWATCH_BATCH_CAP = 100;
45
64
  /**
46
65
  * Cap on silent session-recreate attempts on HTTP 404 from the SSE
47
66
  * stream. When admin-api restarts it drops its in-memory session
48
67
  * store, so the saved sessionId returns 404 on every subscribe. The
49
68
  * CLI mints a fresh server session, swaps the consumer over, and
50
- * keeps running but we cap the recovery to 3 attempts inside 60s
69
+ * keeps running - but we cap the recovery to 3 attempts inside 60s
51
70
  * so a truly down admin-api fails loud instead of spinning forever.
52
- * (α6.14.2 wave 5 CEO dogfood fix.)
71
+ * (α6.14.2 wave 5 - CEO dogfood fix.)
53
72
  */
54
73
  const MAX_SESSION_RECREATE_ATTEMPTS = 3;
55
74
  const SESSION_RECREATE_WINDOW_MS = 60_000;
@@ -84,19 +103,19 @@ export class ReplSession {
84
103
  * `detail` field carries the cumulative model output. `agent.completed`
85
104
  * arrives last and previously overwrote the visible detail to the
86
105
  * literal string `'shipped'` while the transcript line said only
87
- * `shipped.` the actual reply text was lost. By caching the last
106
+ * `shipped.` - the actual reply text was lost. By caching the last
88
107
  * non-trivial detail here, we can flush it into the transcript when
89
108
  * the agent completes so the operator sees what the persona actually
90
109
  * said. CEO wave-2 fix 2026-05-25.
91
110
  */
92
111
  lastStepDetail = new Map();
93
112
  /**
94
- * Optional local SessionStore α6.4. When non-null, every
113
+ * Optional local SessionStore - α6.4. When non-null, every
95
114
  * appendRow() call mirrors the row into the JSONL log so the
96
115
  * conversation can be restored via `/resume`. Errors from the store
97
116
  * are swallowed to a single system line (degradation, not crash).
98
117
  * The store is opened by the CLI bootstrap and closed via
99
- * `ReplSession.close()`. The store ownership is shared the
118
+ * `ReplSession.close()`. The store ownership is shared - the
100
119
  * SqliteSessionStore is process-wide singleton-ish under the
101
120
  * lockfile, so close-on-quit is safe.
102
121
  */
@@ -111,15 +130,69 @@ export class ReplSession {
111
130
  localSessionId;
112
131
  /**
113
132
  * One-shot guard so a store error only emits ONE system line per
114
- * session without this, a stuck filesystem would spam the operator
133
+ * session - without this, a stuck filesystem would spam the operator
115
134
  * with `[store]` errors on every keystroke.
116
135
  */
117
136
  storeErrorEmitted = false;
137
+ /**
138
+ * α6.5 Tier 0 / Tier 1 / chokidar wiring. The bootstrap builds the
139
+ * skeleton + working set + watcher once and hands them to the
140
+ * session. The session uses them to:
141
+ *
142
+ * - render `/context` (count + cap + total bytes + skeleton size).
143
+ * - emit throttled "file changed" system lines on watcher batches.
144
+ * - forget removed files from the working set on `unlink`.
145
+ *
146
+ * All three are optional - tests and minimal callers pass null /
147
+ * undefined and the session degrades to "no three-tier integration"
148
+ * silently. The watcher's own lifecycle is owned by the bootstrap
149
+ * (we do NOT close it in `close()`).
150
+ */
151
+ repoSkeleton;
152
+ workingSet;
153
+ watcher;
154
+ /**
155
+ * Epoch ms of the last filewatch system line. Initialised to 0 so
156
+ * the FIRST batch always emits; subsequent batches inside the gap
157
+ * are coalesced into the next emit window.
158
+ */
159
+ lastFilewatchLineAtEpochMs = 0;
160
+ /**
161
+ * Buffer of batches whose emission was throttled. Drained on the
162
+ * next within-window batch by overwriting the throttled line with
163
+ * a summary that mentions how many additional files were touched.
164
+ * Capped at PENDING_FILEWATCH_BATCH_CAP to bound memory growth
165
+ * under long-running noisy filewatch sources (tsc --watch on a
166
+ * 200-file project hammering for hours). triple-review P1 (PR #380).
167
+ */
168
+ pendingFilewatchBatches = [];
169
+ /**
170
+ * One-shot guard so the overflow-warning system line emits only once
171
+ * per session rather than spamming the operator with `[filewatch]
172
+ * shedding` on every dropped batch.
173
+ */
174
+ pendingFilewatchOverflowWarned = false;
175
+ /**
176
+ * Bound subscriber refs so close() can detach the listeners from the
177
+ * shared watcher. The bootstrap owns the watcher lifecycle (it calls
178
+ * watcher.close() on REPL teardown), but the session MUST detach its
179
+ * own listeners on close() so any chokidar event landing between
180
+ * session.close() and watcher.close() does not run handlers on a
181
+ * dead session. Without detachment, recordFilewatchBatch would
182
+ * touch this.workingSet / this.transcript on a closed session.
183
+ * triple-review P1 (PR #380).
184
+ */
185
+ filewatchBatchHandler = (batch) => {
186
+ this.recordFilewatchBatch(batch);
187
+ };
188
+ filewatchCapHandler = (info) => {
189
+ this.recordFilewatchCapExceeded(info);
190
+ };
118
191
  /**
119
192
  * Rolling dedupe set for `<pugi-ask>` and `<pugi-plan-review>`
120
193
  * signatures. The persona may emit the same envelope twice on network
121
194
  * retry; we suppress the duplicate so the operator does not see two
122
- * stacked modals. Capped at 32 entries generous for a real session,
195
+ * stacked modals. Capped at 32 entries - generous for a real session,
123
196
  * defensive against a hostile flood. (α6.3.)
124
197
  */
125
198
  seenTagSignatures = [];
@@ -144,6 +217,16 @@ export class ReplSession {
144
217
  this.options = options;
145
218
  this.store = options.store ?? null;
146
219
  this.localSessionId = options.localSessionId;
220
+ this.repoSkeleton = options.repoSkeleton ?? null;
221
+ this.workingSet = options.workingSet ?? null;
222
+ this.watcher = options.watcher ?? null;
223
+ // Subscribe to the chokidar watcher when present. Late-binding
224
+ // happens here so the bootstrap can construct the session and
225
+ // attach the watcher in one pass without re-validating shape.
226
+ if (this.watcher) {
227
+ this.watcher.on('batch', this.filewatchBatchHandler);
228
+ this.watcher.on('capExceeded', this.filewatchCapHandler);
229
+ }
147
230
  this.state = {
148
231
  sessionId: undefined,
149
232
  workspaceLabel: options.workspaceLabel,
@@ -207,6 +290,16 @@ export class ReplSession {
207
290
  clearTimeout(this.reconnectTimer);
208
291
  this.reconnectTimer = undefined;
209
292
  }
293
+ // Detach watcher listeners so any chokidar event landing between
294
+ // session.close() and the bootstrap-owned watcher.close() does NOT
295
+ // run a handler on a dead session. The handlers themselves also
296
+ // hard-guard on `this.closed`, but detaching is the load-bearing
297
+ // fix - it severs the strong reference the watcher held on the
298
+ // session callback, which otherwise blocks GC. triple-review P1 (PR #380).
299
+ if (this.watcher) {
300
+ this.watcher.off('batch', this.filewatchBatchHandler);
301
+ this.watcher.off('capExceeded', this.filewatchCapHandler);
302
+ }
210
303
  }
211
304
  /* ------------- input handling -------------- */
212
305
  /**
@@ -284,6 +377,10 @@ export class ReplSession {
284
377
  await this.dispatchResume();
285
378
  return verdict;
286
379
  }
380
+ case 'context': {
381
+ this.dispatchContext();
382
+ return verdict;
383
+ }
287
384
  case 'ask': {
288
385
  // α6.3: synthesise a local yes/no `<pugi-ask>` modal so the
289
386
  // operator can exercise the question UI without a persona-side
@@ -306,7 +403,7 @@ export class ReplSession {
306
403
  }
307
404
  }
308
405
  /**
309
- * In-REPL `/resume` α6.4. Lists the 10 most recent sessions from
406
+ * In-REPL `/resume` - α6.4. Lists the 10 most recent sessions from
310
407
  * the local SessionStore and prints them as a numbered system menu.
311
408
  * The Ink-side picker UI is deferred to the next sprint; today the
312
409
  * operator gets a deterministic list + the exact command to relaunch
@@ -352,12 +449,12 @@ export class ReplSession {
352
449
  /* ------------- α6.3 office-hours surface -------------- */
353
450
  /**
354
451
  * Surface an `<pugi-ask>` modal manually. Returned promise resolves
355
- * with the operator's verdict used by the `pugi ask "<q>"` shell
452
+ * with the operator's verdict - used by the `pugi ask "<q>"` shell
356
453
  * command and by the `/ask` slash. The resolver is wired into the
357
454
  * session state via `pendingAsk` so the REPL UI can render the modal
358
455
  * and forward `onResolve` back through `resolveAsk()`.
359
456
  *
360
- * NOTE: idempotent on a duplicate signature a second presentAsk
457
+ * NOTE: idempotent on a duplicate signature - a second presentAsk
361
458
  * with the same question + option values returns the first
362
459
  * outstanding promise rather than stacking two modals.
363
460
  */
@@ -370,7 +467,7 @@ export class ReplSession {
370
467
  return this.outstandingAskPromise;
371
468
  }
372
469
  // If a DIFFERENT ask is open, reject the new one with a clear
373
- // error rather than silently queueing the persona should never
470
+ // error rather than silently queueing - the persona should never
374
471
  // emit two concurrent asks, and surfacing the bug fails loud.
375
472
  if (this.outstandingAskPromise) {
376
473
  return Promise.reject(new Error('presentAsk: another ask is already pending. Resolve it first.'));
@@ -584,6 +681,129 @@ export class ReplSession {
584
681
  this.appendSystemLine(`Workspace: ${this.state.workspaceLabel}.`);
585
682
  this.appendSystemLine(`CLI: pugi ${this.state.cliVersion}.`);
586
683
  }
684
+ /**
685
+ * α6.5 `/context` slash handler. Surfaces the three-tier context
686
+ * summary as a stack of system lines. Sections (in order):
687
+ *
688
+ * 1. Tier 0 (repo skeleton) - size in bytes, branch, package
689
+ * manager, languages. Skipped when no skeleton was injected
690
+ * (REPL launched outside a workspace or with --no-context).
691
+ *
692
+ * 2. Tier 1 (working set) - `count / capacity` plus the total
693
+ * size in bytes plus the oldest entry's age in seconds.
694
+ * Always emits even when empty so the operator can confirm
695
+ * the tier is wired.
696
+ *
697
+ * 3. Tier 2 (RAG) - one-line heads-up that the Anvil-side
698
+ * workspace lands in α6.5b.
699
+ *
700
+ * The renderer never mutates state.
701
+ */
702
+ dispatchContext() {
703
+ if (this.repoSkeleton) {
704
+ const parts = [`Tier 0 skeleton: ${this.repoSkeleton.totalSize} bytes`];
705
+ if (this.repoSkeleton.branch)
706
+ parts.push(`branch ${this.repoSkeleton.branch}`);
707
+ if (this.repoSkeleton.packageManager)
708
+ parts.push(this.repoSkeleton.packageManager);
709
+ if (this.repoSkeleton.primaryLanguages.length > 0) {
710
+ parts.push(`langs ${this.repoSkeleton.primaryLanguages.slice(0, 3).join('/')}`);
711
+ }
712
+ this.appendSystemLine(parts.join(' - '));
713
+ }
714
+ else {
715
+ this.appendSystemLine('Tier 0 skeleton: not loaded (run pugi init or launch in a workspace).');
716
+ }
717
+ if (this.workingSet) {
718
+ const summary = this.workingSet.summary();
719
+ const ageLine = summary.oldestTouchedAtEpochMs !== null
720
+ ? ` - oldest touch ${formatAgeSeconds(this.now() - summary.oldestTouchedAtEpochMs)} ago`
721
+ : '';
722
+ this.appendSystemLine(`Tier 1 working set: ${summary.count}/${summary.capacity} files, ${summary.totalSizeBytes} bytes${ageLine}.`);
723
+ }
724
+ else {
725
+ this.appendSystemLine('Tier 1 working set: not wired.');
726
+ }
727
+ this.appendSystemLine('Tier 2 RAG: deferred to α6.5b (Anvil-side per-tenant workspace).');
728
+ }
729
+ /**
730
+ * α6.5 chokidar batch handler. Forwards each event to the working
731
+ * set tracker (so `unlink` evicts and `add`/`change` bump the
732
+ * recency) and emits at most one throttled system line per
733
+ * `FILEWATCH_SYSTEM_LINE_GAP_MS` window.
734
+ *
735
+ * The transcript surface intentionally shows ONE filename + the
736
+ * count of additional changes (`file changed: src/foo.ts (+3 more)`).
737
+ * The full event list is preserved in the buffer for future
738
+ * `/context --files` deep-dive (not in α6.5 Phase 1).
739
+ */
740
+ recordFilewatchBatch(batch) {
741
+ // Hard-guard against post-close invocation. close() detaches the
742
+ // watcher listeners, but the EventEmitter contract allows the
743
+ // currently-dispatching emit() call to finish delivering to every
744
+ // listener captured at the start of emit(). If the session closes
745
+ // mid-emit, the handler can still fire on a dead session. Returning
746
+ // early keeps the working set + transcript untouched.
747
+ // triple-review P1 (PR #380).
748
+ if (this.closed)
749
+ return;
750
+ if (this.workingSet) {
751
+ for (const event of batch.events) {
752
+ if (event.kind === 'unlink') {
753
+ this.workingSet.forget(event.absPath);
754
+ }
755
+ // Note: we do NOT auto-track add/change here. The working set
756
+ // reflects files the AGENT touched - filewatch is informational.
757
+ // Future wiring: bash/read/edit tools will call track() directly.
758
+ }
759
+ }
760
+ const nowMs = this.now();
761
+ const sinceLast = nowMs - this.lastFilewatchLineAtEpochMs;
762
+ if (sinceLast < FILEWATCH_SYSTEM_LINE_GAP_MS && this.lastFilewatchLineAtEpochMs !== 0) {
763
+ // Inside the throttle window - buffer for future deep-dive but
764
+ // do not emit a system line. Cap the buffer at
765
+ // PENDING_FILEWATCH_BATCH_CAP and drop the oldest on overflow so
766
+ // a noisy filewatch source cannot drive unbounded memory growth
767
+ // across a long REPL session. triple-review P1 (PR #380).
768
+ if (this.pendingFilewatchBatches.length >= PENDING_FILEWATCH_BATCH_CAP) {
769
+ this.pendingFilewatchBatches.shift();
770
+ if (!this.pendingFilewatchOverflowWarned) {
771
+ this.pendingFilewatchOverflowWarned = true;
772
+ this.appendSystemLine(`Filewatch buffer at cap (${PENDING_FILEWATCH_BATCH_CAP} batches) - shedding oldest. Source may be a build watcher in a tight loop.`);
773
+ }
774
+ }
775
+ this.pendingFilewatchBatches.push(batch);
776
+ return;
777
+ }
778
+ const totalEvents = this.pendingFilewatchBatches.reduce((acc, b) => acc + b.events.length, 0) + batch.events.length;
779
+ const head = batch.events[0];
780
+ if (!head) {
781
+ // Empty batch - should not happen given the watcher guards,
782
+ // but defensive.
783
+ this.pendingFilewatchBatches = [];
784
+ return;
785
+ }
786
+ const wsLine = this.workingSet
787
+ ? ` (working set: ${this.workingSet.size()}/${this.workingSet.capacityLimit()})`
788
+ : '';
789
+ const tail = totalEvents > 1 ? ` (+${totalEvents - 1} more)` : '';
790
+ this.appendSystemLine(`file ${head.kind}: ${head.path}${tail}${wsLine}`);
791
+ this.lastFilewatchLineAtEpochMs = nowMs;
792
+ this.pendingFilewatchBatches = [];
793
+ }
794
+ /**
795
+ * α6.5 chokidar cap-exceeded handler. The watcher closes itself
796
+ * when it crosses the watched-paths cap; the session surfaces a
797
+ * single system line so the operator knows live updates are off.
798
+ * The conversation stays usable - we just lose the file-changed
799
+ * badge for the rest of the session.
800
+ */
801
+ recordFilewatchCapExceeded(info) {
802
+ // Same post-close guard as recordFilewatchBatch. triple-review P1 (PR #380).
803
+ if (this.closed)
804
+ return;
805
+ this.appendSystemLine(`Filewatch off: ${info.watchedCount} watched paths exceeded cap (${info.cap}). Falling back to manual stat-on-read.`);
806
+ }
587
807
  /**
588
808
  * Fetch one URL via the web_fetch tool and inject the resulting
589
809
  * Markdown into the transcript as an operator-attributed brief. The
@@ -716,11 +936,11 @@ export class ReplSession {
716
936
  // synchronously in some transports). If that second 404 arrives
717
937
  // with recreatingSession === true, we must SHORT-CIRCUIT it too
718
938
  // rather than fall through to the legacy "Stream interrupted"
719
- // path otherwise the operator sees the exact 404 line the
939
+ // path - otherwise the operator sees the exact 404 line the
720
940
  // recreate is trying to suppress.
721
941
  if (this.isSessionNotFoundError(error)) {
722
942
  if (this.recreatingSession) {
723
- // Recreate already in flight drop the duplicate 404 on the
943
+ // Recreate already in flight - drop the duplicate 404 on the
724
944
  // floor. The first recreate will either succeed (new stream
725
945
  // opens, this dead handle is gone) or fall through to the
726
946
  // loud "keeps dropping" / "session recreate refused" paths
@@ -770,7 +990,7 @@ export class ReplSession {
770
990
  this.recentRecreateAtMs.shift();
771
991
  }
772
992
  if (this.recentRecreateAtMs.length >= MAX_SESSION_RECREATE_ATTEMPTS) {
773
- // Cap exceeded fall back to the loud "give up" path so the
993
+ // Cap exceeded - fall back to the loud "give up" path so the
774
994
  // operator sees something is actually wrong.
775
995
  this.appendSystemLine('Admin API session keeps dropping (HTTP 404 x3). Type /quit and `pugi resume` to retry.');
776
996
  this.patch({ connection: 'offline' });
@@ -784,7 +1004,7 @@ export class ReplSession {
784
1004
  this.streamHandle.close();
785
1005
  this.streamHandle = undefined;
786
1006
  }
787
- // Reset reconnect attempt + lastEventId the new session is a
1007
+ // Reset reconnect attempt + lastEventId - the new session is a
788
1008
  // fresh stream, not a continuation of the dead one.
789
1009
  this.reconnectAttempt = 0;
790
1010
  this.lastEventId = undefined;
@@ -804,7 +1024,7 @@ export class ReplSession {
804
1024
  this.openStream();
805
1025
  }
806
1026
  catch (error) {
807
- // The recreate POST itself failed fall back to the existing
1027
+ // The recreate POST itself failed - fall back to the existing
808
1028
  // backoff reconnect so the operator still sees retry progress.
809
1029
  this.appendSystemLine(`Session recreate refused (${this.errorMessage(error)}). Reconnecting.`);
810
1030
  this.scheduleReconnect();
@@ -964,7 +1184,7 @@ export class ReplSession {
964
1184
  // splits per line so word-wrap stays correct.
965
1185
  //
966
1186
  // Claude triple-review P1 (PR #369): the prior `includes('```')`
967
- // gate only caught fences multi-line bullets fragmented
1187
+ // gate only caught fences - multi-line bullets fragmented
968
1188
  // per row showed as `▸ Mira • read PUGI.md / ▸ Mira • patched
969
1189
  // bug / ...` instead of a single grouped bullet block.
970
1190
  if (looksLikeMarkdown(finalDetail)) {
@@ -1076,7 +1296,7 @@ export class ReplSession {
1076
1296
  // colon / whitespace) so the prefix the pane adds is the only one
1077
1297
  // visible. We also drop any leaked `<workspace-context-NONCE>`
1078
1298
  // wrapper the model sometimes echoes back at the head of its
1079
- // first turn that envelope is for prompt scaffolding, not for
1299
+ // first turn - that envelope is for prompt scaffolding, not for
1080
1300
  // the operator's eyes.
1081
1301
  const stripped = stripPersonaPrefixEcho(personaSlug, text);
1082
1302
  this.appendRow({ source: 'persona', text: stripped, personaSlug });
@@ -1106,7 +1326,7 @@ export class ReplSession {
1106
1326
  * Best-effort write of one transcript row into the local
1107
1327
  * SessionStore. Swallows errors after emitting one system line so a
1108
1328
  * broken store never blocks the conversation. Public callers go
1109
- * through `appendRow` this method is private on purpose.
1329
+ * through `appendRow` - this method is private on purpose.
1110
1330
  */
1111
1331
  persistRow(row) {
1112
1332
  if (!this.store)
@@ -1138,14 +1358,14 @@ export class ReplSession {
1138
1358
  });
1139
1359
  }
1140
1360
  /**
1141
- * Restore a transcript from a stored event log α6.4. Called by
1361
+ * Restore a transcript from a stored event log - α6.4. Called by
1142
1362
  * the CLI bootstrap when the operator runs `pugi resume <id>` or
1143
1363
  * picks an entry from the `/resume` picker. Replays each event into
1144
1364
  * the local transcript WITHOUT writing back to the store so the
1145
1365
  * restore is idempotent.
1146
1366
  *
1147
1367
  * Implementation note: we briefly disable persistence by setting
1148
- * `storeErrorEmitted` BEFORE the replay and clearing it after but
1368
+ * `storeErrorEmitted` BEFORE the replay and clearing it after - but
1149
1369
  * the cleaner path is to bypass `appendRow` entirely and patch
1150
1370
  * state directly. We do the latter so persistRow does not double-
1151
1371
  * write the restored events.
@@ -1157,7 +1377,7 @@ export class ReplSession {
1157
1377
  if (row)
1158
1378
  rows.push(row);
1159
1379
  }
1160
- // Cap at MAX_TRANSCRIPT_ROWS the same cap appendRow uses so the
1380
+ // Cap at MAX_TRANSCRIPT_ROWS - the same cap appendRow uses so the
1161
1381
  // window math stays consistent post-restore.
1162
1382
  const capped = rows.slice(-MAX_TRANSCRIPT_ROWS);
1163
1383
  this.patch({ transcript: capped });
@@ -1193,7 +1413,7 @@ export class ReplSession {
1193
1413
  // a fresh `agent.step` carries the full body up to the current
1194
1414
  // token (matches the wave-2 caching contract above). We pass the
1195
1415
  // raw detail through the extractors directly rather than keeping a
1196
- // separate buffer but we still record the pre-extraction body so
1416
+ // separate buffer - but we still record the pre-extraction body so
1197
1417
  // a partial open tag is preserved when the next chunk arrives.
1198
1418
  this.askBuffer.set(taskId, detail);
1199
1419
  const askResult = extractAskTags(detail);
@@ -1202,7 +1422,7 @@ export class ReplSession {
1202
1422
  if (this.seenTagSignatures.includes(tag.signature))
1203
1423
  continue;
1204
1424
  this.recordSeenTag(tag.signature);
1205
- // Only one pending ask at a time drop additional tags in the
1425
+ // Only one pending ask at a time - drop additional tags in the
1206
1426
  // same step into the cleaned body as a system warning. The
1207
1427
  // persona's prompt forbids concurrent asks, so this branch is a
1208
1428
  // defensive guard against a misbehaving model.
@@ -1287,7 +1507,7 @@ export class ReplSession {
1287
1507
  /**
1288
1508
  * Map a stored SessionEvent back into a TranscriptRow for `/resume`
1289
1509
  * replay. Returns null when the event has no operator-visible body
1290
- * (e.g. tool.start without a text payload those land back as
1510
+ * (e.g. tool.start without a text payload - those land back as
1291
1511
  * tool stream rows, not transcript rows). The shape mirrors the
1292
1512
  * `persistRow` mapping in reverse:
1293
1513
  *
@@ -1345,7 +1565,7 @@ function eventToTranscriptRow(event) {
1345
1565
  /**
1346
1566
  * Heuristic: does this text contain Markdown structures that benefit
1347
1567
  * from atomic grouping? Code fences, bullet lists, numbered lists,
1348
- * headings anything where per-line splitting would fragment visual
1568
+ * headings - anything where per-line splitting would fragment visual
1349
1569
  * grouping (Claude triple-review P1 PR #369).
1350
1570
  */
1351
1571
  function looksLikeMarkdown(text) {
@@ -1375,6 +1595,31 @@ function safePersonaName(role) {
1375
1595
  return role;
1376
1596
  }
1377
1597
  }
1598
+ /**
1599
+ * Render a millisecond delta as a compact human-readable age. Used by
1600
+ * `/context` to surface the oldest working-set entry's age:
1601
+ *
1602
+ * < 60s -> `45s`
1603
+ * < 1h -> `4m`
1604
+ * < 24h -> `2h`
1605
+ * >= 24h -> `3d`
1606
+ *
1607
+ * Negative deltas (clock skew) clamp to `0s`.
1608
+ */
1609
+ function formatAgeSeconds(deltaMs) {
1610
+ const ms = Math.max(0, deltaMs);
1611
+ const seconds = Math.floor(ms / 1000);
1612
+ if (seconds < 60)
1613
+ return `${seconds}s`;
1614
+ const minutes = Math.floor(seconds / 60);
1615
+ if (minutes < 60)
1616
+ return `${minutes}m`;
1617
+ const hours = Math.floor(minutes / 60);
1618
+ if (hours < 24)
1619
+ return `${hours}h`;
1620
+ const days = Math.floor(hours / 24);
1621
+ return `${days}d`;
1622
+ }
1378
1623
  /**
1379
1624
  * Convenience: list the legal role slugs the operator can target with
1380
1625
  * `/stop`. Surfaced in the slash command help overlay and in the
@@ -1474,7 +1719,7 @@ function parseStatusFromTail(tail) {
1474
1719
  /* */
1475
1720
  /* Mirrors `tui/ask-modal.tsx#encodeAskVerdict` so the session can */
1476
1721
  /* synthesise the operator-side echo without dragging an Ink module */
1477
- /* into the test surface. The two encoders MUST agree byte-for-byte */
1722
+ /* into the test surface. The two encoders MUST agree byte-for-byte - */
1478
1723
  /* a divergence would silently mis-prefix the persona's follow-up. */
1479
1724
  /* ------------------------------------------------------------------ */
1480
1725
  function encodeAskVerdictLocal(verdict) {
@@ -1629,10 +1874,10 @@ export function synthesiseLocalAskTag(question) {
1629
1874
  * "<workspace-context-abc>Pugi, привет" -> "привет"
1630
1875
  * "обычный ответ без префикса" -> "обычный ответ без префикса"
1631
1876
  *
1632
- * The strip is conservative we only remove the display name when it
1877
+ * The strip is conservative - we only remove the display name when it
1633
1878
  * is followed by a separator (comma, colon, dash, space) so a sentence
1634
1879
  * that legitimately contains the name mid-text ("спроси у Pugi") is
1635
- * not mangled. (α6.14.2 wave 5 CEO dogfood fix.)
1880
+ * not mangled. (α6.14.2 wave 5 - CEO dogfood fix.)
1636
1881
  */
1637
1882
  export function stripPersonaPrefixEcho(personaSlug, text) {
1638
1883
  let working = text.trimStart();
@@ -1652,7 +1897,7 @@ export function stripPersonaPrefixEcho(personaSlug, text) {
1652
1897
  }
1653
1898
  // Resolve the display name from the canonical roster. Unknown slugs
1654
1899
  // (forward-compat with future personas streamed by a newer server)
1655
- // skip the strip better to leave the text alone than to mis-strip.
1900
+ // skip the strip - better to leave the text alone than to mis-strip.
1656
1901
  const persona = getPersona(personaSlug);
1657
1902
  if (!persona)
1658
1903
  return working;
@@ -1671,7 +1916,7 @@ export function stripPersonaPrefixEcho(personaSlug, text) {
1671
1916
  // name two or three times back-to-back when the pane prefix also
1672
1917
  // injects "▸ Pugi"; without the loop, only the first token would be
1673
1918
  // peeled and the operator would still see "▸ Pugi Pugi, координатор".
1674
- // Cap at 3 iterations beyond that the text is either pathological
1919
+ // Cap at 3 iterations - beyond that the text is either pathological
1675
1920
  // or unrelated and we should not keep chewing it. Bail when an
1676
1921
  // iteration makes no progress to avoid infinite loops on a regex that
1677
1922
  // matches an empty string (defence-in-depth even though the current
@@ -1,5 +1,5 @@
1
1
  /**
2
- * REPL slash command registry Sprint α5.7, expanded α6.14 wave 2.
2
+ * REPL slash command registry - Sprint α5.7, expanded α6.14 wave 2.
3
3
  *
4
4
  * The REPL input box surfaces a palette of slash commands the operator
5
5
  * can run from inside a persistent session. The wave-2 expansion (CEO
@@ -15,44 +15,31 @@
15
15
  *
16
16
  * Tiering (per CEO wave-2 spec):
17
17
  *
18
- * Tier 1 wired against real state (3 + existing 6 = 9 wired):
18
+ * Tier 1 - wired against real state (3 + existing 6 = 9 wired):
19
19
  * brief, agents, stop, help, quit, web, clear, version, jobs.
20
20
  *
21
- * Tier 2 best-effort wiring against existing surfaces (3):
21
+ * Tier 2 - best-effort wiring against existing surfaces (3):
22
22
  * diff, cost, status.
23
23
  *
24
- * Tier 3 deterministic stubs ("coming in αX.Y") (8):
24
+ * Tier 3 - deterministic stubs ("coming in αX.Y") (8):
25
25
  * compact, resume, memory, config, privacy, budget, mcp, undo.
26
26
  *
27
27
  * Brand voice (brandbook §08): power words `brief / dispatch / stop /
28
28
  * agents / quit / shipped`. Tagline `Brief it. It ships.` reserved for
29
- * `/quit` confirmation and `/help` footer never inline.
29
+ * `/quit` confirmation and `/help` footer - never inline.
30
30
  */
31
31
  import { listRoles } from '../agents/registry.js';
32
32
  /**
33
33
  * Deterministic stub copy returned by the Tier 3 commands. Spec'd
34
34
  * inline so the unit test can pin the exact text without poking at
35
35
  * the help overlay. The version tag at the end maps to the sprint we
36
- * intend to land the real wiring in.
36
+ * intend to land the real wiring in. Keyed by StubSlashCommandName
37
+ * (not the full SlashCommandName union) so wired commands cannot
38
+ * silently appear here with empty placeholders.
37
39
  */
38
40
  export const SLASH_STUB_MESSAGES = Object.freeze({
39
- brief: '',
40
- agents: '',
41
- stop: '',
42
- help: '',
43
- quit: '',
44
- web: '',
45
- clear: '',
46
- version: '',
47
- jobs: '',
48
- ask: '',
49
- diff: '',
50
- cost: '',
51
- status: '',
52
- consensus: '',
53
- compact: 'Manual context compaction lands in α6.5.',
54
- resume: '',
55
- memory: 'Session memory editor lands in α6.5.',
41
+ compact: 'Manual context compaction lands in α6.5b.',
42
+ memory: 'Session memory editor lands in α6.5b.',
56
43
  config: 'Run `pugi config list` from a fresh shell for the full surface; in-REPL editor lands in α6.5.',
57
44
  privacy: 'Run `pugi privacy show` from a fresh shell; in-REPL toggle lands in α6.5.',
58
45
  budget: 'Run `pugi budget` from a fresh shell; in-REPL summary lands in α6.5.',
@@ -69,8 +56,9 @@ export const SLASH_COMMAND_HELP = Object.freeze([
69
56
  // Session
70
57
  { name: 'clear', args: '', gloss: 'Clear conversation pane', group: 'Session' },
71
58
  { name: 'resume', args: '', gloss: 'Pick a stored session to restore', group: 'Session' },
72
- { name: 'compact', args: '', gloss: 'Manual context compaction (α6.5)', group: 'Session', stub: true },
73
- { name: 'memory', args: '', gloss: 'Session memory editor (α6.5)', group: 'Session', stub: true },
59
+ { name: 'context', args: '', gloss: 'Show three-tier context summary (Tier 0 skeleton + Tier 1 working set)', group: 'Session' },
60
+ { name: 'compact', args: '', gloss: 'Manual context compaction (α6.5b)', group: 'Session', stub: true },
61
+ { name: 'memory', args: '', gloss: 'Session memory editor (α6.5b)', group: 'Session', stub: true },
74
62
  // Pugi tools
75
63
  { name: 'web', args: '<url>', gloss: 'Fetch a URL into context', group: 'Pugi tools' },
76
64
  { name: 'diff', args: '', gloss: 'Show pending diff', group: 'Pugi tools' },
@@ -105,7 +93,7 @@ export const SLASH_COMMAND_GROUPS = Object.freeze([
105
93
  * - Empty / whitespace-only input returns `noop` with the original
106
94
  * text so the REPL can ignore it without printing anything.
107
95
  * - Input that does not start with `/` is treated as an implicit
108
- * `/brief <text>` the most-common operator action.
96
+ * `/brief <text>` - the most-common operator action.
109
97
  * - `/<name> [args]` resolves the name against the registry; unknown
110
98
  * names return `error` so the REPL can render a one-line tip
111
99
  * instead of silently dropping the input.
@@ -113,7 +101,7 @@ export const SLASH_COMMAND_GROUPS = Object.freeze([
113
101
  * can render the deterministic "coming in αX.Y" copy uniformly.
114
102
  *
115
103
  * The function never throws. Bad input maps to a structured result the
116
- * REPL can render the alternative (throwing from a keystroke handler)
104
+ * REPL can render - the alternative (throwing from a keystroke handler)
117
105
  * would unmount Ink mid-frame.
118
106
  */
119
107
  export function parseSlashCommand(input) {
@@ -210,6 +198,13 @@ export function parseSlashCommand(input) {
210
198
  // so the slash-command layer stays UI-agnostic.
211
199
  return { kind: 'resume' };
212
200
  }
201
+ case 'context':
202
+ case 'ctx': {
203
+ // α6.5: surface Tier 0 + Tier 1 status. The session module
204
+ // renders the summary as system lines so the operator can see
205
+ // skeleton size + working-set utilisation at a glance.
206
+ return { kind: 'context' };
207
+ }
213
208
  case 'compact':
214
209
  case 'memory':
215
210
  case 'config':
@@ -217,10 +212,11 @@ export function parseSlashCommand(input) {
217
212
  case 'budget':
218
213
  case 'mcp':
219
214
  case 'undo': {
215
+ const stubName = name;
220
216
  return {
221
217
  kind: 'stub',
222
- name: name,
223
- message: SLASH_STUB_MESSAGES[name],
218
+ name: stubName,
219
+ message: SLASH_STUB_MESSAGES[stubName],
224
220
  };
225
221
  }
226
222
  default: {
@@ -42,7 +42,7 @@ import { slugForCwd } from '../core/repl/history.js';
42
42
  * packages/pugi-sdk/package.json); the publish workflow validates the
43
43
  * three are in lockstep.
44
44
  */
45
- const PUGI_CLI_VERSION = "0.1.0-alpha.16";
45
+ const PUGI_CLI_VERSION = "0.1.0-alpha.18";
46
46
  const handlers = {
47
47
  accounts,
48
48
  agents: dispatchAgents,