@north-light/crouter 0.3.174 → 0.3.176

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 (64) hide show
  1. package/dist/api/dto/nodes.d.ts +7 -1
  2. package/dist/api/dto/profiles.d.ts +3 -0
  3. package/dist/clients/attach/render/crtr-output.d.ts +3 -3
  4. package/dist/clients/attach/render/crtr-output.js +25 -13
  5. package/dist/clients/attach/viewer.js +531 -561
  6. package/dist/commands/memory/write.js +13 -12
  7. package/dist/commands/node/create.js +7 -3
  8. package/dist/commands/profile/list.js +3 -1
  9. package/dist/commands/profile/new.js +4 -1
  10. package/dist/commands/profile/project.js +23 -9
  11. package/dist/commands/profile/show.js +3 -1
  12. package/dist/commands/profile.js +2 -2
  13. package/dist/commands/sys/logs.js +4 -2
  14. package/dist/core/__tests__/branded-host-concurrency.test.js +1 -1
  15. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +1 -1
  16. package/dist/core/__tests__/on-read-dedup-resume.test.js +1 -1
  17. package/dist/core/canvas/extensions.d.ts +1 -1
  18. package/dist/core/canvas/extensions.js +7 -1
  19. package/dist/core/command-plugins/transport/http-invoke.js +10 -2
  20. package/dist/core/command.js +3 -1
  21. package/dist/core/config.js +4 -27
  22. package/dist/core/exclusive-lock.d.ts +14 -0
  23. package/dist/core/exclusive-lock.js +124 -0
  24. package/dist/core/human/claim.js +2 -1
  25. package/dist/core/human/convention.d.ts +0 -10
  26. package/dist/core/human/convention.js +1 -123
  27. package/dist/core/io.d.ts +23 -0
  28. package/dist/core/io.js +60 -0
  29. package/dist/core/preview-registry.d.ts +10 -5
  30. package/dist/core/preview-registry.js +136 -2537
  31. package/dist/core/profiles/manifest.d.ts +12 -1
  32. package/dist/core/profiles/manifest.js +57 -48
  33. package/dist/core/review/store.d.ts +4 -1
  34. package/dist/core/review/store.js +10 -0
  35. package/dist/core/runtime/branded-host.js +6 -84
  36. package/dist/core/runtime/broker-extension-render.js +1 -1
  37. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  38. package/dist/core/runtime/canvas-extensions.js +2 -0
  39. package/dist/core/runtime/tmux-bindings.js +1 -1
  40. package/dist/core/runtime/warm-pool.d.ts +9 -8
  41. package/dist/core/runtime/warm-pool.js +58 -62
  42. package/dist/core/substrate/index.d.ts +2 -1
  43. package/dist/core/substrate/index.js +2 -1
  44. package/dist/core/substrate/on-read-node.d.ts +7 -0
  45. package/dist/core/substrate/on-read-node.js +37 -0
  46. package/dist/core/substrate/on-read.d.ts +7 -5
  47. package/dist/core/substrate/on-read.js +15 -23
  48. package/dist/core/termrender/termrender.js +16 -96
  49. package/dist/core/worktree.js +11 -100
  50. package/dist/daemon/api/handlers/nodes.js +32 -9
  51. package/dist/daemon/api/handlers/review-comments.js +5 -44
  52. package/dist/daemon/api/handlers/reviews.js +5 -53
  53. package/dist/daemon/api/handlers/validate.d.ts +22 -0
  54. package/dist/daemon/api/handlers/validate.js +59 -0
  55. package/dist/daemon/api/map.js +1 -0
  56. package/dist/daemon/review/finish.js +1 -10
  57. package/dist/pi-extensions/canvas-doc-substrate.js +1 -1
  58. package/dist/pi-extensions/canvas-preview-result.d.ts +22 -0
  59. package/dist/pi-extensions/canvas-preview-result.js +60 -0
  60. package/dist/types.d.ts +4 -0
  61. package/package.json +1 -1
  62. package/runtime.lock.json +2 -2
  63. package/dist/core/substrate/on-read-pure.d.ts +0 -7
  64. package/dist/core/substrate/on-read-pure.js +0 -272
@@ -187,2593 +187,192 @@ export function iconForPath(tokens) {
187
187
  return familyCp !== undefined ? String.fromCodePoint(familyCp) : '◆';
188
188
  }
189
189
  const MEMORY_READ_CONTENT_LINES = 3;
190
- /** Format a compact mutation receipt. Similar-length labels align into a
191
- * column; a single very long label must not push every short row across the
192
- * card (the old `kind : value` failure mode). */
193
- function keyValueLines(entries) {
194
- if (entries.length === 0)
195
- return [];
196
- const lengths = entries.map(([key]) => key.length);
197
- const min = Math.min(...lengths);
198
- const max = Math.max(...lengths);
199
- const width = max - min <= 12 ? max : 0;
200
- return entries.map(([key, value]) => ({ text: `${width === 0 ? key : key.padEnd(width)}: ${value}` }));
190
+ function record(input) {
191
+ return input.result;
201
192
  }
202
- /** `crtr memory read <name>` prints a `- name:/- kind:/- scope:/- path:`
203
- * metadata block, then `**content:** <body>`, then a `**follow_up:**` hint.
204
- * The collapsed card is a tidy frontmatter glance (Silas, 2026-07-01,
205
- * option C): a `name · kind · scope` identity line (path is filesystem
206
- * noise, dropped; follow_up hint dropped) plus the first few lines of the
207
- * content as a teaser — the full body is what ctrl+o reveals. Returns null
208
- * when neither the metadata fields nor the `**content:**` marker are
209
- * present — an error result (missing/ambiguous doc name, etc.) has none of
210
- * this shape, and must fall back to the raw output rather than render a
211
- * blank collapsed card. */
212
- function summarizeMemoryRead(text) {
213
- const field = (name) => new RegExp(`^-\\s+${name}:\\s*(.+)$`, 'm').exec(text)?.[1].trim() ?? '';
214
- const header = [field('name'), field('kind'), field('scope')].filter((f) => f !== '').join(' · ');
215
- // Content spans from `**content:**` to the `**follow_up:**` hint (or EOF).
216
- const contentMatch = /\*\*content:\*\*\s*([\s\S]*?)(?:\n\s*\*\*follow_up:\*\*|$)/.exec(text);
217
- if (header === '' && contentMatch === null)
218
- return null;
219
- const contentLines = (contentMatch?.[1] ?? '').replace(/^\s+/, '').replace(/\s+$/, '').split('\n');
220
- const teaser = contentLines.slice(0, MEMORY_READ_CONTENT_LINES);
221
- const hasMore = contentLines.slice(MEMORY_READ_CONTENT_LINES).some((l) => l.trim() !== '');
222
- if (hasMore)
223
- teaser.push('…');
224
- const lines = [{ text: header }];
225
- if (teaser.some((l) => l.trim() !== '')) {
226
- lines.push({ text: '' });
227
- for (const l of teaser)
228
- lines.push({ text: l });
229
- }
230
- return lines;
231
- }
232
- /** Cap on individual finding rows in the `memory lint` findings-present
233
- * summary — the count line always shows the true total, this just bounds
234
- * how many per-finding detail lines get listed before falling back to a
235
- * muted "… N more" line. */
236
- const MEMORY_LINT_FINDINGS_CAP = 8;
237
- /** `crtr memory lint` has two real output shapes (verified against the live
238
- * command, not assumed):
239
- * - 0 findings (success return): `- checked: N`, `- corpora: {...}`, a
240
- * prose line, blank, then `0 findings.`
241
- * - >0 findings: the leaf THROWS instead of returning, so the CLI error path
242
- * renders `<error code="error">\nmemory lint: N finding(s) across M
243
- * files\nNext: ...\n</error>` on stdout, with one `crtr: memory lint:
244
- * <path>: <error>` line per finding on stderr (merged into the same tool
245
- * result text when the bash host merges the two streams). Both shapes are
246
- * parsed defensively; an output that matches neither falls through to the
247
- * generic truncation path (returns null). */
248
- function summarizeMemoryLint(text) {
249
- const checkedMatch = /^- checked:\s*(\d+)\s*$/m.exec(text);
250
- const successCountMatch = /(\d+)\s+findings\.\s*$/.exec(text.trimEnd());
251
- if (checkedMatch && successCountMatch) {
252
- const checked = checkedMatch[1];
253
- const count = Number(successCountMatch[1]);
254
- if (count === 0) {
255
- return [
256
- { text: 'PASS', tone: 'success' },
257
- { text: `${checked} docs checked`, tone: 'muted' },
258
- ];
259
- }
260
- return [
261
- { text: `${count} findings`, tone: 'error' },
262
- { text: `${checked} docs checked`, tone: 'muted' },
263
- ];
264
- }
265
- const failureMatch = /(\d+)\s+finding\(s\)\s+across\s+(\d+)\s+files/.exec(text);
266
- if (failureMatch) {
267
- const count = Number(failureMatch[1]);
268
- const checked = failureMatch[2];
269
- const lines = [{ text: `${count} findings`, tone: 'error' }];
270
- const findingLines = [...text.matchAll(/^crtr: memory lint: (.+)$/gm)].map((m) => m[1]);
271
- for (const line of findingLines.slice(0, MEMORY_LINT_FINDINGS_CAP)) {
272
- lines.push({ text: line, tone: 'warning' });
273
- }
274
- const remaining = findingLines.length - MEMORY_LINT_FINDINGS_CAP;
275
- if (remaining > 0)
276
- lines.push({ text: `… ${remaining} more`, tone: 'muted' });
277
- lines.push({ text: `${checked} docs checked`, tone: 'muted' });
278
- return lines;
279
- }
280
- return null;
281
- }
282
- // ---------------------------------------------------------------------------
283
- // Shared table/count parsing helpers for the canvas/node runtime cockpit
284
- // batch below. These parse the exact markdown shapes core/render.ts's generic
285
- // renderArray/renderResult produce (a `- field: N` bullet for a scalar count,
286
- // an `N <label>:` count lead-in followed by a GFM table for an array of
287
- // objects, or a bare `0 <label>.` line for an empty array) — strict recognition
288
- // of that one shape, never a second lenient fallback. Any deviation (missing
289
- // table, column-count mismatch, non-numeric field expected to be numeric)
290
- // returns null so the caller falls through to generic truncation.
291
- // ---------------------------------------------------------------------------
292
- /** Split one GFM table row into its cell strings, honoring `\|` as an escaped
293
- * literal pipe (the only escape core/render.ts's cellEsc introduces) rather
294
- * than a column separator. Returns null when the line isn't a `| ... |` row. */
295
- function splitTableRow(line) {
296
- const trimmed = line.trim();
297
- if (!trimmed.startsWith('|') || !trimmed.endsWith('|'))
298
- return null;
299
- const body = trimmed.slice(1, -1);
300
- const cells = [];
301
- let current = '';
302
- let i = 0;
303
- while (i < body.length) {
304
- const ch = body[i];
305
- if (ch === '\\' && body[i + 1] === '|') {
306
- current += '|';
307
- i += 2;
308
- continue;
309
- }
310
- if (ch === '|') {
311
- cells.push(current.trim());
312
- current = '';
313
- i++;
314
- continue;
315
- }
316
- current += ch;
317
- i++;
318
- }
319
- cells.push(current.trim());
320
- return cells;
193
+ function stringField(value, name) {
194
+ const field = value[name];
195
+ return typeof field === 'string' && field !== '' ? field : undefined;
321
196
  }
322
- /** Find the `N <headingLabel>:` + GFM table block renderArray emits for an
323
- * array-of-objects field named `headingLabel` (e.g. `28 rows:`, `63 nodes:`),
324
- * and parse every data row into a plain `{column: cellText}` record, using the
325
- * header row's own column names as keys — no positional/column-index
326
- * assumptions, so an unrelated extra column (e.g. `hanging`) is carried
327
- * along and simply ignored by callers that only read specific keys. A bare
328
- * `0 <headingLabel>.` line (renderArray's empty-array shape) parses as an
329
- * empty (not null) array. Returns null when the heading is missing, the
330
- * table shape doesn't match (header/separator/row column-count mismatch), or
331
- * trailing non-blank content follows the table — never a partial parse. */
332
- function parseTableRows(text, headingLabel) {
333
- const lines = text.split('\n');
334
- for (let i = 0; i < lines.length; i++) {
335
- const trimmed = lines[i].trim();
336
- if (trimmed === `0 ${headingLabel}.`)
337
- return [];
338
- if (!/^\d+ /.test(trimmed) || !trimmed.endsWith(`${headingLabel}:`))
339
- continue;
340
- let j = i + 1;
341
- while (j < lines.length && lines[j].trim() === '')
342
- j++;
343
- if (j + 1 >= lines.length)
344
- return null;
345
- const header = splitTableRow(lines[j]);
346
- const separator = splitTableRow(lines[j + 1]);
347
- if (header === null || separator === null || header.length === 0 || separator.length !== header.length)
348
- return null;
349
- if (separator.some((cell) => !/^-+$/.test(cell)))
350
- return null;
351
- const rows = [];
352
- j += 2;
353
- while (j < lines.length && lines[j].trim() !== '') {
354
- const cells = splitTableRow(lines[j]);
355
- if (cells === null || cells.length !== header.length)
356
- return null;
357
- const row = {};
358
- for (let c = 0; c < header.length; c++)
359
- row[header[c]] = cells[c];
360
- rows.push(row);
361
- j++;
362
- }
363
- while (j < lines.length && lines[j].trim() === '')
364
- j++;
365
- return j === lines.length ? rows : null;
366
- }
367
- return null;
197
+ function booleanField(value, name) {
198
+ return typeof value[name] === 'boolean' ? value[name] : undefined;
368
199
  }
369
- /** Read a `- <field>: <integer>` scalar bullet (core/render.ts's scalar-bullet
370
- * shape). Returns null when the field isn't present as a plain integer. */
371
- function parseIntBullet(text, field) {
372
- const match = new RegExp(`^-\\s+${field}:\\s*(\\d+)\\s*$`, 'm').exec(text);
373
- return match === null ? null : Number(match[1]);
200
+ function numberField(value, name) {
201
+ return typeof value[name] === 'number' && Number.isFinite(value[name]) ? value[name] : undefined;
374
202
  }
375
- /** Compact a token count for a muted aside: 1000+ rounds to the nearest
376
- * thousand with a `k` suffix (`437000` → `437k`), matching the `ctx 365k`
377
- * shorthand the dashboard tree already uses elsewhere. */
378
- function compactThousands(value) {
379
- return value >= 1000 ? `${Math.round(value / 1000)}k` : String(value);
203
+ function arrayField(value, name) {
204
+ const field = value[name];
205
+ return Array.isArray(field) && field.every((item) => item !== null && typeof item === 'object' && !Array.isArray(item))
206
+ ? field
207
+ : undefined;
380
208
  }
381
- /** The trailing path segment of a cwd, for a compact per-cwd label
382
- * (`/Users/.../cli/crouter` → `crouter`) — a full absolute path is filesystem
383
- * noise in a collapsed summary line. */
384
209
  function basename(path) {
385
210
  const trimmed = path.replace(/[\\/]+$/, '');
386
211
  const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
387
212
  return idx === -1 ? trimmed : trimmed.slice(idx + 1);
388
213
  }
389
- /** Render a `<count> <key>` digest across a fixed preferred key order (each
390
- * shown only when its count is > 0), then any other key present sorted
391
- * alphabetically — the `3 active · 24 done` shape both dashboard and node
392
- * list summaries share. */
393
- function summarizeOrderedCounts(counts, order) {
394
- const inOrder = new Set(order);
395
- const parts = [];
396
- for (const key of order) {
397
- const count = counts.get(key) ?? 0;
398
- if (count > 0)
399
- parts.push(`${count} ${key}`);
400
- }
401
- const extras = [...counts.entries()]
402
- .filter(([key, count]) => count > 0 && !inOrder.has(key))
403
- .sort(([a], [b]) => a.localeCompare(b));
404
- for (const [key, count] of extras)
405
- parts.push(`${count} ${key}`);
406
- return parts.join(' · ');
407
- }
408
- /** Render the top `limit` entries of a name→count map, ranked by count
409
- * descending (name ascending to break ties), with a muted `… N more` tail
410
- * when entries were dropped — the `developer×2, advisor×1` kind-breakdown
411
- * shape. */
412
- function summarizeTopCounts(counts, limit, renderItem) {
413
- const entries = [...counts.entries()]
414
- .filter(([, count]) => count > 0)
415
- .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
416
- const shown = entries.slice(0, limit).map(([name, count]) => renderItem(name, count));
417
- const remaining = entries.length - shown.length;
418
- if (remaining > 0)
419
- shown.push(`… ${remaining} more`);
420
- return shown.join(', ');
421
- }
422
- /** `crtr canvas dashboard`'s object output carries a `rows` array (one entry
423
- * per node: {node_id,name,status,kind,mode,ctx_tokens,asks,...}) rendered by
424
- * renderArray as an `N rows:` table. The collapsed card is a status-count
425
- * digest (active/idle plain, done/dead/canceled muted by the ordering below),
426
- * the largest ctx_tokens across the view as a muted aside, and a warning line
427
- * only when any row carries a pending ask — Silas's 2026-07-01 reframe ("what
428
- * does a human want to know?"), not a re-rendering of the raw tree/table.
429
- * Returns null when the rows table isn't in the expected shape, or is empty
430
- * (an empty canvas has nothing to digest into a status line). */
431
- function summarizeCanvasDashboard(text) {
432
- const rows = parseTableRows(text, 'rows');
433
- if (rows === null || rows.length === 0)
434
- return null;
435
- const statuses = new Map();
436
- let largestCtx = 0;
437
- let asks = 0;
438
- for (const row of rows) {
439
- const status = row['status'];
440
- const ctx = Number(row['ctx_tokens']);
441
- const ask = Number(row['asks']);
442
- if (status === undefined || status === '' || Number.isNaN(ctx) || Number.isNaN(ask))
443
- return null;
444
- statuses.set(status, (statuses.get(status) ?? 0) + 1);
445
- largestCtx = Math.max(largestCtx, ctx);
446
- asks += ask;
447
- }
448
- const digest = summarizeOrderedCounts(statuses, ['active', 'idle', 'done', 'dead', 'canceled']);
449
- if (digest === '')
450
- return null;
451
- const lines = [{ text: digest }];
452
- lines.push({ text: `largest ctx: ${compactThousands(largestCtx)} tokens`, tone: 'muted' });
453
- if (asks > 0)
454
- lines.push({ text: `${asks} pending asks`, tone: 'warning' });
455
- return lines;
456
- }
457
- /** `crtr node inspect list`'s object output carries a `nodes` array (one entry
458
- * per node: {node_id,name,kind,mode,lifecycle,status,cwd,...}) rendered as an
459
- * `N nodes:` table. The collapsed card is a status-count digest (same order
460
- * as the dashboard summary) plus a muted kind breakdown (top 5 kinds by
461
- * count). Returns null when the table isn't in the expected shape or empty. */
462
- function summarizeNodeInspectList(text) {
463
- const rows = parseTableRows(text, 'nodes');
464
- if (rows === null || rows.length === 0)
465
- return null;
466
- const statuses = new Map();
467
- const kinds = new Map();
468
- for (const row of rows) {
469
- const status = row['status'];
470
- const kind = row['kind'];
471
- if (status === undefined || status === '' || kind === undefined || kind === '')
472
- return null;
473
- statuses.set(status, (statuses.get(status) ?? 0) + 1);
474
- kinds.set(kind, (kinds.get(kind) ?? 0) + 1);
475
- }
476
- const statusDigest = summarizeOrderedCounts(statuses, ['active', 'idle', 'done', 'dead', 'canceled']);
477
- const kindDigest = summarizeTopCounts(kinds, 5, (kind, count) => `${kind}×${count}`);
478
- if (statusDigest === '' || kindDigest === '')
479
- return null;
480
- return [
481
- { text: statusDigest },
482
- { text: `kinds: ${kindDigest}`, tone: 'muted' },
483
- ];
484
- }
485
- /** `crtr canvas attention count`'s object output is a single `- count: N`
486
- * bullet. Silas's computed-status framing (2026-07-01): 0 pending asks is a
487
- * genuinely PASSing state worth a green line, not an empty card, and N > 0 is
488
- * worth an error-toned line naming the count — never suppress this leaf down
489
- * to nothing regardless of which way the count falls. Returns null only when
490
- * `count` isn't present as a plain integer (an unrecognized/error shape). */
491
- function summarizeCanvasAttentionCount(text) {
492
- const count = parseIntBullet(text, 'count');
493
- if (count === null)
494
- return null;
495
- if (count === 0)
496
- return [{ text: 'PASS · no pending asks', tone: 'success' }];
497
- return [{ text: `${count} pending asks`, tone: 'error' }];
498
- }
499
- /** `crtr canvas attention list`'s object output is a `- total: N` bullet
500
- * followed by an `M items:` table ({node_id,name,cwd,count}). The collapsed
501
- * card leads with `total · cwd count`, calls out the hottest single cwd in
502
- * warning tone (the one place a human should look first), then a muted
503
- * digest of the remaining cwd basenames (paths are filesystem noise here).
504
- * Zero pending asks reads as the same PASS line attention count uses —
505
- * consistent health framing across both leaves, never an empty card. Returns
506
- * null when the total/items shape doesn't match, or when any item is missing
507
- * its cwd/count (an unrecognized row shape). */
508
- function summarizeCanvasAttentionList(text) {
509
- const total = parseIntBullet(text, 'total');
510
- const items = parseTableRows(text, 'items');
511
- if (total === null || items === null)
512
- return null;
513
- if (total === 0 || items.length === 0)
514
- return [{ text: 'PASS · no pending asks', tone: 'success' }];
515
- const entries = [];
516
- for (const row of items) {
517
- const cwd = row['cwd'];
518
- const count = Number(row['count']);
519
- if (cwd === undefined || cwd === '' || Number.isNaN(count))
520
- return null;
521
- entries.push({ cwd, count });
522
- }
523
- const sorted = entries
524
- .slice()
525
- .sort((a, b) => b.count - a.count || basename(a.cwd).localeCompare(basename(b.cwd)) || a.cwd.localeCompare(b.cwd));
526
- const hottest = sorted[0];
527
- const restBasenames = sorted.slice(1).map((item) => basename(item.cwd));
528
- const lines = [
529
- { text: `${total} total · ${items.length} cwds` },
530
- { text: `hottest: ${basename(hottest.cwd)} (${hottest.count})`, tone: 'warning' },
531
- ];
532
- if (restBasenames.length > 0) {
533
- const shown = restBasenames.slice(0, 4);
534
- const remaining = restBasenames.length - shown.length;
535
- const digest = remaining > 0 ? `${shown.join(', ')}, … ${remaining} more` : shown.join(', ');
536
- lines.push({ text: `other cwds: ${digest}`, tone: 'muted' });
537
- }
538
- return lines;
539
- }
540
- /** `crtr canvas attention map` (src/commands/attention.ts:227) has no custom
541
- * `render`; its `counts` field is a nested plain object (node_id → ask
542
- * count), not an array, so the generic renderer's `renderResult` emits it
543
- * as ONE scalar bullet via `scalarStr`'s compact-JSON branch: `- counts:
544
- * {"id1":0,"id2":3,...}` (output schema at attention.ts:190). No node names
545
- * are available in this output (only ids), so the collapsed card names the
546
- * hottest id, not a name. The real shape is `Record<string, number>` — a
547
- * mixed object (any non-numeric value) is a shape mismatch, not a partial
548
- * total, so it's rejected wholesale rather than filtered down. Returns
549
- * null when `counts` isn't parseable JSON, is empty, or has any
550
- * non-numeric entry. */
551
- function summarizeCanvasAttentionMap(text) {
552
- const m = /^- counts:\s*(\{.*\})\s*$/m.exec(text);
553
- if (m === null)
554
- return null;
555
- let counts;
556
- try {
557
- counts = JSON.parse(m[1]);
558
- }
559
- catch {
560
- return null;
561
- }
562
- if (counts === null || typeof counts !== 'object')
563
- return null;
564
- const rawEntries = Object.entries(counts);
565
- if (rawEntries.length === 0)
566
- return null;
567
- if (rawEntries.some(([, v]) => typeof v !== 'number'))
568
- return null;
569
- const entries = rawEntries;
570
- const total = entries.reduce((s, [, v]) => s + v, 0);
571
- if (total === 0)
572
- return [{ text: 'PASS · no pending asks', tone: 'success' }];
573
- const withAsks = entries.filter(([, v]) => v > 0);
574
- const sorted = withAsks.slice().sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
575
- const hottest = sorted[0];
576
- return [
577
- { text: `${total} asks across ${withAsks.length} node${withAsks.length === 1 ? '' : 's'}` },
578
- { text: `hottest: ${hottest[0]} (${hottest[1]})`, tone: 'warning' },
579
- ];
580
- }
581
- /** Find the first argv token after `skipWords` that isn't a boolean flag
582
- * (`--foo`) — recovers a leaf's own positional id when its render() output
583
- * carries no id of its own (see summarizeNodeLifecycleRevive below). Safe
584
- * here specifically because every `node lifecycle revive` flag is a boolean
585
- * with no separate value token, so a naive forward scan can't mistake a
586
- * flag's value for the positional. */
587
- function firstPositionalAfter(tokens, skipWords) {
588
- for (let i = skipWords; i < tokens.length; i++) {
589
- if (!tokens[i].startsWith('--'))
590
- return tokens[i];
591
- }
592
- return undefined;
593
- }
594
- /** `crtr node lifecycle revive` (src/commands/node-lifecycle-revive.ts) has no
595
- * custom `render`, so its object output falls through to generic
596
- * renderResult. Two shapes depending on flags:
597
- * - `--now` kick: the only shape with `- kicked: true`.
598
- * - plain single-node revive (default or `--fresh`): the only shape with
599
- * `- resumed: true|false`. `window` is documented "Always null", so it's
600
- * never bulleted — and there is no node id anywhere in the output, so
601
- * it's recovered from argv (the leaf's own positional) via
602
- * firstPositionalAfter.
603
- * Returns null on any other/unrecognized shape. */
604
- function summarizeNodeLifecycleRevive(text, tokens) {
605
- if (/^- kicked:\s*true\s*$/m.test(text)) {
606
- return [{ text: 'kicked — daemon resumes in ~20s', tone: 'warning' }];
607
- }
608
- const resumedMatch = /^- resumed:\s*(true|false)\s*$/m.exec(text);
609
- if (resumedMatch === null)
610
- return null;
611
- const id = firstPositionalAfter(tokens, 3); // 'node','lifecycle','revive' = 3 words
612
- const lines = [{ text: id !== undefined ? `revived ${id}` : 'revived', tone: 'success' }];
613
- const readyMatch = /^- ready:\s*(true|false)\s*$/m.exec(text);
614
- if (readyMatch !== null && readyMatch[1] === 'false') {
615
- lines.push({ text: 'not yet attachable (socket not ready)', tone: 'warning' });
616
- }
617
- return lines;
618
- }
619
- const MAINTENANCE_ROSTER_CAP = 4;
620
- /** `crtr canvas prune` emits scalar maintenance receipts followed by a `nodes`
621
- * table. A dry run deliberately reports `pruned: 0` while the table holds its
622
- * candidates; a real run must report exactly as many pruned nodes as table
623
- * rows. This keeps the collapsed receipt outcome-first while retaining the
624
- * expanded table as the authoritative roster. */
625
- function summarizeCanvasPrune(text) {
626
- const pruned = parseIntBullet(text, 'pruned');
627
- const ttlDays = parseIntBullet(text, 'ttlDays');
628
- const dryRunMatch = /^- dryRun:\s*(true|false)\s*$/m.exec(text);
629
- const rows = parseTableRows(text, 'nodes');
630
- if (pruned === null || ttlDays === null || dryRunMatch === null || rows === null)
631
- return null;
632
- const dryRun = dryRunMatch[1] === 'true';
633
- if ((dryRun && pruned !== 0) || (!dryRun && pruned !== rows.length))
634
- return null;
635
- const statuses = new Map();
636
- const ids = [];
637
- for (const row of rows) {
638
- const id = row['node_id'];
639
- const status = row['status'];
640
- if (id === undefined || id === '' || status === undefined || status === '')
641
- return null;
642
- ids.push(id);
643
- statuses.set(status, (statuses.get(status) ?? 0) + 1);
644
- }
645
- const lines = [
646
- dryRun
647
- ? { text: `dry run · ${rows.length} candidate${rows.length === 1 ? '' : 's'}`, tone: 'accent' }
648
- : rows.length === 0
649
- ? { text: 'nothing to prune', tone: 'success' }
650
- : { text: `pruned ${pruned} node${pruned === 1 ? '' : 's'}`, tone: 'success' },
651
- { text: `ttl: ${ttlDays} days`, tone: 'muted' },
652
- ];
653
- const statusDigest = summarizeOrderedCounts(statuses, ['active', 'idle', 'done', 'dead', 'canceled', 'empty']);
654
- if (statusDigest !== '')
655
- lines.push({ text: statusDigest, tone: 'muted' });
656
- if (ids.length > 0) {
657
- const shown = ids.slice(0, MAINTENANCE_ROSTER_CAP);
658
- const remaining = ids.length - shown.length;
659
- lines.push({ text: remaining > 0 ? `${shown.join(', ')}, … ${remaining} more` : shown.join(', '), tone: 'muted' });
660
- }
661
- return lines;
662
- }
663
- /** `crtr canvas browse` writes a static forest only without a TTY. Each node
664
- * line ends in the stable `[kind/mode] ctx Nk` suffix; interactive ANSI output,
665
- * cycle diagnostics, and malformed lines deliberately fall back to raw text. */
666
- function summarizeCanvasBrowse(text) {
667
- const trimmed = text.trim();
668
- if (trimmed === '(canvas is empty)')
669
- return [{ text: 'canvas empty', tone: 'muted' }];
670
- if (trimmed === '(no live roots)')
671
- return [{ text: 'no live roots', tone: 'muted' }];
672
- const kinds = new Map();
673
- let asks = 0;
674
- const lines = text.split('\n').filter((line) => line.trim() !== '');
675
- if (lines.length === 0)
676
- return null;
677
- for (const line of lines) {
678
- const row = /^(?:[│ ]*(?:├─ |└─ ))?.+ \[(.+)\/(base|orchestrator)\] ctx \d+k(?: ⚑(\d+))?(?: · .+)?$/.exec(line);
679
- if (row === null || row[1] === '')
680
- return null;
681
- kinds.set(row[1], (kinds.get(row[1]) ?? 0) + 1);
682
- asks += Number(row[3] ?? 0);
683
- }
684
- const kindDigest = summarizeTopCounts(kinds, MAINTENANCE_ROSTER_CAP, (kind, count) => `${kind}×${count}`);
685
- if (kindDigest === '')
686
- return null;
687
- const out = [
688
- { text: `${lines.length} node${lines.length === 1 ? '' : 's'} in forest` },
689
- { text: `kinds: ${kindDigest}`, tone: 'muted' },
690
- ];
691
- if (asks > 0)
692
- out.push({ text: `${asks} pending ask${asks === 1 ? '' : 's'}`, tone: 'warning' });
693
- return out;
694
- }
695
- /** `crtr canvas revive --all` has a short mode bullet plus a prose message;
696
- * candidate and failure details switch between bullets and prose as their
697
- * length changes, so the stable message count is the only digest input. */
698
- function summarizeCanvasRevive(text) {
699
- const mode = /^- mode:\s*(preview|revived)\s*$/m.exec(text)?.[1];
700
- const message = readFieldFlexible(text, 'message') ?? soloProseAfterBullets(text);
701
- if (mode === undefined || message === undefined)
702
- return null;
703
- if (mode === 'preview') {
704
- if (message === 'No disconnected nodes — every node with a resumable session already has a running engine. Nothing to revive.') {
705
- return [{ text: 'PASS · no disconnected nodes', tone: 'success' }];
706
- }
707
- const preview = /^(\d+) disconnected node\(s\) WOULD be revived \(RESUME\)\. Nothing has been launched yet:/.exec(message);
708
- if (preview === null)
709
- return null;
710
- return [
711
- { text: `preview · ${preview[1]} candidate${preview[1] === '1' ? '' : 's'}`, tone: 'accent' },
712
- { text: 'nothing launched', tone: 'muted' },
713
- ];
714
- }
715
- const complete = /^Revived (\d+) disconnected node\(s\) \(RESUME\)\.$/.exec(message);
716
- if (complete !== null)
717
- return [{ text: `revived ${complete[1]}`, tone: 'success' }];
718
- const partial = /^Revived (\d+) of (\d+) disconnected node\(s\); (\d+) failed to relaunch \(see failed\)\.$/.exec(message);
719
- if (partial === null)
720
- return null;
721
- return [
722
- { text: `revived ${partial[1]} of ${partial[2]}`, tone: 'success' },
723
- { text: `${partial[3]} failed to relaunch`, tone: 'warning' },
724
- ];
725
- }
726
- /** `crtr canvas chord` is a high-frequency menu dispatcher. Its action can
727
- * become prose when argv is long, so retain the dispatched command as the
728
- * compact asset while rejecting anything outside its two real result shapes. */
729
- function summarizeCanvasChord(text) {
730
- const ran = /^- ran:\s*(true|false)\s*$/m.exec(text)?.[1];
731
- const nodeId = /^- node_id:\s*(\S+)\s*$/m.exec(text)?.[1];
732
- const action = readFieldFlexible(text, 'action') ?? soloProseAfterBullets(text);
733
- if (ran === undefined || nodeId === undefined || action === undefined)
734
- return null;
735
- if (ran === 'false')
736
- return action === 'noop' ? [{ text: `no action · ${nodeId}`, tone: 'muted' }] : null;
737
- if (!action.startsWith('crtr '))
738
- return null;
739
- return [
740
- { text: `action dispatched · ${nodeId}`, tone: 'success' },
741
- { text: truncateLine(action, 120), tone: 'muted' },
742
- ];
743
- }
744
- /** `crtr canvas history search` (renderSearch, src/commands/canvas-history/
745
- * search.ts) has three shapes: `0 hits.`; `--full` (### heading blocks
746
- * per hit, not tabular — skipped, returns null); or the normal `N of M
747
- * hits:` lead-in plus a GFM table (columns `{ref,node,kind,source,ts,
748
- * score?,snippet}` — `--full` mode aside, this is now the ONLY row shape
749
- * this leaf can produce; the historical `--grep` flag on this leaf was
750
- * removed, and that line-hit shape now lives at the separate `canvas
751
- * history grep` leaf). Reuses this file's own `splitTableRow` helper rather
752
- * than `parseTableRows` (whose `N <label>:` lead-in must end in a literal
753
- * colon immediately before the table — `N of M hits:` is a close but
754
- * different shape). Returns null on `--full` or any other shape mismatch: a
755
- * non-`---` separator cell, a missing `node`/`ts`/`kind` column (all three
756
- * are now always present on this leaf's rows), or a parsed row count that
757
- * doesn't match the leading `N of M hits:` displayed count `N` (the actual
758
- * page size — pagination can make it less than `M`, the grand total). */
759
- function summarizeCanvasHistorySearch(text) {
760
- if (/^0 hits\.$/m.test(text))
761
- return [{ text: '0 hits', tone: 'muted' }];
762
- const lead = /^(\d+) of (\d+) hits:$/m.exec(text);
763
- if (lead === null)
764
- return null;
765
- const displayed = Number(lead[1]);
766
- const total = Number(lead[2]);
767
- const lines = text.split('\n');
768
- const headerIdx = lines.findIndex((l) => l.trim().startsWith('| ref |'));
769
- if (headerIdx === -1)
770
- return null;
771
- const header = splitTableRow(lines[headerIdx]);
772
- const separator = splitTableRow(lines[headerIdx + 1]);
773
- if (header === null || separator === null || header.length !== separator.length)
774
- return null;
775
- if (separator.some((cell) => !/^-+$/.test(cell)))
776
- return null;
777
- if (!header.includes('node') || !header.includes('ts') || !header.includes('kind'))
778
- return null;
779
- const rows = [];
780
- let j = headerIdx + 2;
781
- while (j < lines.length && lines[j].trim().startsWith('|')) {
782
- const cells = splitTableRow(lines[j]);
783
- if (cells === null || cells.length !== header.length)
784
- return null;
785
- const row = {};
786
- header.forEach((h, i) => { row[h] = cells[i]; });
787
- rows.push(row);
788
- j++;
789
- }
790
- if (rows.length !== displayed)
791
- return null;
792
- const top = rows.slice(0, 3).map((r) => `${r['node']} · ${r['kind']} · ${r['ts']}`);
793
- const out = [{ text: `${total} hit${total === 1 ? '' : 's'}` }];
794
- for (const t of top)
795
- out.push({ text: t, tone: 'muted' });
796
- const remaining = rows.length - top.length;
797
- if (remaining > 0)
798
- out.push({ text: `… ${remaining} more shown`, tone: 'muted' });
799
- return out;
800
- }
801
- /** `crtr canvas history read` (src/commands/canvas-history/read.ts:61):
802
- * `${node} | source: ${source} | ts: ${ts} | ref: ${ref}\n\n${content}`
803
- * — note the DOUBLE SPACES around each `|`. Mirrors summarizeMemoryRead's
804
- * identity+teaser pattern (reusing MEMORY_READ_CONTENT_LINES). Returns null
805
- * when the head line doesn't match this exact shape. */
806
- function summarizeCanvasHistoryRead(text) {
807
- const first = text.split('\n')[0] ?? '';
808
- const head = /^(.+?) \| source: (.+?) \| ts: (.+?) \| ref: (.+)$/.exec(first);
809
- if (head === null)
810
- return null;
811
- const [, node, source, ts] = head;
812
- const rest = text.split('\n').slice(1);
813
- let idx = 0;
814
- while (idx < rest.length && rest[idx].trim() === '')
815
- idx++;
816
- const contentLines = rest.slice(idx);
817
- const teaser = contentLines.slice(0, MEMORY_READ_CONTENT_LINES);
818
- const hasMore = contentLines.slice(MEMORY_READ_CONTENT_LINES).some((l) => l.trim() !== '');
819
- if (hasMore)
820
- teaser.push('…');
821
- const lines = [{ text: `${node} · ${source} · ${ts}` }];
822
- if (teaser.some((l) => l.trim() !== '')) {
823
- lines.push({ text: '' });
824
- for (const l of teaser)
825
- lines.push({ text: l });
826
- }
827
- return lines;
828
- }
829
- /** `crtr canvas history grep` (grepLeaf, src/commands/canvas-history/grep.ts,
830
- * its own `renderGrep` at the bottom of that file) has the same `0 hits.` /
831
- * `N of M hits:` lead-in shape as `canvas history search` but a DIFFERENT,
832
- * fixed row shape — `{ref,node,ts,line,text}` (HistoryGrepHitDTO), never
833
- * `kind`, since this leaf is an exact regex line search, not ranked/browse.
834
- * Reuses splitTableRow for the same reason summarizeCanvasHistorySearch
835
- * does. Returns null on any shape mismatch: a non-`---` separator cell, a
836
- * missing `node`/`line`/`text` column, or a parsed row count that doesn't
837
- * match the leading `N of M hits:` displayed count `N` (the page size —
838
- * pagination can make it less than `M`, the grand total). */
839
- function summarizeCanvasHistoryGrep(text) {
840
- if (/^0 hits\.$/m.test(text))
841
- return [{ text: '0 hits', tone: 'muted' }];
842
- const lead = /^(\d+) of (\d+) hits:$/m.exec(text);
843
- if (lead === null)
844
- return null;
845
- const displayed = Number(lead[1]);
846
- const total = Number(lead[2]);
847
- const lines = text.split('\n');
848
- const headerIdx = lines.findIndex((l) => l.trim().startsWith('| ref |'));
849
- if (headerIdx === -1)
850
- return null;
851
- const header = splitTableRow(lines[headerIdx]);
852
- const separator = splitTableRow(lines[headerIdx + 1]);
853
- if (header === null || separator === null || header.length !== separator.length)
854
- return null;
855
- if (separator.some((cell) => !/^-+$/.test(cell)))
856
- return null;
857
- if (!header.includes('node') || !header.includes('line') || !header.includes('text'))
858
- return null;
859
- const rows = [];
860
- let j = headerIdx + 2;
861
- while (j < lines.length && lines[j].trim().startsWith('|')) {
862
- const cells = splitTableRow(lines[j]);
863
- if (cells === null || cells.length !== header.length)
864
- return null;
865
- const row = {};
866
- header.forEach((h, i) => { row[h] = cells[i]; });
867
- rows.push(row);
868
- j++;
869
- }
870
- if (rows.length !== displayed)
871
- return null;
872
- const top = rows.slice(0, 3).map((r) => `${r['node']} · line ${r['line']}: ${truncateLine(r['text'] ?? '', 80)}`);
873
- const out = [{ text: `${total} hit${total === 1 ? '' : 's'}` }];
874
- for (const t of top)
875
- out.push({ text: t, tone: 'muted' });
876
- const remaining = rows.length - top.length;
877
- if (remaining > 0)
878
- out.push({ text: `… ${remaining} more shown`, tone: 'muted' });
879
- return out;
880
- }
881
- /** `crtr node inspect artifacts` (src/commands/node-inspect-artifacts.ts):
882
- * `node: <name (id)>\n\n0 artifacts. | N artifacts:\n\n<table
883
- * cols=ref,source,ts,detail>\n\n<follow_up>`. `source` values are
884
- * colon-prefixed types (`report:final`, `report:update`, `doc`,
885
- * `roadmap`). Reuses this file's `summarizeTopCounts` for the type
886
- * breakdown. Returns null when the `node:` line, the table shape (a
887
- * non-`---` separator cell), or a missing `source` column doesn't
888
- * match — or when the parsed row count doesn't match the leading `N
889
- * artifacts:` count (this render has no pagination, so the two must
890
- * agree exactly). */
891
- function summarizeNodeInspectArtifacts(text) {
892
- const nodeMatch = /^node: (.+)$/m.exec(text);
893
- if (nodeMatch === null)
894
- return null;
895
- if (/^0 artifacts\.$/m.test(text))
896
- return [{ text: `${nodeMatch[1]} · 0 artifacts`, tone: 'muted' }];
897
- const countMatch = /^(\d+) artifacts:$/m.exec(text);
898
- if (countMatch === null)
899
- return null;
900
- const lines = text.split('\n');
901
- const headerIdx = lines.findIndex((l) => l.trim().startsWith('| ref |'));
902
- if (headerIdx === -1)
903
- return null;
904
- const header = splitTableRow(lines[headerIdx]);
905
- const separator = splitTableRow(lines[headerIdx + 1]);
906
- if (header === null || separator === null || header.length !== separator.length)
907
- return null;
908
- if (separator.some((cell) => !/^-+$/.test(cell)))
909
- return null;
910
- const sourceIdx = header.indexOf('source');
911
- if (sourceIdx === -1)
912
- return null;
913
- const typeCounts = new Map();
914
- let rowCount = 0;
915
- let j = headerIdx + 2;
916
- while (j < lines.length && lines[j].trim().startsWith('|')) {
917
- const cells = splitTableRow(lines[j]);
918
- if (cells === null || cells.length !== header.length)
919
- return null;
920
- const type = (cells[sourceIdx] ?? '').split(':')[0];
921
- typeCounts.set(type, (typeCounts.get(type) ?? 0) + 1);
922
- rowCount++;
923
- j++;
924
- }
925
- if (rowCount !== Number(countMatch[1]))
926
- return null;
927
- const digest = summarizeTopCounts(typeCounts, 5, (name, count) => `${name}s×${count}`);
928
- return [{ text: `${countMatch[1]} artifacts` }, { text: digest, tone: 'muted' }];
929
- }
930
- /** `crtr node inspect context` renders a non-TTY roster as `Context roots
931
- * (N):`, followed by one tab-delimited `label (self)\tfiles\tdir` row per
932
- * root. Collapse the filesystem paths into a count and a compact root-name
933
- * digest; a missing/malformed row is an unrecognized shape. */
934
- function summarizeNodeInspectContext(text) {
935
- const head = /^Context roots \((\d+)\):\n([\s\S]*)$/.exec(text);
936
- if (head === null)
937
- return null;
938
- const rows = head[2].split('\n');
939
- if (rows.length !== Number(head[1]) || rows.length === 0)
940
- return null;
941
- let files = 0;
942
- const labels = [];
943
- for (const row of rows) {
944
- const match = /^ {2}(.+?)(?: \(self\))?\t(\d+) files\t(.+)$/.exec(row);
945
- if (match === null)
946
- return null;
947
- labels.push(match[1]);
948
- files += Number(match[2]);
949
- }
950
- const shown = labels.slice(0, 5);
951
- const remaining = labels.length - shown.length;
952
- return [
953
- { text: `${rows.length} context root${rows.length === 1 ? '' : 's'} · ${files} files` },
954
- { text: remaining > 0 ? `${shown.join(', ')}, … ${remaining} more` : shown.join(', '), tone: 'muted' },
955
- ];
956
- }
957
- /** `crtr node inspect transcript` returns the saved Markdown title and
958
- * status/lifecycle line followed by raw message text. The collapsed card keeps
959
- * only the fixed metadata; raw text can contain section-like Markdown. */
960
- function summarizeNodeInspectTranscript(text) {
961
- const head = /^# (.+)\n\nSaved transcript · ([^·\n]+) · ([^\n]+)\n\n([\s\S]*)$/.exec(text);
962
- if (head === null)
963
- return null;
964
- const [, title, status, lifecycle, body] = head;
965
- if (body === undefined)
966
- return null;
967
- const lines = [{ text: `${title} · ${status.trim()} · ${lifecycle.trim()}` }];
968
- if (body === '' || body === '_No saved messages._\n')
969
- lines.push({ text: 'no saved messages', tone: 'muted' });
970
- return lines;
971
- }
972
- /** `crtr node fork` has one success receipt with a branching viewer clause.
973
- * Keep the new root id and viewer disposition instead of truncating the
974
- * explanatory fixed prose. */
975
- function summarizeNodeFork(text) {
976
- const match = /^Forked independent idle root (\S+)\. (Viewer opened beside the source(?: in session ([^.]+))?\.|No viewer opened because no tmux pane was available\.) It will make no model call until its first real user message\.$/.exec(text);
977
- if (match === null)
978
- return null;
979
- const [, id, viewerSentence, session] = match;
980
- const viewer = viewerSentence.startsWith('Viewer opened')
981
- ? `viewer opened${session !== undefined ? ` · ${session}` : ''}`
982
- : 'no viewer · outside tmux';
983
- return [{ text: `forked idle root ${id}`, tone: 'success' }, { text: viewer, tone: 'muted' }];
984
- }
985
- /** `crtr node worktree close` reports an already-landed fast-forward plus
986
- * best-effort cleanup. Recognize only its full success receipt; errors retain
987
- * their raw recovery guidance. */
988
- function summarizeNodeWorktreeClose(text) {
989
- const match = /^Managed worktree landed — its local base branch fast-forwarded to (\S+) at (\S+)\.(.*)$/s.exec(text);
990
- if (match === null)
991
- return null;
992
- const [, branch, sha, tail] = match;
993
- const noteAt = tail.indexOf(' Note: ');
994
- const cleanup = noteAt === -1 ? tail : tail.slice(0, noteAt);
995
- const note = noteAt === -1 ? undefined : tail.slice(noteAt + ' Note: '.length);
996
- if (note !== undefined && note.trim() === '')
997
- return null;
998
- const worktreeRemoved = /^ Worktree removed at .+\.$/.test(cleanup);
999
- if (!worktreeRemoved && cleanup !== '')
1000
- return null;
1001
- if (!worktreeRemoved && note === undefined)
1002
- return null;
1003
- return [
1004
- { text: `landed ${branch} · ${sha.slice(0, 12)}`, tone: 'success' },
1005
- { text: note === undefined ? 'worktree and branch cleaned up' : worktreeRemoved ? 'worktree removed · cleanup needs attention' : 'cleanup needs attention', tone: note === undefined ? 'muted' : 'warning' },
1006
- ];
1007
- }
1008
- /** `crtr sys daemon start` (src/commands/sys/daemon.ts:40, `spawnDaemon()` in
1009
- * src/daemon/manage.ts:89) has no custom `render`; the generic renderer
1010
- * bullets `{started:true,pid}` or `{started:false,existing_pid}`. `pid` is
1011
- * unconditionally set alongside `started:true` (manage.ts:125) — a
1012
- * true-with-no-pid shape is unrecognized, so it returns null rather than a
1013
- * pid-less success line. `existing_pid` genuinely can be absent on the
1014
- * false branch (a `readPidfile() ?? undefined` race), so that one stays
1015
- * optional. Returns null when `started` isn't a plain boolean bullet, or
1016
- * is `true` without a `pid` bullet. */
1017
- function summarizeSysDaemonStart(text) {
1018
- const m = /^- started:\s*(true|false)\s*$/m.exec(text);
1019
- if (m === null)
1020
- return null;
1021
- if (m[1] === 'true') {
1022
- const pid = /^- pid:\s*(\d+)\s*$/m.exec(text)?.[1];
1023
- if (pid === undefined)
1024
- return null;
1025
- return [{ text: `started · pid ${pid}`, tone: 'success' }];
1026
- }
1027
- const pid = /^- existing_pid:\s*(\d+)\s*$/m.exec(text)?.[1];
1028
- return [{ text: `already running${pid !== undefined ? ` · pid ${pid}` : ''}`, tone: 'muted' }];
1029
- }
1030
- /** `crtr sys daemon status` (src/commands/sys/daemon.ts:65) has no custom
1031
- * `render`; the generic renderer bullets `{running:true,pid}` or
1032
- * `{running:false}`. `pid` is unconditionally set alongside `running:true`
1033
- * (daemon.ts:70 — the true branch only fires once `readPidfile()` already
1034
- * returned a pid), so a true-with-no-pid shape is unrecognized. Returns
1035
- * null when `running` isn't a plain boolean bullet, or is `true` without a
1036
- * `pid` bullet. */
1037
- function summarizeSysDaemonStatus(text) {
1038
- const m = /^- running:\s*(true|false)\s*$/m.exec(text);
1039
- if (m === null)
1040
- return null;
1041
- if (m[1] === 'true') {
1042
- const pid = /^- pid:\s*(\d+)\s*$/m.exec(text)?.[1];
1043
- if (pid === undefined)
1044
- return null;
1045
- return [{ text: `running · pid ${pid}`, tone: 'success' }];
1046
- }
1047
- return [{ text: 'not running', tone: 'error' }];
1048
- }
1049
- /** `crtr sys daemon stop` (src/commands/sys/daemon.ts:93) has no custom
1050
- * `render`; the generic renderer bullets `{stopped:true,pid}` or
1051
- * `{stopped:false}`. `pid` is unconditionally set alongside `stopped:true`
1052
- * (daemon.ts:98 — the signaled pid is echoed back in the same return), so a
1053
- * true-with-no-pid shape is unrecognized. Returns null when `stopped` isn't
1054
- * a plain boolean bullet, or is `true` without a `pid` bullet. */
1055
- function summarizeSysDaemonStop(text) {
1056
- const m = /^- stopped:\s*(true|false)\s*$/m.exec(text);
1057
- if (m === null)
1058
- return null;
1059
- if (m[1] === 'true') {
1060
- const pid = /^- pid:\s*(\d+)\s*$/m.exec(text)?.[1];
1061
- if (pid === undefined)
1062
- return null;
1063
- return [{ text: `stopped · pid ${pid}` }];
1064
- }
1065
- return [{ text: 'was not running', tone: 'muted' }];
1066
- }
1067
- /** `crtr canvas issue new` (src/commands/canvas-issue.ts) has no custom
1068
- * `render`; the generic renderer bullets `{created:false}` (empty note,
1069
- * line 125), `{created:true,target:'github',type,title,url}` (line 137),
1070
- * or `{created:true,target:'file',type,title,path}` (line 159). `url` is
1071
- * unconditionally set on every `target:'github'` return, and `path` on
1072
- * every `target:'file'` return — so either missing (e.g. a >80-char
1073
- * value core/render.ts's `isProse` rule pushed to prose form instead of a
1074
- * bullet) is an unrecognized shape, not a success to report without the
1075
- * field. Returns null when `created` isn't a plain boolean bullet, or the
1076
- * target's required companion field isn't present as a bullet. */
1077
- function summarizeCanvasIssueNew(text) {
1078
- const createdMatch = /^- created:\s*(true|false)\s*$/m.exec(text);
1079
- if (createdMatch === null)
1080
- return null;
1081
- if (createdMatch[1] === 'false')
1082
- return [{ text: 'nothing captured', tone: 'muted' }];
1083
- const target = /^- target:\s*(\S+)\s*$/m.exec(text)?.[1];
1084
- if (target === 'github') {
1085
- const url = /^- url:\s*(\S+)\s*$/m.exec(text)?.[1];
1086
- if (url === undefined)
1087
- return null;
1088
- const num = /\/issues\/(\d+)$/.exec(url)?.[1];
1089
- return [
1090
- { text: num !== undefined ? `issue #${num}` : 'issue filed', tone: 'success' },
1091
- { text: url, tone: 'muted' },
1092
- ];
1093
- }
1094
- if (target === 'file') {
1095
- const path = /^- path:\s*(\S+)\s*$/m.exec(text)?.[1];
1096
- if (path === undefined)
1097
- return null;
1098
- return [
1099
- { text: 'note saved', tone: 'success' },
1100
- { text: basename(path), tone: 'muted' },
1101
- ];
1102
- }
1103
- return null;
1104
- }
1105
- /** Compact the immediate `crtr node new` result. */
1106
- function summarizeNodeNew(text) {
1107
- const spawned = /^Spawned "([^"]*)" \((\S+)\) — (\w+)/.exec(text);
1108
- if (spawned !== null) {
1109
- const [, name, id, status] = spawned;
1110
- return [{ text: `spawned ${name} (${id}) · ${status}`, tone: 'success' }];
1111
- }
1112
- return null;
1113
- }
1114
- /** Compact immediate node-message receipts whose guidance is command-owned,
1115
- * never user text. Other shapes fall through to generic truncation. */
1116
- function summarizeNodeMessageEngine(text) {
1117
- if (/^Delivered\.$/.test(text))
1118
- return [{ text: 'delivered', tone: 'success' }];
1119
- if (/^Delivered — the dormant target was revived to receive it\.$/.test(text)) {
1120
- return [{ text: 'delivered · revived', tone: 'success' }];
1121
- }
1122
- if (/^Delivered — held for the target's next natural cycle; not revived \(deferred\)\.$/.test(text)) {
1123
- return [{ text: 'delivered · held (deferred)', tone: 'accent' }];
1124
- }
1125
- const fresh = /^Fresh-revived (\S+) now/.exec(text);
1126
- if (fresh !== null)
1127
- return [{ text: `fresh-revived ${fresh[1]}`, tone: 'success' }];
1128
- return null;
1129
- }
1130
- /** Compact the `crtr node wait deadline` receipt (nodeWaitDeadline.render):
1131
- * `Deadline cron armed — <name> (<id>), fires <iso>.` followed by
1132
- * command-owned guidance prose. Other shapes fall through to generic
1133
- * truncation. */
1134
- function summarizeNodeWaitDeadline(text) {
1135
- const m = /^Deadline cron armed — (\S+) \((\S+)\), fires (\S+)\.\s*$/m.exec(text);
1136
- if (m === null)
1137
- return null;
1138
- const [, , id, firesAt] = m;
1139
- return [
1140
- { text: `deadline armed — fires ${firesAt}`, tone: 'accent' },
1141
- { text: id, tone: 'muted' },
1142
- ];
1143
- }
1144
- /** `crtr node yield` (`src/commands/node/lifecycle.ts`) has no custom `render`;
1145
- * the generic renderer bullets `{node_id, promoted, model?, will_refresh}`
1146
- * — none of these names are PROSE_FIELDS. `will_refresh` is documented
1147
- * "Always true" (the command has no path that returns false), so
1148
- * `- will_refresh: false` is itself an unrecognized/error shape, not an
1149
- * alternate success to report armed. Returns null unless `will_refresh` is
1150
- * exactly `true`, or `node_id` isn't present as a plain bullet. */
1151
- function summarizeNodeYield(text) {
1152
- if (!/^- will_refresh:\s*true\s*$/m.test(text))
1153
- return null;
1154
- const nodeId = /^- node_id:\s*(\S+)\s*$/m.exec(text);
1155
- if (nodeId === null)
1156
- return null;
1157
- const promoted = /^- promoted:\s*true\s*$/m.test(text);
1158
- const model = /^- model:\s*(\S+)\s*$/m.exec(text)?.[1];
1159
- const extras = [];
1160
- if (promoted)
1161
- extras.push('promoted → orchestrator');
1162
- if (model !== undefined)
1163
- extras.push(`model: ${model}`);
1164
- const lines = [{ text: 'yield armed — refreshes on next wake', tone: 'accent' }];
1165
- if (extras.length > 0)
1166
- lines.push({ text: extras.join(' · '), tone: 'muted' });
1167
- return lines;
1168
- }
1169
- /** `crtr node lifecycle close` (`nodeClose.render` in `src/commands/node/lifecycle.ts`):
1170
- * `Closed ${id} and its exclusive subtree — ${count} node(s)
1171
- * closed${spared>0 ? ", ${spared} spared..." : ''}.` on success, or a
1172
- * short failure line — left as null there, the generic fallback is fine for
1173
- * a one-liner. */
1174
- function summarizeNodeLifecycleClose(text) {
1175
- const m = /^Closed (\S+) and its exclusive subtree — (\d+) node\(s\) closed(?:, (\d+) spared)?/.exec(text);
1176
- if (m === null)
1177
- return null;
1178
- const [, id, countStr, sparedStr] = m;
1179
- const count = Number(countStr);
1180
- const descendants = count - 1;
1181
- const lines = [{ text: `closed ${id}`, tone: 'success' }];
1182
- if (descendants > 0)
1183
- lines.push({ text: `${descendants} descendant${descendants === 1 ? '' : 's'} canceled`, tone: 'warning' });
1184
- if (sparedStr !== undefined)
1185
- lines.push({ text: `${sparedStr} spared (managed elsewhere)`, tone: 'muted' });
1186
- return lines;
1187
- }
1188
- /** `crtr push final` (makeTierLeaf's shared render, src/commands/push.ts:161)
1189
- * leads with a fixed sentence ('Result recorded — node finished; ...')
1190
- * followed unconditionally by `\n\n- report: ${report_path}` (`report_path`
1191
- * is a required output field, push.ts:94). Returns null when that lead
1192
- * sentence isn't present, or the `- report:` line is missing (an
1193
- * unrecognized/error shape either way). */
1194
- function summarizePushFinal(text) {
1195
- if (!/^Result recorded — node finished/.test(text))
1196
- return null;
1197
- const report = /^- report: (.+)$/m.exec(text)?.[1];
1198
- if (report === undefined)
1199
- return null;
1200
- return [
1201
- { text: 'final pushed · node done', tone: 'success' },
1202
- { text: basename(report.trim()), tone: 'muted' },
1203
- ];
1204
- }
1205
- /** `crtr push urgent` (makeTierLeaf's shared render, src/commands/push.ts:161):
1206
- * `Urgent report fanned to ${n} subscriber(s) — they are force-woken.\n\n-
1207
- * report: ${report_path}`, with the same unconditional `- report:` line as
1208
- * push final. Returns null when the lead sentence doesn't match, or the
1209
- * `- report:` line is missing. */
1210
- function summarizePushUrgent(text) {
1211
- const m = /^Urgent report fanned to (\d+) subscriber/.exec(text);
1212
- if (m === null)
1213
- return null;
1214
- if (/^- report: (.+)$/m.exec(text) === null)
1215
- return null;
1216
- return [{ text: `urgent → ${m[1]} subscribers woken`, tone: 'warning' }];
1217
- }
1218
- // ---------------------------------------------------------------------------
1219
- // Batch C — human/memory/pkg. Shared helpers for the two output shapes
1220
- // renderResult (core/render.ts) can give the SAME logical field depending on
1221
- // string length/newlines (isProse): a short value is a `- field: value`
1222
- // bullet; a long/multiline one is prose — labeled `**field:** text` when 2+
1223
- // prose fields are present, or a bare unlabeled paragraph when it's the SOLE
1224
- // prose field (renderResult only labels when there's more than one). Several
1225
- // leaves below carry a field (a path, typically) that crosses this threshold
1226
- // depending on the user's home directory length, so both forms are handled
1227
- // rather than assumed.
1228
- // ---------------------------------------------------------------------------
1229
- /** Read a `- field: value` bullet or a `**field:** text` labeled-prose block
1230
- * for the same logical field — the two shapes renderResult can produce once
1231
- * 2+ prose fields are present (see block comment above). Does NOT recognize
1232
- * a bare unlabeled solo-prose paragraph (see soloProseAfterBullets for
1233
- * that); a leaf with exactly one long field needs that instead. */
1234
- function readFieldFlexible(text, field) {
1235
- const bullet = new RegExp(`^-\\s+${field}:\\s*(.+)$`, 'm').exec(text);
1236
- if (bullet !== null)
1237
- return bullet[1].trim();
1238
- const labeled = new RegExp(`^\\*\\*${field}:\\*\\*\\s*([\\s\\S]*?)(?=\\n\\n|$)`, 'm').exec(text);
1239
- return labeled !== null ? labeled[1].trim() : undefined;
1240
- }
1241
- /** Recover the SOLE prose field's text when it renders as a bare, unlabeled
1242
- * paragraph after the bullets block — renderResult's shape for exactly one
1243
- * long/multiline field among otherwise-short scalars (see block comment
1244
- * above). Requires at least one bullet line above it (otherwise this isn't
1245
- * the bullets-then-solo-paragraph shape at all) and bails if what follows
1246
- * looks like a labeled prose block or an array heading instead of a bare
1247
- * paragraph — never misparse a different shape as this one. */
1248
- function soloProseAfterBullets(text) {
1249
- const lines = text.split('\n');
1250
- let i = 0;
1251
- while (i < lines.length && /^- /.test(lines[i]))
1252
- i++;
1253
- if (i === 0)
1254
- return undefined;
1255
- while (i < lines.length && lines[i].trim() === '')
1256
- i++;
1257
- if (i >= lines.length)
1258
- return undefined;
1259
- if (/^\*\*\w+:\*\*/.test(lines[i]) || /^\d+ \S+:$/.test(lines[i]) || /^0 \S+\.$/.test(lines[i]))
1260
- return undefined;
1261
- const para = [];
1262
- while (i < lines.length && lines[i].trim() !== '') {
1263
- para.push(lines[i]);
1264
- i++;
1265
- }
1266
- const text2 = para.join(' ').trim();
1267
- return text2 === '' ? undefined : text2;
1268
- }
1269
- /** Find the first argv token after `skip` that isn't a flag, skipping the
1270
- * value token that follows any flag listed in `valueFlags` (a boolean flag
1271
- * with no separate value is skipped on its own via the `startsWith('--')`
1272
- * check). Generalizes firstPositionalAfter (above) for leaves that mix
1273
- * boolean and value-taking flags ahead of their positional. */
1274
- function firstPositionalArg(tokens, skip, valueFlags) {
1275
- for (let i = skip; i < tokens.length; i++) {
1276
- const t = tokens[i];
1277
- if (t.startsWith('--')) {
1278
- if (valueFlags.has(t))
1279
- i++;
1280
- continue;
1281
- }
1282
- return t;
1283
- }
1284
- return undefined;
1285
- }
1286
- /** Truncate a single line of text to `max` chars for a muted-aside teaser,
1287
- * appending an ellipsis when it was cut. */
1288
214
  function truncateLine(line, max) {
1289
215
  return line.length > max ? `${line.slice(0, max).trimEnd()}…` : line;
1290
216
  }
1291
- const ASK_TEASER_CHARS = 160;
1292
- /** `crtr human ask` (humanAsk.run, src/commands/human/prompts.ts) returns
1293
- * `{job_id, dir, follow_up}` — `job_id` is always short (a node id) so it
1294
- * always bullets; `dir`/`follow_up` cross the prose threshold depending on
1295
- * path length, so the shape is recognized by `job_id` plus the stable road-sign
1296
- * prefix `Queued for the human` (queuedFollowUp, prompts.ts — its inbox-
1297
- * affordance tail resolves per call, the prefix never varies) appearing
1298
- * anywhere in the text — never by assuming dir/follow_up's bullet vs. prose
1299
- * form. Arrival is always just durable queue state, so the card collapses to
1300
- * one posted line. The
1301
- * question text isn't in the output at all (only the bare-question invocation
1302
- * echoes it), so it's recovered from argv: the first non-flag token after
1303
- * `human ask`, skipping `--context-file`'s value — absent for a
1304
- * `--context-file` deck invocation, which still gets the road-sign line with
1305
- * no question teaser. Returns null when the road-sign is absent (an
1306
- * error/unrecognized shape). */
1307
- function summarizeHumanAsk(text, tokens) {
1308
- const jobId = /^- job_id:\s*(\S+)\s*$/m.exec(text)?.[1];
1309
- if (jobId === undefined)
1310
- return null;
1311
- if (!text.includes('Queued for the human'))
1312
- return null;
1313
- const lines = [{ text: 'ask posted → waiting on human', tone: 'accent' }];
1314
- const question = firstPositionalArg(tokens, 2, new Set(['--context-file']));
1315
- if (question !== undefined) {
1316
- const firstLine = truncateLine(question.split('\n')[0].trim(), ASK_TEASER_CHARS);
1317
- if (firstLine !== '')
1318
- lines.push({ text: firstLine, tone: 'muted' });
1319
- }
1320
- return lines;
1321
- }
1322
- /** `crtr human review new` (humanReviewNew.run,
1323
- * src/commands/human/review.ts) returns `{job_id, dir, output, review_id,
1324
- * follow_up}`. A ticket bridge id plus the daemon-owned review id and the
1325
- * stable live-review road-sign distinguish this canonical result from an
1326
- * error or another review leaf. The reviewed file is recovered from argv:
1327
- * it is the positional after `human review new`, skipping the two value
1328
- * flags. Returns null when that canonical result shape is absent. */
1329
- function summarizeHumanReviewNew(text, tokens) {
1330
- const jobId = readFieldFlexible(text, 'job_id');
1331
- const reviewId = readFieldFlexible(text, 'review_id');
1332
- if (jobId === undefined || reviewId === undefined)
1333
- return null;
1334
- if (!text.includes('The review is live for the person in the attach viewer with its companion agent.'))
1335
- return null;
1336
- const file = firstPositionalArg(tokens, 3, new Set(['--output', '--subtitle']));
1337
- const label = file !== undefined ? `review posted: ${basename(file)}` : 'review posted';
1338
- return [{ text: `${label} → live with companion`, tone: 'accent' }];
1339
- }
1340
- /** `crtr human list` (humanList.run, src/commands/human/queue.ts:283) has no
1341
- * custom `render`; the generic renderer bullets `- total: N` then an
1342
- * `N items:` table (or `0 items.`), columns including `kind` (the deck's
1343
- * own interaction kind — `decision`/`question`/etc, often empty for a plain
1344
- * freeform ask; NOT whether it was posed via `ask` vs `review`). Returns
1345
- * null when the total/items shape doesn't match, or when `total` is
1346
- * nonzero but the returned page is empty (exhausted pagination, not a
1347
- * true zero total — collapsing that to "none pending" would misreport). */
1348
- function summarizeHumanList(text) {
1349
- const total = parseIntBullet(text, 'total');
1350
- const rows = parseTableRows(text, 'items');
1351
- if (total === null || rows === null)
1352
- return null;
1353
- if (total === 0)
1354
- return [{ text: 'none pending', tone: 'success' }];
1355
- if (rows.length === 0)
1356
- return null;
1357
- const kinds = new Map();
1358
- for (const row of rows) {
1359
- const kind = row['kind'];
1360
- if (kind !== undefined && kind !== '')
1361
- kinds.set(kind, (kinds.get(kind) ?? 0) + 1);
1362
- }
1363
- const lines = [{ text: `${total} pending` }];
1364
- const kindDigest = summarizeTopCounts(kinds, 5, (k, c) => `${k}×${c}`);
1365
- if (kindDigest !== '')
1366
- lines.push({ text: kindDigest, tone: 'muted' });
1367
- return lines;
1368
- }
1369
- const DECK_PROMPT_TEASER_CHARS = 160;
1370
- /** `crtr human deck` (humanDeck.run, src/commands/human/queue.ts:319) bullets
1371
- * `id`/`interaction_count` (always short) then an `N interactions:` table
1372
- * (columns `id,kind,prompt,options,allow_freetext`). `options` is a
1373
- * cellEsc'd array of JSON-stringified `{id,label,...}` objects joined by
1374
- * `, ` (core/render.ts's scalarStr for an array of objects) — counted via
1375
- * the `"id":` occurrences each option object carries, not by splitting on
1376
- * `, ` (a label/description could itself contain a comma). Only the FIRST
1377
- * interaction is teased — decks built by `ask`/`review` always have exactly
1378
- * one; a multi-interaction context-file deck gets an `interaction_count`
1379
- * aside instead of every prompt. Returns null when the `id`/
1380
- * `interaction_count` bullets or the interactions table don't match, or
1381
- * when `allow_freetext` isn't exactly `true`/`false` (a malformed/
1382
- * truncated row must not silently default to "no freetext"). */
1383
- function summarizeHumanDeck(text) {
1384
- const id = /^- id:\s*(\S+)\s*$/m.exec(text)?.[1];
1385
- const interactionCount = parseIntBullet(text, 'interaction_count');
1386
- if (id === undefined || interactionCount === null)
1387
- return null;
1388
- const rows = parseTableRows(text, 'interactions');
1389
- if (rows === null || rows.length === 0)
1390
- return null;
1391
- const first = rows[0];
1392
- const prompt = first['prompt'];
1393
- if (prompt === undefined || prompt === '')
1394
- return null;
1395
- const optionCount = (first['options'] ?? '').match(/"id":/g)?.length ?? 0;
1396
- const allowFreetextRaw = first['allow_freetext'];
1397
- if (allowFreetextRaw !== 'true' && allowFreetextRaw !== 'false')
1398
- return null;
1399
- const allowFreetext = allowFreetextRaw === 'true';
1400
- const lines = [{ text: truncateLine(prompt, DECK_PROMPT_TEASER_CHARS) }];
1401
- const parts = [];
1402
- if (optionCount > 0)
1403
- parts.push(`${optionCount} option${optionCount === 1 ? '' : 's'}`);
1404
- if (allowFreetext)
1405
- parts.push('freetext');
1406
- if (interactionCount > 1)
1407
- parts.push(`${interactionCount} interactions`);
1408
- if (parts.length > 0)
1409
- lines.push({ text: parts.join(' · '), tone: 'muted' });
1410
- return lines;
1411
- }
1412
- /** Notify has a durable queue receipt but its interaction directory becomes
1413
- * prose on long homes. The title is the human-visible asset, recovered from
1414
- * argv rather than exposing filesystem plumbing in the collapsed card. */
1415
- function summarizeHumanNotify(text, tokens) {
1416
- if (!/^- queued:\s*true\s*$/m.test(text))
1417
- return null;
1418
- const dir = readFieldFlexible(text, 'dir') ?? soloProseAfterBullets(text);
1419
- const title = firstPositionalArg(tokens, 2, new Set(['--body']));
1420
- if (dir === undefined || dir === '' || title === undefined || title.trim() === '')
1421
- return null;
1422
- return [
1423
- { text: 'notification queued', tone: 'success' },
1424
- { text: truncateLine(title.trim(), ASK_TEASER_CHARS), tone: 'muted' },
1425
- ];
1426
- }
1427
- /** Show either returns a short pane id or one of two intentional no-display
1428
- * reasons. Any other prose is an unrecognized/error shape and stays raw. */
1429
- function summarizeHumanShow(text) {
1430
- const pane = /^- pane_id:\s*(\S+)\s*$/m.exec(text)?.[1];
1431
- if (pane !== undefined) {
1432
- if (text.trim() !== `- pane_id: ${pane}`)
1433
- return null;
1434
- return [
1435
- { text: 'display opened', tone: 'success' },
1436
- { text: `pane ${pane}`, tone: 'muted' },
1437
- ];
1438
- }
1439
- const reason = text.trim();
1440
- if (reason !== 'not in tmux' && reason !== 'renderer unavailable (termrender/uv missing)')
1441
- return null;
1442
- return [
1443
- { text: 'not displayed', tone: 'warning' },
1444
- { text: reason, tone: 'muted' },
1445
- ];
1446
- }
1447
- /** Cancel has a bespoke but verbose receipt; preserve its real success/no-op
1448
- * distinction while reducing the queue action to outcome plus interaction id. */
1449
- function summarizeHumanCancel(text) {
1450
- const canceled = /^Canceled human interaction (\S+) — it left the human inbox and subscribers were notified no answer is coming\.$/.exec(text)?.[1];
1451
- if (canceled !== undefined) {
1452
- return [
1453
- { text: `canceled ${canceled}`, tone: 'success' },
1454
- { text: 'human inbox cleared', tone: 'muted' },
1455
- ];
1456
- }
1457
- const alreadyResolved = /^Nothing to cancel for (\S+) — already_resolved\.$/.exec(text)?.[1];
1458
- return alreadyResolved === undefined ? null : [{ text: `already resolved · ${alreadyResolved}`, tone: 'muted' }];
1459
- }
1460
- /** Resolve uses bullets for a winning result; a losing race adds a sole prose
1461
- * reason. Keep the finalized outcome distinct from claimed/already-resolved. */
1462
- function summarizeHumanResolve(text) {
1463
- const resolved = /^- resolved:\s*(true|false)\s*$/m.exec(text)?.[1];
1464
- const jobId = /^- job_id:\s*(\S+)\s*$/m.exec(text)?.[1];
1465
- if (resolved === undefined || jobId === undefined)
1466
- return null;
1467
- if (resolved === 'true')
1468
- return [{ text: `resolved ${jobId}`, tone: 'success' }];
1469
- const reason = soloProseAfterBullets(text);
1470
- if (reason !== 'already_resolved' && reason !== 'claimed')
1471
- return null;
1472
- return [
1473
- { text: `not resolved · ${jobId}`, tone: 'warning' },
1474
- { text: reason === 'claimed' ? 'another resolver holds the claim' : 'already resolved', tone: 'muted' },
1475
- ];
1476
- }
1477
- /** `crtr memory list` (listLeaf.run, src/commands/memory/list.ts) has no
1478
- * custom `render`; the generic renderer bullets `- total: N` (next_cursor
1479
- * omitted entirely when null — core/render.ts skips null/undefined fields)
1480
- * then the follow_up prose then an `N items:` table (columns
1481
- * `name,short_form,kind,scope,is_dir`, plus `path` only with --paths;
1482
- * `title` was dropped from this leaf's output). `total` is the true corpus
1483
- * count across every page; the kind/scope digest below is computed from the
1484
- * RETURNED PAGE only — with the default 50-per-page limit this matches
1485
- * `total` in practice, but a narrower --limit would undercount against the
1486
- * true total (the same page-vs-total caveat other paginated summaries in
1487
- * this file carry). Returns null when the total/items shape doesn't match,
1488
- * or when `total` is nonzero but the returned page is empty (exhausted
1489
- * pagination, not a true zero total). */
1490
- function summarizeMemoryList(text) {
1491
- const total = parseIntBullet(text, 'total');
1492
- const rows = parseTableRows(text, 'items');
1493
- if (total === null || rows === null)
1494
- return null;
1495
- if (total === 0)
1496
- return [{ text: '0 docs', tone: 'muted' }];
1497
- if (rows.length === 0)
1498
- return null;
1499
- const kinds = new Map();
1500
- const scopes = new Map();
1501
- for (const row of rows) {
1502
- const kind = row['kind'];
1503
- const scope = row['scope'];
1504
- if (kind === undefined || kind === '' || scope === undefined || scope === '')
1505
- return null;
1506
- kinds.set(kind, (kinds.get(kind) ?? 0) + 1);
1507
- scopes.set(scope, (scopes.get(scope) ?? 0) + 1);
1508
- }
1509
- const kindDigest = summarizeTopCounts(kinds, 5, (k, c) => `${k}×${c}`);
1510
- const scopeDigest = summarizeTopCounts(scopes, 5, (s, c) => `${s}×${c}`);
1511
- if (kindDigest === '' || scopeDigest === '')
1512
- return null;
1513
- return [
1514
- { text: `${total} docs` },
1515
- { text: `${kindDigest} / ${scopeDigest}`, tone: 'muted' },
1516
- ];
1517
- }
1518
- const MEMORY_FIND_NAME_CAP = 5;
1519
- /** `crtr memory find` bullets `- query: <query>` then prose follow_up, then
1520
- * either `0 hits.` or an `N hits:` table. Ranked rows have `name` and `score`;
1521
- * grep rows have `name`, `line`, and `text`. Grep is deliberately rejected
1522
- * (null, generic fallback) so the matching text remains visible. Results are
1523
- * paginated now (--cursor replaces the old --all; next_cursor renders as
1524
- * either a short bullet or, past ~80 chars, labeled/unlabeled prose — this
1525
- * summarizer never reads next_cursor, so neither shape affects it). Returns
1526
- * null when the query bullet or ranked table shape does not match. */
1527
- function summarizeMemoryFind(text) {
1528
- const query = /^- query:\s*(.+)$/m.exec(text)?.[1]?.trim();
1529
- if (query === undefined)
1530
- return null;
1531
- if (/^0 hits\.$/m.test(text))
1532
- return [{ text: `0 hits for "${query}"`, tone: 'muted' }];
1533
- const rows = parseTableRows(text, 'hits');
1534
- if (rows === null || rows.length === 0)
1535
- return null;
1536
- if (rows.some((r) => r['score'] === undefined || r['score'] === ''))
1537
- return null;
1538
- const names = rows.map((r) => r['name']);
1539
- if (names.some((n) => n === undefined || n === ''))
1540
- return null;
1541
- const shown = names.slice(0, MEMORY_FIND_NAME_CAP);
1542
- const remaining = names.length - shown.length;
1543
- const namesLine = remaining > 0 ? `${shown.join(', ')}, … ${remaining} more` : shown.join(', ');
1544
- return [
1545
- { text: `${rows.length} hit${rows.length === 1 ? '' : 's'} for "${query}"` },
1546
- { text: namesLine, tone: 'muted' },
1547
- ];
1548
- }
1549
- /** `crtr memory write` confirms the invocation's selected frontmatter as an
1550
- * ordered `{key,value}` table after its scalar identity fields and prose.
1551
- * The collapsed card keeps the outcome first, then formats those choices as
1552
- * aligned YAML-like key/value rows. Inherited update fields are intentionally
1553
- * absent because the command result only returns options selected now.
1554
- * Returns null when the identity or frontmatter table is unrecognized. */
1555
- function summarizeMemoryWrite(text) {
1556
- const name = /^- name:\s*(\S+)\s*$/m.exec(text)?.[1];
1557
- const createdMatch = /^- created:\s*(true|false)\s*$/m.exec(text);
1558
- const frontmatter = parseTableRows(text, 'frontmatter');
1559
- if (name === undefined || createdMatch === null || frontmatter === null || frontmatter.length === 0)
1560
- return null;
1561
- if (frontmatter.some((row) => row['key'] === undefined || row['key'] === '' || row['value'] === undefined))
1562
- return null;
1563
- const created = createdMatch[1] === 'true';
1564
- return [
1565
- { text: `${created ? 'wrote' : 'updated'} ${name}`, tone: 'success' },
1566
- { text: '' },
1567
- ...keyValueLines(frontmatter.map((row) => [row['key'], row['value']])),
1568
- ];
1569
- }
1570
- /** `crtr node config` renders one outcome line containing only fields changed
1571
- * by this call (`Configured <id> — key=value, ...`). Turn that dense receipt
1572
- * into the shared mutation-card grammar: outcome first, then aligned changed
1573
- * values. */
1574
- function summarizeNodeConfig(text) {
1575
- const match = /^Configured\s+(\S+)\s+—\s+(.+)$/m.exec(text);
1576
- if (match === null)
1577
- return null;
1578
- const entries = [...match[2].matchAll(/(?:^|, )(model|lifecycle|kind|mode|name)=([^,]*(?:,(?! (?:model|lifecycle|kind|mode|name)=)[^,]*)*)/g)]
1579
- .map((item) => [item[1], item[2]]);
1580
- if (entries.length === 0)
1581
- return null;
1582
- return [
1583
- { text: `configured ${match[1]}`, tone: 'success' },
1584
- { text: '' },
1585
- ...keyValueLines(entries),
1586
- ];
1587
- }
1588
- /** Compact an inspection result into a primary state line plus a bounded set
1589
- * of muted facts. Profile and config reads both use this
1590
- * rather than each inventing a bespoke card grammar. */
1591
- const INSPECTION_DETAIL_CAP = 5;
1592
- function inspectionGlance(head, details) {
1593
- const shown = details.slice(0, INSPECTION_DETAIL_CAP);
1594
- const lines = [head, ...shown.map((text) => ({ text, tone: 'muted' }))];
1595
- const remaining = details.length - shown.length;
1596
- if (remaining > 0)
1597
- lines.push({ text: `… ${remaining} more`, tone: 'muted' });
1598
- return lines;
1599
- }
1600
- /** `crtr profile list` is JSONL rather than the generic markdown renderer:
1601
- * one profile record per line followed by exactly one follow_up record.
1602
- * Validate every record so an error or unrelated JSON stream stays raw. */
1603
- function summarizeProfileList(text) {
1604
- const lines = text.split('\n').filter((line) => line !== '');
1605
- if (lines.length === 0)
1606
- return null;
1607
- const records = [];
1608
- try {
1609
- for (const line of lines)
1610
- records.push(JSON.parse(line));
1611
- }
1612
- catch {
1613
- return null;
1614
- }
1615
- const followUp = records.pop();
1616
- if (followUp === null || typeof followUp !== 'object' || typeof followUp['follow_up'] !== 'string')
1617
- return null;
1618
- const profiles = [];
1619
- for (const record of records) {
1620
- if (record === null || typeof record !== 'object')
1621
- return null;
1622
- const row = record;
1623
- if (typeof row['profile_id'] !== 'string' || typeof row['name'] !== 'string' || !Number.isInteger(row['projects_count']) || row['projects_count'] < 0)
1624
- return null;
1625
- if (row['last_used_at'] !== null && typeof row['last_used_at'] !== 'string')
1626
- return null;
1627
- profiles.push({ name: row['name'], projects: row['projects_count'] });
1628
- }
1629
- return inspectionGlance({ text: `${profiles.length} profile${profiles.length === 1 ? '' : 's'}` }, profiles.map((profile) => `${profile.name} · ${profile.projects} project${profile.projects === 1 ? '' : 's'}`));
1630
- }
1631
- /** Parse a scalar-array block from renderResult (`N name:` followed by `-`
1632
- * rows, or `0 name.`). Scalar arrays are emitted after every bullet/prose
1633
- * field, so any non-blank trailing output means this is not the exact shape. */
1634
- function parseScalarList(text, name) {
1635
- const lines = text.split('\n');
1636
- for (let i = 0; i < lines.length; i++) {
1637
- if (lines[i].trim() === `0 ${name}.`) {
1638
- return lines.slice(i + 1).every((line) => line.trim() === '') ? [] : null;
1639
- }
1640
- const heading = new RegExp(`^(\\d+) ${name}:$`).exec(lines[i].trim());
1641
- if (heading === null)
1642
- continue;
1643
- const expected = Number(heading[1]);
1644
- const values = [];
1645
- let j = i + 1;
1646
- while (j < lines.length && /^- (.+)$/.test(lines[j])) {
1647
- values.push(/^- (.+)$/.exec(lines[j])[1]);
1648
- j++;
1649
- }
1650
- if (values.length !== expected || !lines.slice(j).every((line) => line.trim() === ''))
1651
- return null;
1652
- return values;
1653
- }
1654
- return null;
1655
- }
1656
- function summarizeProfileShow(text) {
1657
- const id = /^- profile_id:\s*(\S+)\s*$/m.exec(text)?.[1];
1658
- const name = readFieldFlexible(text, 'name');
1659
- const configExists = /^- config_exists:\s*(true|false)\s*$/m.exec(text)?.[1];
1660
- const projects = parseScalarList(text, 'projects');
1661
- if (id === undefined || name === undefined || name === '' || configExists === undefined || projects === null)
1662
- return null;
1663
- return inspectionGlance({ text: `${name} · ${projects.length} project${projects.length === 1 ? '' : 's'}` }, [`${id} · config ${configExists === 'true' ? 'present' : 'absent'}`, ...projects.map(basename)]);
1664
- }
1665
- function summarizeProfileDefaultShow(text) {
1666
- const dir = readFieldFlexible(text, 'dir');
1667
- const pinned = /^- pinned:\s*(true|false)\s*$/m.exec(text)?.[1];
1668
- if (dir === undefined || pinned === undefined)
1669
- return null;
1670
- if (pinned === 'false')
1671
- return inspectionGlance({ text: 'no default profile', tone: 'muted' }, [basename(dir)]);
1672
- const id = /^- profile_id:\s*(\S+)\s*$/m.exec(text)?.[1];
1673
- const name = readFieldFlexible(text, 'name');
1674
- const stale = /^- stale:\s*true\s*$/m.test(text);
1675
- if (id === undefined || (name === undefined && !stale) || (name !== undefined && stale))
1676
- return null;
1677
- return inspectionGlance({ text: stale ? 'stale default profile' : `default: ${name}`, tone: stale ? 'warning' : 'accent' }, [id, basename(dir)]);
1678
- }
1679
- function summarizeProfileEnvList(text) {
1680
- const id = /^- profile_id:\s*(\S+)\s*$/m.exec(text)?.[1];
1681
- const count = parseIntBullet(text, 'count');
1682
- const names = parseScalarList(text, 'names');
1683
- if (id === undefined || count === null || names === null || names.length !== count)
1684
- return null;
1685
- return inspectionGlance({ text: `${count} env var${count === 1 ? '' : 's'}`, tone: count === 0 ? 'muted' : 'plain' }, [id, ...names]);
1686
- }
1687
- function summarizeCanvasConfigList(text) {
1688
- const rows = text.split('\n').filter((line) => line.trim() !== '');
1689
- if (rows.length < 2)
1690
- return null;
1691
- const details = [];
1692
- for (const row of rows) {
1693
- const match = /^(.+?) — .+ \((token \S+|no token stored)\)$/.exec(row);
1694
- if (match === null)
1695
- return null;
1696
- details.push(`${match[1]} · ${match[2] === 'no token stored' ? 'token missing' : 'token stored'}`);
1697
- }
1698
- return inspectionGlance({ text: `${rows.length} remote canvas targets` }, details);
1699
- }
1700
- /** `sys config get` only needs a card when the value itself exceeds generic
1701
- * truncation (whole objects such as model ladders/keybindings). */
1702
- function summarizeSysConfigGet(text) {
1703
- const key = /^- key:\s*(.+)$/m.exec(text)?.[1]?.trim();
1704
- const scope = /^- scope:\s*(\S+)\s*$/m.exec(text)?.[1];
1705
- const value = readFieldFlexible(text, 'value') ?? soloProseAfterBullets(text);
1706
- if (key === undefined || key === '' || scope === undefined || value === undefined || value.length <= 100)
1707
- return null;
1708
- return inspectionGlance({ text: `${key} · ${scope}` }, [truncateLine(value, 160)]);
1709
- }
1710
- function summarizeSysConfigPath(text) {
1711
- const rows = parseTableRows(text, 'paths');
1712
- if (rows === null || rows.length < 2)
1713
- return null;
1714
- const details = [];
1715
- for (const row of rows) {
1716
- const scope = row['scope'];
1717
- const path = row['path'];
1718
- if (scope === undefined || scope === '' || path === undefined || path === '')
1719
- return null;
1720
- details.push(`${scope} · ${truncateLine(path, 120)}`);
1721
- }
1722
- return inspectionGlance({ text: `${rows.length} config paths` }, details);
1723
- }
1724
- /** `crtr sys config set` uses the generic scalar result shape. The config key
1725
- * itself is the meaningful changed field; scope stays as a second aligned
1726
- * discriminator instead of repeating raw output bullets. */
1727
- function summarizeSysConfigSet(text) {
1728
- const key = /^- key:\s*(.+)$/m.exec(text)?.[1]?.trim();
1729
- const value = /^- value:\s*(.+)$/m.exec(text)?.[1]?.trim();
1730
- const scope = /^- scope:\s*(\S+)\s*$/m.exec(text)?.[1];
1731
- if (key === undefined || value === undefined || scope === undefined)
1732
- return null;
1733
- return [
1734
- { text: `updated config`, tone: 'success' },
1735
- { text: '' },
1736
- ...keyValueLines([[key, value], ['scope', scope]]),
1737
- ];
1738
- }
1739
- /** Remote-canvas mutations already emit terse prose. Normalize them into the
1740
- * same outcome/changed-values grammar without exposing the relay token. */
1741
- function summarizeCanvasConfigAdd(text) {
1742
- const match = /^Registered remote canvas target '([^']+)' → (.+) \(token stored\)\.$/m.exec(text);
1743
- if (match === null)
1744
- return null;
1745
- return [
1746
- { text: `registered ${match[1]}`, tone: 'success' },
1747
- { text: '' },
1748
- ...keyValueLines([['preview', match[2]], ['relay-token', 'stored']]),
1749
- ];
1750
- }
1751
- function summarizeCanvasConfigRemove(text) {
1752
- const removed = /^Removed remote canvas target '([^']+)'\.$/m.exec(text)?.[1];
1753
- return removed === undefined ? null : [{ text: `removed ${removed}`, tone: 'success' }];
1754
- }
1755
- function summarizeCanvasUse(text) {
1756
- const active = /^Active canvas:\s+(.+)\.$/m.exec(text)?.[1];
1757
- return active === undefined ? null : [{ text: `active canvas: ${active}`, tone: 'accent' }];
1758
- }
1759
- /** Remaining node mutations use a mix of terse prose and generic bullets.
1760
- * Normalize their receipts without repeating guidance or filesystem paths. */
1761
- function summarizeNodeSubscribe(text) {
1762
- const match = /^Subscribed\s+(\S+)\s+→\s+(\S+)\s+\((active|passive)\)\.$/m.exec(text);
1763
- if (match === null)
1764
- return null;
1765
- return [
1766
- { text: 'subscribed', tone: 'success' },
1767
- { text: '' },
1768
- ...keyValueLines([['subscriber', match[1]], ['publisher', match[2]], ['mode', match[3]]]),
1769
- ];
1770
- }
1771
- function summarizeNodeUnsubscribe(text) {
1772
- const match = /^Unsubscribed\s+(\S+)\s+from\s+(\S+)\.$/m.exec(text);
1773
- if (match === null)
1774
- return null;
1775
- return [
1776
- { text: 'unsubscribed', tone: 'success' },
1777
- { text: '' },
1778
- ...keyValueLines([['subscriber', match[1]], ['publisher', match[2]]]),
1779
- ];
1780
- }
1781
- function summarizeNodePromote(text) {
1782
- const field = (name) => new RegExp(`^-\\s+${name}:\\s*(.+)$`, 'm').exec(text)?.[1]?.trim();
1783
- const node = field('node_id');
1784
- const kind = field('kind');
1785
- const mode = field('mode');
1786
- const lifecycle = field('lifecycle');
1787
- const roadmap = field('roadmap_written');
1788
- if (node === undefined || kind === undefined || mode === undefined || lifecycle === undefined || roadmap === undefined)
1789
- return null;
1790
- const entries = [['kind', kind], ['mode', mode], ['lifecycle', lifecycle]];
1791
- const model = field('model');
1792
- if (model !== undefined)
1793
- entries.push(['model', model]);
1794
- entries.push(['roadmap', roadmap === 'true' ? 'seeded' : 'kept']);
1795
- return [{ text: `promoted ${node}`, tone: 'success' }, { text: '' }, ...keyValueLines(entries)];
1796
- }
1797
- function summarizeNodeDemote(text) {
1798
- const match = /^Demoted\s+(\S+)\s+—\s+lifecycle now\s+(\S+)\s+\(in place\)(, viewer closed \(broker still running off-screen\))?\.$/m.exec(text);
1799
- if (match === null)
1800
- return null;
1801
- return [
1802
- { text: `demoted ${match[1]}`, tone: 'success' },
1803
- { text: '' },
1804
- ...keyValueLines([['lifecycle', match[2]], ['viewer', match[3] === undefined ? 'attached' : 'detached']]),
1805
- ];
1806
- }
1807
- function summarizeNodeRecycle(text) {
1808
- const node = /^Recycled the pane — finished (\S+)\.$/m.exec(text)?.[1];
1809
- if (node === undefined)
1810
- return null;
1811
- const finalized = /^- finalized:\s*(true|false)$/m.exec(text)?.[1];
1812
- if (finalized === undefined)
1813
- return null;
1814
- const entries = [['finalized', finalized]];
1815
- const delivered = /^- delivered:\s*(\d+)$/m.exec(text)?.[1];
1816
- const root = /^- new root:\s*(\S+)$/m.exec(text)?.[1];
1817
- if (delivered !== undefined)
1818
- entries.push(['delivered', delivered]);
1819
- if (root !== undefined)
1820
- entries.push(['new root', root]);
1821
- return [{ text: `recycled ${node}`, tone: 'success' }, { text: '' }, ...keyValueLines(entries)];
1822
- }
1823
- /** Generic plugin and marketplace mutation results become outcome-first cards;
1824
- * paths stay in the expanded raw output rather than dominating the receipt. */
1825
- function summarizePkgPluginToggle(text) {
1826
- const name = /^- name:\s*(\S+)\s*$/m.exec(text)?.[1];
1827
- const scope = /^- scope:\s*(\S+)\s*$/m.exec(text)?.[1];
1828
- const enabled = /^- enabled:\s*(true|false)\s*$/m.exec(text)?.[1];
1829
- if (name === undefined || scope === undefined || enabled === undefined)
1830
- return null;
1831
- return [
1832
- { text: `${enabled === 'true' ? 'enabled' : 'disabled'} ${name}`, tone: 'success' },
1833
- { text: '' },
1834
- ...keyValueLines([['scope', scope]]),
1835
- ];
1836
- }
1837
- function summarizePkgPluginRemove(text, tokens) {
1838
- if (!/^- removed:\s*true\s*$/m.test(text))
1839
- return null;
1840
- const name = tokens[3];
1841
- const scopes = /^\d+ scopes:\s*\n((?:- .+\n?)*)/m.exec(text)?.[1]
1842
- ?.split('\n').map((line) => line.replace(/^-\s*/, '').trim()).filter(Boolean);
1843
- if (name === undefined || scopes === undefined || scopes.length === 0)
1844
- return null;
1845
- return [{ text: `removed ${name}`, tone: 'success' }, { text: '' }, ...keyValueLines([['scopes', scopes.join(', ')]])];
1846
- }
1847
- function summarizePkgMarketAdd(text) {
1848
- const name = /^- name:\s*(\S+)\s*$/m.exec(text)?.[1];
1849
- const scope = /^- scope:\s*(\S+)\s*$/m.exec(text)?.[1];
1850
- if (name === undefined || scope === undefined)
1851
- return null;
1852
- return [{ text: `added marketplace ${name}`, tone: 'success' }, { text: '' }, ...keyValueLines([['scope', scope]])];
1853
- }
1854
- function summarizePkgMarketRemove(text) {
1855
- const name = /^- name:\s*(\S+)\s*$/m.exec(text)?.[1];
1856
- if (name === undefined || !/^- removed:\s*true\s*$/m.test(text))
1857
- return null;
1858
- return [{ text: `removed marketplace ${name}`, tone: 'success' }];
1859
- }
1860
- /** `crtr pkg plugin install` (pluginInstall.run, src/commands/pkg/
1861
- * plugin-manage.ts:418) has no custom `render`; the generic renderer
1862
- * bullets `name`, `scope`, and optional `transport`, then `path` — almost
1863
- * always long enough to cross the prose threshold, and since it is the sole
1864
- * prose field it renders as a bare unlabeled paragraph (see
1865
- * soloProseAfterBullets). Returns null when `name`/`scope` aren't present as
1866
- * bullets — an error/unrecognized shape (path is a muted aside, not required
1867
- * for a recognized digest). */
1868
- function summarizePkgPluginInstall(text) {
1869
- const name = /^- name:\s*(\S+)\s*$/m.exec(text)?.[1];
1870
- const scope = /^- scope:\s*(\S+)\s*$/m.exec(text)?.[1];
1871
- const transport = /^- transport:\s*(exec|http)\s*$/m.exec(text)?.[1];
1872
- if (name === undefined || scope === undefined)
1873
- return null;
1874
- const path = readFieldFlexible(text, 'path') ?? soloProseAfterBullets(text);
1875
- return [
1876
- { text: `installed ${name}${transport !== undefined ? ` · ${transport}` : ''}`, tone: 'success' },
1877
- { text: path !== undefined ? `${scope} · ${path}` : scope, tone: 'muted' },
1878
- ];
1879
- }
1880
- /** `crtr pkg plugin update` (pluginUpdate.run, src/commands/pkg/
1881
- * plugin-manage.ts:571) has no custom `render`; the generic renderer emits
1882
- * an `N updated:` table. HTTP rows carry `transport=http` and no sha because
1883
- * they replace commands.json after refetch; git rows retain their optional
1884
- * `sha`. Returns null when the table doesn't match this shape or an `updated`
1885
- * cell is not exactly `true`/`false`. */
1886
- function summarizePkgPluginUpdate(text) {
1887
- const rows = parseTableRows(text, 'updated');
1888
- if (rows === null)
1889
- return null;
1890
- if (rows.length === 0)
1891
- return [{ text: 'nothing to update', tone: 'muted' }];
1892
- if (rows.length === 1) {
1893
- const row = rows[0];
1894
- const name = row['name'];
1895
- const updated = row['updated'];
1896
- const transport = row['transport'];
1897
- if (name === undefined || (updated !== 'true' && updated !== 'false') || (transport !== undefined && transport !== '' && transport !== 'exec' && transport !== 'http'))
1898
- return null;
1899
- const sha = (row['sha'] ?? '').slice(0, 7);
1900
- const lines = [
1901
- updated === 'true' ? { text: `${transport === 'http' ? 'refreshed' : 'updated'} ${name}`, tone: 'success' } : { text: `${name} already current`, tone: 'muted' },
1902
- ];
1903
- if (transport !== undefined && transport !== '')
1904
- lines.push({ text: transport, tone: 'muted' });
1905
- if (sha !== '')
1906
- lines.push({ text: sha, tone: 'muted' });
1907
- return lines;
1908
- }
1909
- let updatedCount = 0;
1910
- let httpCount = 0;
1911
- for (const row of rows) {
1912
- const updated = row['updated'];
1913
- const transport = row['transport'];
1914
- if (updated !== 'true' && updated !== 'false')
1915
- return null;
1916
- if (transport !== undefined && transport !== '' && transport !== 'exec' && transport !== 'http')
1917
- return null;
1918
- if (updated === 'true')
1919
- updatedCount++;
1920
- if (transport === 'http')
1921
- httpCount++;
1922
- }
1923
- const current = rows.length - updatedCount;
1924
- return [{ text: `${updatedCount} updated · ${current} already current${httpCount > 0 ? ` · ${httpCount} HTTP` : ''}` }];
1925
- }
1926
- const PKG_LIST_NAME_CAP = 8;
1927
- /** `crtr pkg plugin list` (pluginList.run, src/commands/pkg/
1928
- * plugin-inspect.ts:14) bullets `- total: N` then an `N items:` table
1929
- * (columns `name,scope,version,enabled,transport,source_marketplace,path`).
1930
- * `enabled`/name counts are read from the RETURNED page only — with the
1931
- * default 50-per-page limit this matches `total` in practice, but a
1932
- * narrower `--limit` would undercount enabled/shown names against the
1933
- * true total (the same page-vs-total caveat other paginated summaries in
1934
- * this file carry). Returns null when the total/items shape doesn't
1935
- * match, or when `total` is nonzero but the returned page is empty
1936
- * (exhausted pagination, not a true zero total). */
1937
- function summarizePkgPluginList(text) {
1938
- const total = parseIntBullet(text, 'total');
1939
- const rows = parseTableRows(text, 'items');
1940
- if (total === null || rows === null)
1941
- return null;
1942
- if (total === 0)
1943
- return [{ text: '0 plugins', tone: 'muted' }];
1944
- if (rows.length === 0)
1945
- return null;
1946
- let enabledCount = 0;
1947
- const names = [];
1948
- for (const row of rows) {
1949
- const name = row['name'];
1950
- const enabled = row['enabled'];
1951
- const transport = row['transport'];
1952
- if (name === undefined || name === '' || (enabled !== 'true' && enabled !== 'false') || (transport !== 'exec' && transport !== 'http' && transport !== ''))
1953
- return null;
1954
- if (enabled === 'true')
1955
- enabledCount++;
1956
- names.push(`${name}${transport === '' ? '' : ` (${transport})`}`);
1957
- }
1958
- const shown = names.slice(0, PKG_LIST_NAME_CAP);
1959
- const remaining = names.length - shown.length;
1960
- const namesLine = remaining > 0 ? `${shown.join(', ')}, … ${remaining} more` : shown.join(', ');
1961
- return [
1962
- { text: `${total} plugins · ${enabledCount} enabled` },
1963
- { text: namesLine, tone: 'muted' },
1964
- ];
1965
- }
1966
- /** `crtr pkg plugin show` (pluginShow.run, src/commands/pkg/
1967
- * plugin-inspect.ts:88) bullets `name`/`scope`/`path`/`enabled` (path is
1968
- * usually short here — a plugin dir, not a nested interaction path — so
1969
- * stays a bullet) then `manifest` — an OBJECT, so core/render.ts's
1970
- * scalarStr always JSON.stringifies it to a bullet regardless of length
1971
- * (isProse only ever triggers for strings) — then an `N docs:` table (or
1972
- * `0 docs.`). `version` and `transport.kind` are read from the parsed
1973
- * manifest JSON, not top-level fields. Returns null when `name`/`enabled`/
1974
- * `manifest` don't match, `manifest` isn't valid JSON, or the docs count
1975
- * line is missing. */
1976
- function summarizePkgPluginShow(text) {
1977
- const name = /^- name:\s*(\S+)\s*$/m.exec(text)?.[1];
1978
- const enabledMatch = /^- enabled:\s*(true|false)\s*$/m.exec(text);
1979
- const manifestMatch = /^- manifest:\s*(\{.*\})\s*$/m.exec(text);
1980
- if (name === undefined || enabledMatch === null || manifestMatch === null)
1981
- return null;
1982
- let manifest;
1983
- try {
1984
- manifest = JSON.parse(manifestMatch[1]);
1985
- }
1986
- catch {
1987
- return null;
1988
- }
1989
- const version = manifest !== null && typeof manifest === 'object' && 'version' in manifest && typeof manifest.version === 'string'
1990
- ? manifest.version
1991
- : undefined;
1992
- const transport = manifest !== null && typeof manifest === 'object' && 'transport' in manifest && manifest.transport !== null && typeof manifest.transport === 'object' && ['exec', 'http'].includes(manifest.transport.kind)
1993
- ? manifest.transport.kind
1994
- : undefined;
1995
- const docsCount = /^0 docs\.$/m.test(text) ? 0 : Number(/^(\d+) docs:$/m.exec(text)?.[1] ?? NaN);
1996
- if (Number.isNaN(docsCount))
1997
- return null;
1998
- const enabled = enabledMatch[1] === 'true';
1999
- return [
2000
- { text: `${name}${version !== undefined ? `@${version}` : ''}${transport !== undefined ? ` · ${transport}` : ''} · ${enabled ? 'enabled' : 'disabled'}`, tone: enabled ? 'success' : 'muted' },
2001
- { text: `${docsCount} substrate doc${docsCount === 1 ? '' : 's'}`, tone: 'muted' },
2002
- ];
2003
- }
2004
- /** `crtr pkg market list` (marketList.run, src/commands/pkg/
2005
- * market-inspect.ts:14) bullets `- total: N` then an `N items:` table
2006
- * (columns `name,scope,url,ref,path`). Returns null when the total/items
2007
- * shape doesn't match, or when `total` is nonzero but the returned page
2008
- * is empty (exhausted pagination, not a true zero total). */
2009
- function summarizePkgMarketList(text) {
2010
- const total = parseIntBullet(text, 'total');
2011
- const rows = parseTableRows(text, 'items');
2012
- if (total === null || rows === null)
2013
- return null;
2014
- if (total === 0)
2015
- return [{ text: '0 marketplaces', tone: 'muted' }];
2016
- if (rows.length === 0)
2017
- return null;
2018
- const names = rows.map((r) => r['name']);
2019
- if (names.some((n) => n === undefined || n === ''))
2020
- return null;
2021
- return [
2022
- { text: `${total} marketplaces` },
2023
- { text: names.join(', '), tone: 'muted' },
2024
- ];
2025
- }
2026
- const MARKET_BROWSE_NAME_CAP = 8;
2027
- /** `crtr pkg market browse` (marketBrowse.run, src/commands/pkg/
2028
- * market-inspect.ts:77) bullets `marketplace`/`total` then an `N items:`
2029
- * table (columns include `name,ref,source,version,description,keywords,
2030
- * installed,installed_scope`). Returns null when the marketplace/total
2031
- * bullets or the items table don't match, or when `total` is nonzero but
2032
- * the returned page is empty (exhausted pagination, not a true zero
2033
- * total). */
2034
- function summarizePkgMarketBrowse(text) {
2035
- const market = /^- marketplace:\s*(\S+)\s*$/m.exec(text)?.[1];
2036
- const total = parseIntBullet(text, 'total');
2037
- const rows = parseTableRows(text, 'items');
2038
- if (market === undefined || total === null || rows === null)
2039
- return null;
2040
- if (total === 0)
2041
- return [{ text: `0 plugins in ${market}`, tone: 'muted' }];
2042
- if (rows.length === 0)
2043
- return null;
2044
- const names = rows.map((r) => r['name']);
2045
- if (names.some((n) => n === undefined || n === ''))
2046
- return null;
2047
- const shown = names.slice(0, MARKET_BROWSE_NAME_CAP);
217
+ function topNames(rows, key, cap = 5) {
218
+ const names = rows.map((row) => stringField(row, key));
219
+ if (names.some((name) => name === undefined))
220
+ return undefined;
221
+ const shown = names.slice(0, cap);
2048
222
  const remaining = names.length - shown.length;
2049
- const namesLine = remaining > 0 ? `${shown.join(', ')}, … ${remaining} more` : shown.join(', ');
2050
- return [
2051
- { text: `${total} plugins in ${market}` },
2052
- { text: namesLine, tone: 'muted' },
2053
- ];
2054
- }
2055
- // ---------------------------------------------------------------------------
2056
- // Batch D — search/sys/surface. search's three leaves share a rendering
2057
- // convention (a numbered `### i. title` heading per item, an optional meta
2058
- // line, then highlight bullets or a text blockquote — see exa.ts's
2059
- // renderResult) and a fixed, unconditional follow_up sentence per leaf that
2060
- // doubles as a shape boundary; sys/surface leaves have no shared shape and
2061
- // are handled independently below.
2062
- // ---------------------------------------------------------------------------
2063
- const SEARCH_RESULT_CAP = 3;
2064
- /** Pull a bare host out of a search result's meta line (renderResult,
2065
- * src/commands/search/exa.ts:150 — `url · date · author · via source`, url
2066
- * first when present) for a compact `title — domain` digest row. Returns
2067
- * undefined when the line isn't a `https?://` url — a highlight bullet with
2068
- * no meta line at all, or a meta line that leads with the date/author
2069
- * because url was absent. */
2070
- function extractDomain(metaLine) {
2071
- const first = (metaLine.split(' · ')[0] ?? '').trim();
2072
- const m = /^https?:\/\/(?:www\.)?([^/\s]+)/.exec(first);
2073
- return m?.[1];
223
+ return remaining > 0 ? `${shown.join(', ')}, … ${remaining} more` : shown.join(', ');
2074
224
  }
2075
- /** `search web`'s follow_up (src/commands/search/web.ts:56–57) is a FIXED
2076
- * string every call appends after the results (or `No results…`) line —
2077
- * its presence is required below so a corrupted/foreign document that
2078
- * happens to start with a matching lead line still can't be mistaken for
2079
- * this leaf's real output. */
2080
- const SEARCH_WEB_FOLLOWUP_MARKER = 'Fetch full content for selected result URLs with the contents leaf after checking its schema.';
2081
- /** `crtr search web` (webLeaf.render, src/commands/search/web.ts:70) has two
2082
- * shapes: `No results for "<query>".` (zero hits) or `${N} results for
2083
- * "${query}":` followed by one `### i. title` heading per result (each
2084
- * optionally followed by a meta line — see extractDomain) then highlight
2085
- * bullets or a text blockquote, blank-line separated, then the fixed
2086
- * follow_up road sign (see SEARCH_WEB_FOLLOWUP_MARKER). Returns null when
2087
- * the follow_up marker is absent, the lead line matches neither shape, or
2088
- * the parsed heading count doesn't equal the declared total (a pi
2089
- * bash-tool truncation mid-list, or an unrecognized layout). */
2090
- function summarizeSearchWeb(text) {
2091
- if (!text.includes(SEARCH_WEB_FOLLOWUP_MARKER))
225
+ function summarizeError(input) {
226
+ const error = input.error;
227
+ if (error === undefined)
2092
228
  return null;
2093
- const firstLine = text.split('\n')[0] ?? '';
2094
- const noResults = /^No results for "(.+)"\.$/.exec(firstLine);
2095
- if (noResults !== null)
2096
- return [{ text: `0 results for "${noResults[1]}"`, tone: 'muted' }];
2097
- const head = /^(\d+) results for ".+":$/.exec(firstLine);
2098
- if (head === null)
2099
- return null;
2100
- const count = Number(head[1]);
2101
- const lines = text.split('\n');
2102
- const items = [];
2103
- for (let i = 0; i < lines.length; i++) {
2104
- const h = /^### \d+\. (.+)$/.exec(lines[i]);
2105
- if (h === null)
2106
- continue;
2107
- items.push({ title: h[1], domain: extractDomain(lines[i + 1] ?? '') });
2108
- }
2109
- if (items.length !== count)
229
+ const code = stringField(error, 'error');
230
+ const message = stringField(error, 'message');
231
+ if (code === undefined || message === undefined)
2110
232
  return null;
2111
- const top = items.slice(0, SEARCH_RESULT_CAP).map((it) => (it.domain !== undefined ? `${it.title} — ${it.domain}` : it.title));
2112
- const out = [{ text: `${count} result${count === 1 ? '' : 's'}` }];
2113
- for (const t of top)
2114
- out.push({ text: t, tone: 'muted' });
2115
- const remaining = items.length - top.length;
2116
- if (remaining > 0)
2117
- out.push({ text: `… ${remaining} more`, tone: 'muted' });
2118
- return out;
233
+ return [{ text: code, tone: 'error' }, { text: truncateLine(message, 160), tone: 'muted' }];
2119
234
  }
2120
- /** Fixed lead sentence of `search answer`'s follow_up (src/commands/search/
2121
- * answer.ts:39) — the SAME string on every call (no branching), so its
2122
- * presence marks where the answer body ends and the follow_up road sign
2123
- * begins, regardless of whether citations are present. */
2124
- const SEARCH_ANSWER_FOLLOWUP_MARKER = 'Read any cited source in full with the contents leaf';
2125
- /** `crtr search answer` (answerLeaf.render, src/commands/search/answer.ts:52)
2126
- * has two shapes: `No answer was returned.` (empty) or `${answer}\n\n` then,
2127
- * when citations exist, `Sources (${N}):` plus one numbered line per
2128
- * citation and a blank line, then unconditionally the fixed follow_up
2129
- * sentence. Citations are optional; the follow_up marker is not, so it
2130
- * locates the answer/Sources boundary either way. Mirrors
2131
- * summarizeMemoryRead's teaser+ellipsis shape for the answer body. Returns
2132
- * null when the follow_up marker is absent (unrecognized shape) or the
2133
- * recovered answer text is empty. */
2134
- function summarizeSearchAnswer(text) {
2135
- const followUpIdx = text.indexOf(SEARCH_ANSWER_FOLLOWUP_MARKER);
2136
- if (followUpIdx === -1)
235
+ function summarizeMemoryRead(input) {
236
+ const value = record(input);
237
+ if (value === undefined)
2137
238
  return null;
2138
- if (/^No answer was returned\.$/.test(text.split('\n')[0] ?? '')) {
2139
- return [{ text: 'no answer returned', tone: 'muted' }];
2140
- }
2141
- const sourcesMatch = /^Sources \((\d+)\):$/m.exec(text);
2142
- const bodyEnd = sourcesMatch !== null ? text.indexOf(sourcesMatch[0]) : followUpIdx;
2143
- if (bodyEnd <= 0)
2144
- return null;
2145
- const answer = text.slice(0, bodyEnd).trim();
2146
- if (answer === '')
239
+ const name = stringField(value, 'name');
240
+ const kind = stringField(value, 'kind');
241
+ const scope = stringField(value, 'scope');
242
+ const content = stringField(value, 'content');
243
+ if (name === undefined || kind === undefined || scope === undefined)
2147
244
  return null;
2148
- let citationCount = 0;
2149
- if (sourcesMatch !== null) {
2150
- citationCount = Number(sourcesMatch[1]);
2151
- const citationsBlock = text.slice(text.indexOf(sourcesMatch[0]) + sourcesMatch[0].length, followUpIdx);
2152
- const numberedLines = [...citationsBlock.matchAll(/^\d+\. .+$/gm)];
2153
- if (numberedLines.length !== citationCount)
2154
- return null;
245
+ const lines = [{ text: `${name} · ${kind} · ${scope}` }];
246
+ if (content !== undefined) {
247
+ const teaser = content.trim().split('\n').slice(0, MEMORY_READ_CONTENT_LINES);
248
+ if (teaser.length > 0 && teaser.some((line) => line !== ''))
249
+ lines.push({ text: '' }, ...teaser.map((text) => ({ text })));
2155
250
  }
2156
- const answerLines = answer.split('\n').filter((l) => l.trim() !== '');
2157
- const teaser = answerLines.slice(0, MEMORY_READ_CONTENT_LINES);
2158
- const hasMore = answerLines.length > MEMORY_READ_CONTENT_LINES;
2159
- const lines = teaser.map((l) => ({ text: l }));
2160
- if (hasMore)
2161
- lines.push({ text: '…', tone: 'muted' });
2162
- lines.push({ text: `${citationCount} citation${citationCount === 1 ? '' : 's'}`, tone: 'muted' });
2163
251
  return lines;
2164
252
  }
2165
- /** Fixed lead sentence of `search contents`'s follow_up (src/commands/
2166
- * search/contents.ts:71) — always this exact string, used to bound the
2167
- * last result block below (its true end would otherwise swallow a trailing
2168
- * `Failed (…):` block and the follow_up text into the char count). */
2169
- const SEARCH_CONTENTS_FOLLOWUP_MARKER = 'Stale or empty content? Re-run with --max-age-hours 0';
2170
- const SEARCH_CONTENTS_NAME_CAP = 5;
2171
- /** `crtr search contents` (contentsLeaf.render, src/commands/search/
2172
- * contents.ts:87): `No content extracted.` (0 results) or `Extracted ${N}
2173
- * URL(s):` plus one `### i. title` block per result (heading, optional
2174
- * meta line, then highlight bullets or a text blockquote); either way, an
2175
- * optional `Failed (${M}):` bullet list follows, then unconditionally the
2176
- * fixed follow_up sentence. Per-URL `chars` is the length of that result's
2177
- * own rendered content block (title/meta lines excluded) — the visible
2178
- * extracted text, not Exa's own byte count. Returns null when the
2179
- * follow_up marker is absent, the heading count doesn't match `N`, or (in
2180
- * the 0-results case) there were no failures either — that combination
2181
- * isn't a real shape this leaf produces. */
2182
- function summarizeSearchContents(text) {
2183
- const followUpIdx = text.indexOf(SEARCH_CONTENTS_FOLLOWUP_MARKER);
2184
- if (followUpIdx === -1)
2185
- return null;
2186
- const failedMatch = /^Failed \((\d+)\):$/m.exec(text);
2187
- const failedCount = failedMatch !== null ? Number(failedMatch[1]) : 0;
2188
- const firstLine = text.split('\n')[0] ?? '';
2189
- if (/^No content extracted\.$/.test(firstLine)) {
2190
- if (failedCount === 0)
2191
- return [{ text: 'no content extracted', tone: 'muted' }];
2192
- return [{ text: `0 extracted · ${failedCount} failed`, tone: 'error' }];
2193
- }
2194
- const head = /^Extracted (\d+) URLs?:$/.exec(firstLine);
2195
- if (head === null)
2196
- return null;
2197
- const count = Number(head[1]);
2198
- const lines = text.split('\n');
2199
- const headingIdxs = [];
2200
- for (let i = 0; i < lines.length; i++)
2201
- if (/^### \d+\. /.test(lines[i]))
2202
- headingIdxs.push(i);
2203
- if (headingIdxs.length !== count)
2204
- return null;
2205
- const contentEndOffset = failedMatch !== null ? text.indexOf(failedMatch[0]) : followUpIdx;
2206
- const contentEndLine = text.slice(0, contentEndOffset).split('\n').length - 1;
2207
- const items = [];
2208
- for (let k = 0; k < headingIdxs.length; k++) {
2209
- const start = headingIdxs[k];
2210
- const end = k + 1 < headingIdxs.length ? headingIdxs[k + 1] : contentEndLine;
2211
- const heading = /^### \d+\. (.+)$/.exec(lines[start]);
2212
- if (heading === null)
2213
- return null;
2214
- const body = lines.slice(start + 1, end);
2215
- // renderResult (exa.ts:184–189) puts the meta line — url first when
2216
- // present — immediately after the heading; drop it so `chars` reflects
2217
- // only the extracted content, not the URL/date/author line.
2218
- const contentLines = body.length > 0 && /^https?:\/\//.test(body[0]) ? body.slice(1) : body;
2219
- items.push({ title: heading[1], chars: contentLines.join('\n').trim().length });
2220
- }
2221
- const shown = items.slice(0, SEARCH_CONTENTS_NAME_CAP).map((it) => `${it.title} · ${it.chars} chars`);
2222
- const out = [{ text: `extracted ${count} URL${count === 1 ? '' : 's'}` }];
2223
- for (const s of shown)
2224
- out.push({ text: s, tone: 'muted' });
2225
- const remaining = items.length - shown.length;
2226
- if (remaining > 0)
2227
- out.push({ text: `… ${remaining} more`, tone: 'muted' });
2228
- if (failedCount > 0)
2229
- out.push({ text: `${failedCount} failed`, tone: 'warning' });
2230
- return out;
2231
- }
2232
- /** `crtr sys setup` opens a pi-tui wizard when stdout is a TTY (its `render`
2233
- * is always `''` — the wizard writes directly via process.stdout, and its
2234
- * raw ANSI curses frames never match any marker below, so this summarizer
2235
- * safely returns null in that case). In the headless/agent path
2236
- * (src/commands/sys/setup.ts's `!process.stdout.isTTY` branch,
2237
- * `renderSetupStaticInstructions` in setup-core.ts — the shape every
2238
- * bash-tool call to this leaf actually produces) it instead prints a
2239
- * static inventory: the headline `crtr sys setup (non-interactive)`, a
2240
- * `Packages:` section (`- <name> — <description> (installed|not
2241
- * installed)`), a `System deps:` section (`- <tool>: ✓ installed` or `-
2242
- * <tool>: <command/hint>`), then an `- Exa key: …` bullet and a footer line
2243
- * — verified against the live command, 2026-07-01. Returns null when the
2244
- * headline, both section headers, or the Exa-key marker bounding the
2245
- * System deps section are missing, or any row's shape doesn't match — a
2246
- * partial/truncated dump must not report a false step count. */
2247
- function summarizeSysSetup(text) {
2248
- if ((text.split('\n')[0] ?? '') !== 'crtr sys setup (non-interactive)')
2249
- return null;
2250
- const lines = text.split('\n');
2251
- const pkgIdx = lines.indexOf('Packages:');
2252
- const sysIdx = lines.indexOf('System deps:');
2253
- if (pkgIdx === -1 || sysIdx === -1 || sysIdx <= pkgIdx)
2254
- return null;
2255
- const exaIdx = lines.findIndex((l, i) => i > sysIdx && /^- Exa key:/.test(l));
2256
- if (exaIdx === -1)
2257
- return null;
2258
- const pkgLines = lines.slice(pkgIdx + 1, sysIdx).filter((l) => l.trim() !== '');
2259
- const sysLines = lines.slice(sysIdx + 1, exaIdx).filter((l) => l.trim() !== '');
2260
- if (pkgLines.length === 0 && sysLines.length === 0)
2261
- return null;
2262
- let ok = 0;
2263
- const pending = [];
2264
- for (const l of pkgLines) {
2265
- const m = /^- (.+?) — .+\((installed|not installed)\)$/.exec(l);
2266
- if (m === null)
2267
- return null;
2268
- if (m[2] === 'installed')
2269
- ok++;
2270
- else
2271
- pending.push(m[1]);
2272
- }
2273
- for (const l of sysLines) {
2274
- const m = /^- (\S+): (.+)$/.exec(l);
2275
- if (m === null)
2276
- return null;
2277
- if (m[2] === '✓ installed')
2278
- ok++;
2279
- else
2280
- pending.push(m[1]);
2281
- }
2282
- if (pending.length === 0)
2283
- return [{ text: `${ok} steps ok`, tone: 'success' }];
2284
- return [
2285
- { text: `${ok} ok · ${pending.length} pending`, tone: 'warning' },
2286
- { text: pending.join(', '), tone: 'muted' },
2287
- ];
2288
- }
2289
- const SYS_DOCTOR_ISSUE_CAP = 5;
2290
- /** `crtr sys doctor` (sysDoctorLeaf.run, src/commands/sys/doctor.ts:283) has
2291
- * no custom `render`; the generic renderer bullets `ok`/`failed` then an `N
2292
- * checks:` table (columns scope,name,status,message,remediation, plus a
2293
- * `fixed` column ONLY when at least one row set it — renderArray's column
2294
- * set is the union of keys across every row, src/core/render.ts:60).
2295
- * `failed` counts only UNRESOLVED fails (status:fail with no `fixed:true`)
2296
- * — mirroring doctor.ts's own `ok`/`failed` computation, which excludes
2297
- * `fixed:true` rows. Returns null when the ok/failed bullets or the checks
2298
- * table don't match this shape, or when the open-fail-row count found in
2299
- * the table doesn't equal the declared `failed` bullet (a truncated table,
2300
- * or a row missing name/message). */
2301
- function summarizeSysDoctor(text) {
2302
- const okMatch = /^- ok:\s*(true|false)\s*$/m.exec(text);
2303
- const failedMatch = /^- failed:\s*(\d+)\s*$/m.exec(text);
2304
- if (okMatch === null || failedMatch === null)
2305
- return null;
2306
- const rows = parseTableRows(text, 'checks');
2307
- if (rows === null)
2308
- return null;
2309
- const failed = Number(failedMatch[1]);
2310
- const fixedCount = rows.filter((r) => r['status'] === 'fail' && r['fixed'] === 'true').length;
2311
- if (failed === 0) {
2312
- // A fail row with no `fixed:true` here would contradict the declared
2313
- // `failed: 0` — an inconsistent/foreign shape, not a real PASS.
2314
- if (rows.some((r) => r['status'] === 'fail' && r['fixed'] !== 'true'))
2315
- return null;
2316
- const lines = [{ text: `PASS · ${rows.length} checks`, tone: 'success' }];
2317
- if (fixedCount > 0)
2318
- lines.push({ text: `fixed ${fixedCount} automatically`, tone: 'muted' });
253
+ function summarizeCount(label, successZero = false) {
254
+ return (input) => {
255
+ const value = record(input);
256
+ const count = value === undefined ? undefined : numberField(value, 'count') ?? numberField(value, 'total');
257
+ if (count === undefined)
258
+ return null;
259
+ if (count === 0 && successZero)
260
+ return [{ text: `PASS · no ${label}`, tone: 'success' }];
261
+ return [{ text: `${count} ${label}`, tone: count === 0 ? 'muted' : 'plain' }];
262
+ };
263
+ }
264
+ function summarizeArray(label, field = 'items') {
265
+ return (input) => {
266
+ const value = record(input);
267
+ if (value === undefined)
268
+ return null;
269
+ const rows = arrayField(value, field);
270
+ const total = numberField(value, 'total') ?? (rows?.length);
271
+ if (rows === undefined || total === undefined)
272
+ return null;
273
+ const lines = [{ text: `${total} ${label}`, tone: total === 0 ? 'muted' : 'plain' }];
274
+ const names = topNames(rows, 'name');
275
+ if (names !== undefined && names !== '')
276
+ lines.push({ text: names, tone: 'muted' });
2319
277
  return lines;
2320
- }
2321
- const openIssues = rows.filter((r) => r['status'] === 'fail' && r['fixed'] !== 'true');
2322
- if (openIssues.length !== failed)
2323
- return null;
2324
- const shown = [];
2325
- for (const row of openIssues.slice(0, SYS_DOCTOR_ISSUE_CAP)) {
2326
- const name = row['name'];
2327
- const message = row['message'];
2328
- if (name === undefined || message === undefined)
2329
- return null;
2330
- shown.push(`${name}: ${message}`);
2331
- }
2332
- const lines = [{ text: `${failed} issue${failed === 1 ? '' : 's'}`, tone: 'error' }];
2333
- for (const s of shown)
2334
- lines.push({ text: s, tone: 'warning' });
2335
- const remaining = openIssues.length - shown.length;
2336
- if (remaining > 0)
2337
- lines.push({ text: `… ${remaining} more`, tone: 'muted' });
2338
- if (fixedCount > 0)
2339
- lines.push({ text: `fixed ${fixedCount} automatically`, tone: 'muted' });
2340
- return lines;
278
+ };
2341
279
  }
2342
- const SYS_UPDATE_OUTDATED_CAP = 5;
2343
- /** `crtr sys update` (sysUpdateLeaf.run, src/commands/sys/update.ts:19) has
2344
- * two shapes depending on `--check`: bounded check — a `- up_to_date:
2345
- * true|false` bullet plus an `N updates:` table (columns name,kind,current,
2346
- * latest,up_to_date,unreachable — no version field at the top level, so
2347
- * command-icons.md's `vX.Y.Z` framing doesn't fit this leaf; that's `sys
2348
- * version`'s shape, not this one); or apply — `- target: self|content|all`
2349
- * + `- status: done|failed` + `- error: <msg>` (only on failure). Verified
2350
- * against the live `--check` path, 2026-07-01; the apply path is read from
2351
- * source only — running it mutates the install (spawns npm/git). Returns
2352
- * null when neither shape's required bullets/table are present. */
2353
- function summarizeSysUpdate(text) {
2354
- const upToDateMatch = /^- up_to_date:\s*(true|false)\s*$/m.exec(text);
2355
- if (upToDateMatch !== null) {
2356
- const rows = parseTableRows(text, 'updates');
2357
- if (rows === null)
2358
- return null;
2359
- if (upToDateMatch[1] === 'true') {
2360
- // sys update's own computation is `up_to_date = updates.every(u =>
2361
- // u.up_to_date || u.unreachable)` (update.ts:104) — a row that
2362
- // disagrees (reachable and NOT up to date) contradicts the bullet.
2363
- if (!rows.every((r) => r['up_to_date'] === 'true' || r['unreachable'] === 'true'))
2364
- return null;
2365
- return [{ text: `PASS · ${rows.length} up to date`, tone: 'success' }];
2366
- }
2367
- const outdated = [];
2368
- let unreachable = 0;
2369
- for (const row of rows) {
2370
- const name = row['name'];
2371
- const current = row['current'];
2372
- const latest = row['latest'];
2373
- const isUpToDate = row['up_to_date'];
2374
- const isUnreachable = row['unreachable'];
2375
- if (name === undefined || isUpToDate === undefined || isUnreachable === undefined)
2376
- return null;
2377
- if (isUnreachable === 'true') {
2378
- unreachable++;
2379
- continue;
2380
- }
2381
- if (isUpToDate === 'false')
2382
- outdated.push(`${name}: ${current} → ${latest}`);
2383
- }
2384
- const lines = [{ text: `${outdated.length} outdated · ${rows.length} checked`, tone: 'warning' }];
2385
- const shown = outdated.slice(0, SYS_UPDATE_OUTDATED_CAP);
2386
- for (const s of shown)
2387
- lines.push({ text: s, tone: 'muted' });
2388
- const remaining = outdated.length - shown.length;
2389
- if (remaining > 0)
2390
- lines.push({ text: `… ${remaining} more`, tone: 'muted' });
2391
- if (unreachable > 0)
2392
- lines.push({ text: `${unreachable} unreachable`, tone: 'muted' });
2393
- return lines;
2394
- }
2395
- const statusMatch = /^- status:\s*(done|failed)\s*$/m.exec(text);
2396
- if (statusMatch === null)
280
+ function summarizeAttentionMap(input) {
281
+ const value = record(input);
282
+ const counts = value?.['counts'];
283
+ if (counts === null || typeof counts !== 'object' || Array.isArray(counts))
2397
284
  return null;
2398
- const target = /^- target:\s*(\S+)\s*$/m.exec(text)?.[1];
2399
- if (target === undefined)
285
+ const entries = Object.entries(counts);
286
+ if (entries.some(([, count]) => typeof count !== 'number'))
2400
287
  return null;
2401
- if (statusMatch[1] === 'done')
2402
- return [{ text: `updated · ${target}`, tone: 'success' }];
2403
- const error = /^- error:\s*(.+)$/m.exec(text)?.[1];
2404
- const lines = [{ text: `update failed · ${target}`, tone: 'error' }];
2405
- if (error !== undefined)
2406
- lines.push({ text: error, tone: 'muted' });
2407
- return lines;
288
+ const total = entries.reduce((sum, [, count]) => sum + count, 0);
289
+ if (total === 0)
290
+ return [{ text: 'PASS · no pending asks', tone: 'success' }];
291
+ const hottest = entries.filter(([, count]) => count > 0).sort((a, b) => b[1] - a[1])[0];
292
+ return hottest === undefined ? null : [{ text: `${total} asks`, tone: 'warning' }, { text: `hottest: ${hottest[0]} (${hottest[1]})`, tone: 'muted' }];
2408
293
  }
2409
- /** `crtr sys feedback` (sysFeedbackLeaf.run, src/commands/sys/feedback.ts:
2410
- * 187) has no custom `render`; the generic renderer bullets `status`
2411
- * (always short) then either `url` (filed shape — a short GitHub issue
2412
- * link) or `path`+`error` (saved-locally shape; `path` is usually short but
2413
- * can cross the prose threshold with a long home dir, and `error` is a
2414
- * free-form spawn/gh message that often does — read via
2415
- * readFieldFlexible/soloProseAfterBullets, the same two-shape pattern
2416
- * Batch C's human/memory summarizers use for a field whose bullet-vs-prose
2417
- * form depends on string length). Read from source only — running it files
2418
- * a real GitHub issue or writes a local report, so it was not invoked
2419
- * live. Returns null when `status` isn't `filed`/`saved-locally`, or the
2420
- * shape's required companion field is missing. */
2421
- function summarizeSysFeedback(text) {
2422
- const statusMatch = /^- status:\s*(filed|saved-locally)\s*$/m.exec(text);
2423
- if (statusMatch === null)
2424
- return null;
2425
- if (statusMatch[1] === 'filed') {
2426
- const url = readFieldFlexible(text, 'url') ?? soloProseAfterBullets(text);
2427
- if (url === undefined)
294
+ function summarizeDaemon(field, active, inactive) {
295
+ return (input) => {
296
+ const value = record(input);
297
+ if (value === undefined)
2428
298
  return null;
2429
- return [
2430
- { text: 'issue filed', tone: 'success' },
2431
- { text: url, tone: 'muted' },
2432
- ];
2433
- }
2434
- const path = readFieldFlexible(text, 'path') ?? soloProseAfterBullets(text);
2435
- if (path === undefined)
2436
- return null;
2437
- // The reason (gh missing, unauthenticated, or the API call itself
2438
- // failing — feedback.ts:188–195) is prose the digest doesn't try to
2439
- // classify; "not filed" states the fact without guessing the cause.
2440
- return [
2441
- { text: 'saved locally — not filed', tone: 'warning' },
2442
- { text: basename(path), tone: 'muted' },
2443
- ];
2444
- }
2445
- // ---------------------------------------------------------------------------
2446
- // Sys sync/support/diagnostic batch. The three migration leaves have the
2447
- // same bespoke `header + optional GFM table` render shape; support has the
2448
- // generic result-object shape; and logs is a bounded normalized event stream.
2449
- // These helpers retain error fallback rather than treating a near match as a
2450
- // successful operation.
2451
- // ---------------------------------------------------------------------------
2452
- function parseSyncRows(text) {
2453
- const lines = text.split('\n');
2454
- let i = 1;
2455
- while (i < lines.length && lines[i].trim() === '')
2456
- i++;
2457
- const header = splitTableRow(lines[i] ?? '');
2458
- const separator = splitTableRow(lines[i + 1] ?? '');
2459
- if (header === null ||
2460
- separator === null ||
2461
- header.length !== 4 ||
2462
- header.join('\u0000') !== 'status\u0000memory doc\u0000target\u0000source' ||
2463
- separator.length !== header.length ||
2464
- separator.some((cell) => cell !== '---'))
2465
- return null;
2466
- const rows = [];
2467
- i += 2;
2468
- while (i < lines.length && lines[i].trim() !== '') {
2469
- const cells = splitTableRow(lines[i]);
2470
- if (cells === null || cells.length !== header.length)
299
+ const state = booleanField(value, field);
300
+ if (state === undefined)
2471
301
  return null;
2472
- const row = {};
2473
- for (let c = 0; c < header.length; c++)
2474
- row[header[c]] = cells[c];
2475
- rows.push(row);
2476
- i++;
2477
- }
2478
- while (i < lines.length && lines[i].trim() === '')
2479
- i++;
2480
- return i === lines.length ? rows : null;
302
+ const pid = numberField(value, 'pid') ?? numberField(value, 'existing_pid');
303
+ return [{ text: state ? `${active}${pid === undefined ? '' : ` · pid ${pid}`}` : inactive, tone: state ? 'success' : 'muted' }];
304
+ };
2481
305
  }
2482
- function summarizeBatchDigest(primary, secondary) {
2483
- const nonzero = secondary.filter((item) => item.count > 0);
2484
- const aside = nonzero.length === 0
2485
- ? { text: `all ${primary.count} candidates accounted for`, tone: 'muted' }
2486
- : {
2487
- text: nonzero.map((item) => `${item.count} ${item.label}`).join(' · '),
2488
- tone: nonzero.some((item) => item.tone === 'warning') ? 'warning' : 'muted',
2489
- };
2490
- return [{ text: `${primary.count} ${primary.label}`, tone: primary.tone }, aside];
2491
- }
2492
- /** The three `sys sync` leaves render an exact count header followed by either
2493
- * `No sync candidates.` or a fixed four-column table. The header deliberately
2494
- * repeats the table counts, so both are checked before collapsing the batch. */
2495
- function summarizeSysSync(text, lane, allowedStatuses, tokens) {
2496
- const first = text.split('\n')[0] ?? '';
2497
- const escapedLane = lane.replace(/-/g, '\\-');
2498
- const head = new RegExp(`^crtr sys sync ${escapedLane} — (?:(dry run): )?(\\d+) (would import|imported|would ignore|ignored), (\\d+) skipped(?:, (\\d+) (ignored|excluded))?$`).exec(first);
2499
- if (head === null)
2500
- return null;
2501
- const primaryLabel = head[3];
2502
- const primaryStatus = primaryLabel.replace(/ /g, '-');
2503
- const primaryCount = Number(head[2]);
2504
- const skippedCount = Number(head[4]);
2505
- const extraCount = head[5] === undefined ? 0 : Number(head[5]);
2506
- const extraStatus = head[6];
2507
- const headerDryRun = head[1] === 'dry run';
2508
- if (!allowedStatuses.includes(primaryStatus) || !allowedStatuses.includes('skipped') || (extraStatus !== undefined && !allowedStatuses.includes(extraStatus)))
2509
- return null;
2510
- if (headerDryRun !== (primaryStatus === 'would-import'))
306
+ function summarizeHumanAsk(input, tokens) {
307
+ const value = record(input);
308
+ if (value === undefined || stringField(value, 'job_id') === undefined)
2511
309
  return null;
2512
- // The producer omits `dry run` from headers whose only candidates are
2513
- // skipped/excluded. argv remains authoritative for that otherwise identical
2514
- // rendered shape.
2515
- const dryRun = headerDryRun || tokens.includes('--dry-run');
2516
- const summaryPrimaryLabel = dryRun
2517
- ? primaryStatus === 'imported' ? 'would import' : primaryStatus === 'ignored' ? 'would ignore' : primaryLabel
2518
- : primaryLabel;
2519
- const noCandidates = `crtr sys sync ${lane} — ${first.split(' — ')[1]}\n\nNo sync candidates.`;
2520
- if (text === noCandidates) {
2521
- if (primaryCount !== 0 || skippedCount !== 0 || extraCount !== 0)
2522
- return null;
2523
- return [
2524
- { text: 'no sync candidates', tone: 'muted' },
2525
- { text: lane, tone: 'muted' },
2526
- ];
2527
- }
2528
- const rows = parseSyncRows(text);
2529
- if (rows === null || rows.length === 0)
2530
- return null;
2531
- const counts = new Map();
2532
- for (const row of rows) {
2533
- const status = row['status'];
2534
- if (status === undefined || !allowedStatuses.includes(status))
2535
- return null;
2536
- counts.set(status, (counts.get(status) ?? 0) + 1);
2537
- }
2538
- if ((counts.get(primaryStatus) ?? 0) !== primaryCount ||
2539
- (counts.get('skipped') ?? 0) !== skippedCount ||
2540
- (extraStatus === undefined ? 0 : counts.get(extraStatus) ?? 0) !== extraCount)
2541
- return null;
2542
- for (const [status, count] of counts) {
2543
- if (count > 0 && status !== primaryStatus && status !== 'skipped' && status !== extraStatus)
2544
- return null;
2545
- }
2546
- const hasSkipped = skippedCount > 0;
2547
- return summarizeBatchDigest({
2548
- count: primaryCount,
2549
- label: summaryPrimaryLabel,
2550
- tone: hasSkipped ? 'warning' : primaryCount === 0 ? 'muted' : dryRun || primaryStatus === 'would-ignore' ? 'accent' : 'success',
2551
- }, [
2552
- { count: skippedCount, label: 'skipped', tone: 'warning' },
2553
- ...(extraStatus === undefined ? [] : [{ count: extraCount, label: extraStatus, tone: 'muted' }]),
2554
- ]);
2555
- }
2556
- /** Shared health framing for a diagnostic stream: the top line communicates
2557
- * severity while the muted aside preserves the useful bounded breakdown. */
2558
- function summarizeHealthStatus(recordCount, errors, warnings, aside) {
2559
- if (errors > 0) {
2560
- return [
2561
- { text: `${recordCount} records · ${errors} errors${warnings > 0 ? ` · ${warnings} warnings` : ''}`, tone: 'error' },
2562
- { text: aside, tone: 'muted' },
2563
- ];
2564
- }
2565
- if (warnings > 0) {
2566
- return [
2567
- { text: `${recordCount} records · ${warnings} warnings`, tone: 'warning' },
2568
- { text: aside, tone: 'muted' },
2569
- ];
2570
- }
310
+ const question = tokens.slice(2).find((token) => !token.startsWith('--'));
2571
311
  return [
2572
- { text: `PASS · ${recordCount} records`, tone: 'success' },
2573
- { text: aside, tone: 'muted' },
312
+ { text: 'ask posted → waiting on human', tone: 'accent' },
313
+ ...(question === undefined ? [] : [{ text: truncateLine(question.split('\n')[0].trim(), 160), tone: 'muted' }]),
2574
314
  ];
2575
315
  }
2576
- /** `sys logs` prints zero lines for an empty snapshot, otherwise one fully
2577
- * normalized canonical-event or RAW-residue line per record. */
2578
- function summarizeSysLogs(text) {
2579
- if (text === '')
2580
- return summarizeHealthStatus(0, 0, 0, 'no matching diagnostics');
2581
- const componentCounts = new Map();
2582
- let errors = 0;
2583
- let warnings = 0;
2584
- let raw = 0;
2585
- const lines = text.split('\n');
2586
- if (lines.at(-1) === '')
2587
- lines.pop();
2588
- if (lines.length === 0 || lines.some((line) => line === ''))
316
+ function summarizeHumanResolve(input) {
317
+ const value = record(input);
318
+ if (value === undefined)
2589
319
  return null;
2590
- for (const line of lines) {
2591
- const event = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2}) (DEBUG|INFO|WARN|ERROR) (daemon|broker) \S+ op=[0-9a-f]{32}(?: \{.*\})?$/.exec(line);
2592
- if (event !== null) {
2593
- componentCounts.set(event[2], (componentCounts.get(event[2]) ?? 0) + 1);
2594
- if (event[1] === 'ERROR')
2595
- errors++;
2596
- else if (event[1] === 'WARN')
2597
- warnings++;
2598
- continue;
2599
- }
2600
- const residue = /^RAW \S+ \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2}) (.+)$/.exec(line);
2601
- if (residue === null)
2602
- return null;
2603
- try {
2604
- JSON.parse(residue[1]);
2605
- }
2606
- catch {
2607
- return null;
2608
- }
2609
- raw++;
2610
- }
2611
- const componentDigest = summarizeOrderedCounts(componentCounts, ['daemon', 'broker']);
2612
- const aside = [componentDigest, raw > 0 ? `${raw} raw` : ''].filter((part) => part !== '').join(' · ');
2613
- return summarizeHealthStatus(lines.length, errors, warnings, aside === '' ? 'no component detail' : aside);
2614
- }
2615
- function summarizeSysSupportPrepare(text) {
2616
- if (!/^- status:\s*prepared\s*$/m.test(text))
2617
- return null;
2618
- const reference = /^- reference:\s*(\S+)\s*$/m.exec(text)?.[1];
2619
- const node = /^- node_id:\s*(\S+)\s*$/m.exec(text)?.[1];
2620
- const sourceCount = /^- source_count:\s*(\d+)\s*$/m.exec(text)?.[1];
2621
- const bytes = /^- bytes:\s*(\d+)\s*$/m.exec(text)?.[1];
2622
- const path = readFieldFlexible(text, 'path') ?? soloProseAfterBullets(text);
2623
- if (reference === undefined || node === undefined || sourceCount === undefined || bytes === undefined || path === undefined)
320
+ const resolved = booleanField(value, 'resolved');
321
+ const job = stringField(value, 'job_id');
322
+ if (resolved === undefined || job === undefined)
2624
323
  return null;
2625
- return [
2626
- { text: `prepared ${reference}`, tone: 'success' },
2627
- { text: `${node} · ${sourceCount} sources · ${bytes} bytes`, tone: 'muted' },
2628
- ];
324
+ return [{ text: `${resolved ? 'resolved' : 'not resolved'} ${job}`, tone: resolved ? 'success' : 'warning' }];
2629
325
  }
2630
- function summarizeSysSupportSubmit(text) {
2631
- if (!/^- status:\s*submitted\s*$/m.test(text))
326
+ function summarizeStatus(input) {
327
+ const value = record(input);
328
+ if (value === undefined)
2632
329
  return null;
2633
- const reference = /^- reference:\s*(\S+)\s*$/m.exec(text)?.[1];
2634
- const repository = /^- repository:\s*(\S+)\s*$/m.exec(text)?.[1];
2635
- const url = readFieldFlexible(text, 'url') ?? soloProseAfterBullets(text);
2636
- if (reference === undefined || repository === undefined || url === undefined)
330
+ const status = stringField(value, 'status');
331
+ if (status === undefined)
2637
332
  return null;
2638
- return [
2639
- { text: `submitted ${reference}`, tone: 'success' },
2640
- { text: repository, tone: 'muted' },
2641
- { text: url, tone: 'muted' },
2642
- ];
333
+ return [{ text: status, tone: ['done', 'success', 'submitted', 'filed'].includes(status) ? 'success' : status === 'failed' ? 'error' : 'plain' }];
2643
334
  }
2644
- /** Focus is the high-frequency generic surface result. The target is an argv
2645
- * asset because the result does not echo it; all three booleans are required
2646
- * so a partial/error result never becomes a false success receipt. */
2647
- function summarizeSurfaceNodeFocus(text, tokens) {
2648
- const focused = /^- focused:\s*(true|false)\s*$/m.exec(text)?.[1];
2649
- const revived = /^- revived:\s*(true|false)\s*$/m.exec(text)?.[1];
2650
- const inPlace = /^- in_place:\s*(true|false)\s*$/m.exec(text)?.[1];
2651
- const nodeId = firstPositionalArg(tokens, 3, new Set(['--pane']));
2652
- if (focused === undefined || revived === undefined || inPlace === undefined || nodeId === undefined)
335
+ function summarizeProfileList(input) {
336
+ const records = input.jsonl;
337
+ if (records === undefined)
2653
338
  return null;
2654
- const lines = [
2655
- focused === 'true'
2656
- ? { text: `focused ${nodeId}`, tone: 'success' }
2657
- : { text: `could not focus ${nodeId}`, tone: 'warning' },
2658
- ];
2659
- if (revived === 'true')
2660
- lines.push({ text: 'broker revived', tone: 'accent' });
2661
- else if (focused === 'true')
2662
- lines.push({ text: inPlace === 'true' ? 'existing surface selected' : 'opened', tone: 'muted' });
2663
- return lines;
2664
- }
2665
- /** `surface edit` renders a single receipt whose absolute path can dominate a
2666
- * collapsed card. Keep the user-facing file asset and pane, with the full path
2667
- * still available in the expanded raw result. */
2668
- function summarizeSurfaceEdit(text) {
2669
- const match = /^opened (.+) in a new pane \((%[^)]+)\)$/.exec(text);
2670
- if (match === null)
339
+ const profiles = records.filter((item) => stringField(item, 'name') !== undefined && numberField(item, 'projects_count') !== undefined);
340
+ if (profiles.length === 0 && records.length > 0)
2671
341
  return null;
2672
342
  return [
2673
- { text: 'editor opened', tone: 'success' },
2674
- { text: `${basename(match[1])} · ${match[2]}`, tone: 'muted' },
343
+ { text: `${profiles.length} profile${profiles.length === 1 ? '' : 's'}` },
344
+ ...profiles.slice(0, 5).map((profile) => ({ text: `${stringField(profile, 'name')} · ${numberField(profile, 'projects_count')} projects`, tone: 'muted' })),
2675
345
  ];
2676
346
  }
2677
347
  const SUMMARIZERS = [
2678
348
  { path: 'memory read', run: summarizeMemoryRead },
2679
- { path: 'memory lint', run: summarizeMemoryLint },
2680
- { path: 'canvas dashboard', run: summarizeCanvasDashboard },
2681
- { path: 'node inspect list', run: summarizeNodeInspectList },
2682
- { path: 'canvas attention count', run: summarizeCanvasAttentionCount },
2683
- { path: 'canvas attention list', run: summarizeCanvasAttentionList },
2684
- { path: 'canvas attention map', run: summarizeCanvasAttentionMap },
2685
- { path: 'canvas prune', run: summarizeCanvasPrune },
2686
- { path: 'canvas browse', run: summarizeCanvasBrowse },
2687
- { path: 'canvas revive', run: summarizeCanvasRevive },
2688
- { path: 'canvas chord', run: summarizeCanvasChord },
2689
- { path: 'node lifecycle revive', run: summarizeNodeLifecycleRevive },
2690
- { path: 'canvas history search', run: summarizeCanvasHistorySearch },
2691
- { path: 'canvas history read', run: summarizeCanvasHistoryRead },
2692
- { path: 'canvas history grep', run: summarizeCanvasHistoryGrep },
2693
- { path: 'node inspect artifacts', run: summarizeNodeInspectArtifacts },
2694
- { path: 'node inspect context', run: summarizeNodeInspectContext },
2695
- { path: 'node inspect transcript', run: summarizeNodeInspectTranscript },
2696
- { path: 'node fork', run: summarizeNodeFork },
2697
- { path: 'node worktree close', run: summarizeNodeWorktreeClose },
2698
- { path: 'sys daemon start', run: summarizeSysDaemonStart },
2699
- { path: 'sys daemon status', run: summarizeSysDaemonStatus },
2700
- { path: 'sys daemon stop', run: summarizeSysDaemonStop },
2701
- { path: 'canvas issue new', run: summarizeCanvasIssueNew },
2702
- { path: 'node new', run: summarizeNodeNew },
2703
- { path: 'node message send', run: summarizeNodeMessageEngine },
2704
- { path: 'node message request', run: summarizeNodeMessageEngine },
2705
- { path: 'node wait deadline', run: summarizeNodeWaitDeadline },
2706
- { path: 'node yield', run: summarizeNodeYield },
2707
- { path: 'node lifecycle close', run: summarizeNodeLifecycleClose },
2708
- { path: 'push urgent', run: summarizePushUrgent },
2709
- { path: 'push final', run: summarizePushFinal },
349
+ { path: 'memory list', run: summarizeArray('docs') },
350
+ { path: 'memory find', run: summarizeArray('hits', 'hits') },
351
+ { path: 'canvas attention count', run: summarizeCount('pending asks', true) },
352
+ { path: 'canvas attention list', run: summarizeArray('pending asks') },
353
+ { path: 'canvas attention map', run: summarizeAttentionMap },
2710
354
  { path: 'human ask', run: summarizeHumanAsk },
2711
- { path: 'human review new', run: summarizeHumanReviewNew },
2712
- { path: 'human list', run: summarizeHumanList },
2713
- { path: 'human deck', run: summarizeHumanDeck },
2714
- { path: 'human notify', run: summarizeHumanNotify },
2715
- { path: 'human show', run: summarizeHumanShow },
2716
- { path: 'human cancel', run: summarizeHumanCancel },
355
+ { path: 'human list', run: summarizeArray('pending') },
2717
356
  { path: 'human resolve', run: summarizeHumanResolve },
2718
- { path: 'memory list', run: summarizeMemoryList },
2719
- { path: 'memory find', run: summarizeMemoryFind },
2720
- { path: 'memory write', run: summarizeMemoryWrite },
2721
- { path: 'node config', run: summarizeNodeConfig },
2722
- { path: 'node subscription add', run: summarizeNodeSubscribe },
2723
- { path: 'node subscription remove', run: summarizeNodeUnsubscribe },
2724
- { path: 'node promote', run: summarizeNodePromote },
2725
- { path: 'node lifecycle demote', run: summarizeNodeDemote },
2726
- { path: 'node lifecycle recycle', run: summarizeNodeRecycle },
357
+ { path: 'pkg plugin list', run: summarizeArray('plugins') },
358
+ { path: 'pkg market list', run: summarizeArray('marketplaces') },
359
+ { path: 'pkg market browse', run: summarizeArray('plugins') },
360
+ { path: 'canvas dashboard', run: summarizeArray('nodes', 'rows') },
361
+ { path: 'node inspect list', run: summarizeArray('nodes', 'nodes') },
362
+ { path: 'sys daemon start', run: summarizeDaemon('started', 'started', 'already running') },
363
+ { path: 'sys daemon status', run: summarizeDaemon('running', 'running', 'not running') },
364
+ { path: 'sys daemon stop', run: summarizeDaemon('stopped', 'stopped', 'was not running') },
365
+ { path: 'sys update', run: summarizeStatus },
366
+ { path: 'sys feedback', run: summarizeStatus },
2727
367
  { path: 'profile list', run: summarizeProfileList },
2728
- { path: 'profile show', run: summarizeProfileShow },
2729
- { path: 'profile default show', run: summarizeProfileDefaultShow },
2730
- { path: 'profile env list', run: summarizeProfileEnvList },
2731
- { path: 'canvas config list', run: summarizeCanvasConfigList },
2732
- { path: 'sys config get', run: summarizeSysConfigGet },
2733
- { path: 'sys config path', run: summarizeSysConfigPath },
2734
- { path: 'sys config set', run: summarizeSysConfigSet },
2735
- { path: 'canvas config add', run: summarizeCanvasConfigAdd },
2736
- { path: 'canvas config remove', run: summarizeCanvasConfigRemove },
2737
- { path: 'canvas use', run: summarizeCanvasUse },
2738
- { path: 'pkg plugin install', run: summarizePkgPluginInstall },
2739
- { path: 'pkg plugin remove', run: summarizePkgPluginRemove },
2740
- { path: 'pkg plugin enable', run: summarizePkgPluginToggle },
2741
- { path: 'pkg plugin disable', run: summarizePkgPluginToggle },
2742
- { path: 'pkg plugin update', run: summarizePkgPluginUpdate },
2743
- { path: 'pkg market add', run: summarizePkgMarketAdd },
2744
- { path: 'pkg market remove', run: summarizePkgMarketRemove },
2745
- { path: 'pkg market update', run: summarizePkgPluginUpdate },
2746
- { path: 'pkg plugin list', run: summarizePkgPluginList },
2747
- { path: 'pkg plugin show', run: summarizePkgPluginShow },
2748
- { path: 'pkg market list', run: summarizePkgMarketList },
2749
- { path: 'pkg market browse', run: summarizePkgMarketBrowse },
2750
- { path: 'search web', run: summarizeSearchWeb },
2751
- { path: 'search answer', run: summarizeSearchAnswer },
2752
- { path: 'search contents', run: summarizeSearchContents },
2753
- { path: 'sys setup', run: summarizeSysSetup },
2754
- { path: 'sys doctor', run: summarizeSysDoctor },
2755
- { path: 'sys update', run: summarizeSysUpdate },
2756
- { path: 'sys feedback', run: summarizeSysFeedback },
2757
- { path: 'sys sync skills', run: (text, tokens) => summarizeSysSync(text, 'skills', ['imported', 'would-import', 'skipped', 'ignored', 'would-ignore'], tokens) },
2758
- { path: 'sys sync project-guidance', run: (text, tokens) => summarizeSysSync(text, 'project-guidance', ['imported', 'would-import', 'skipped'], tokens) },
2759
- { path: 'sys sync deps', run: (text, tokens) => summarizeSysSync(text, 'deps', ['imported', 'would-import', 'skipped', 'excluded'], tokens) },
2760
- { path: 'sys logs', run: summarizeSysLogs },
2761
- { path: 'sys support prepare', run: summarizeSysSupportPrepare },
2762
- { path: 'sys support submit', run: summarizeSysSupportSubmit },
2763
- { path: 'surface node focus', run: summarizeSurfaceNodeFocus },
2764
- { path: 'surface edit', run: summarizeSurfaceEdit },
2765
368
  ];
2766
- /** Dispatch a command's argv tokens + its raw result text to the summarizer
2767
- * that owns that leaf path (longest-registered-prefix match, same shape as
2768
- * suppressOutputForPath), or null when no command owns a summary — the
2769
- * caller falls through to generic truncation in that case. */
2770
- export function summarizePath(tokens, rawText) {
2771
- let best;
2772
- for (const { path, run } of SUMMARIZERS) {
2773
- const words = path.split(' ');
2774
- if (words.length <= tokens.length && words.every((word, i) => tokens[i] === word) && (best === undefined || words.length > best.words)) {
2775
- best = { words: words.length, run };
2776
- }
2777
- }
2778
- return best?.run(rawText, tokens) ?? null;
369
+ /** Summarize an exact CLI result record. The recorded path is authoritative:
370
+ * shell tokenization is used only for an invocation's static icon/help chrome,
371
+ * never to identify or decode the returned leaf data. */
372
+ export function summarizePath(preview, tokens = []) {
373
+ const error = summarizeError(preview);
374
+ if (error !== null)
375
+ return error;
376
+ const best = SUMMARIZERS.find(({ path }) => path === preview.path);
377
+ return best?.run(preview, tokens) ?? null;
2779
378
  }