@crouton-kit/crouter 0.3.17 → 0.3.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/builtin-personas/design/{base.md → PERSONA.md} +4 -0
  2. package/dist/builtin-personas/developer/{base.md → PERSONA.md} +4 -0
  3. package/dist/builtin-personas/explore/{base.md → PERSONA.md} +4 -0
  4. package/dist/builtin-personas/general/{base.md → PERSONA.md} +4 -0
  5. package/dist/builtin-personas/plan/{base.md → PERSONA.md} +4 -0
  6. package/dist/builtin-personas/plan/reviewers/architecture-fit/{base.md → PERSONA.md} +1 -1
  7. package/dist/builtin-personas/plan/reviewers/code-smells/{base.md → PERSONA.md} +1 -1
  8. package/dist/builtin-personas/plan/reviewers/pattern-consistency/{base.md → PERSONA.md} +1 -1
  9. package/dist/builtin-personas/plan/reviewers/requirements-coverage/{base.md → PERSONA.md} +1 -1
  10. package/dist/builtin-personas/plan/reviewers/security/{base.md → PERSONA.md} +1 -1
  11. package/dist/builtin-personas/review/{base.md → PERSONA.md} +4 -0
  12. package/dist/builtin-personas/spec/{base.md → PERSONA.md} +4 -0
  13. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +24 -14
  14. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +4 -4
  15. package/dist/commands/daemon.js +1 -1
  16. package/dist/commands/human/prompts.js +3 -9
  17. package/dist/commands/human/shared.d.ts +26 -1
  18. package/dist/commands/human/shared.js +48 -10
  19. package/dist/commands/node.js +53 -4
  20. package/dist/core/__tests__/human-surface-target.test.d.ts +1 -0
  21. package/dist/core/__tests__/human-surface-target.test.js +98 -0
  22. package/dist/core/__tests__/persona-subkind.test.js +18 -15
  23. package/dist/core/canvas/paths.d.ts +4 -1
  24. package/dist/core/canvas/paths.js +10 -4
  25. package/dist/core/canvas/types.js +2 -2
  26. package/dist/core/help.d.ts +6 -0
  27. package/dist/core/help.js +7 -0
  28. package/dist/core/personas/index.d.ts +4 -3
  29. package/dist/core/personas/index.js +3 -2
  30. package/dist/core/personas/loader.d.ts +34 -16
  31. package/dist/core/personas/loader.js +102 -29
  32. package/dist/core/personas/resolve.d.ts +4 -4
  33. package/dist/core/personas/resolve.js +16 -14
  34. package/dist/core/runtime/placement.d.ts +10 -0
  35. package/dist/core/runtime/placement.js +37 -1
  36. package/dist/core/spawn.d.ts +20 -1
  37. package/dist/core/spawn.js +52 -5
  38. package/dist/pi-extensions/canvas-nav.js +77 -30
  39. package/package.json +1 -1
@@ -13,15 +13,60 @@ export function isInTmux() {
13
13
  export function shellQuote(s) {
14
14
  return `'${s.replace(/'/g, "'\\''")}'`;
15
15
  }
16
- /** Count panes in the current tmux window (0 outside tmux / on error). */
17
- export function countPanesInCurrentWindow() {
18
- const result = spawnSync('tmux', ['list-panes', '-F', '#{pane_id}'], {
19
- encoding: 'utf8',
20
- });
16
+ /** Count panes in a tmux window (0 outside tmux / on error). With `targetPane`,
17
+ * counts the window THAT pane lives in (the placement decision must reflect the
18
+ * window the new pane will actually open into, not the caller's backstage one);
19
+ * without it, the caller's current window. */
20
+ export function countPanesInWindow(targetPane) {
21
+ const args = targetPane !== undefined && targetPane !== ''
22
+ ? ['list-panes', '-t', targetPane, '-F', '#{pane_id}']
23
+ : ['list-panes', '-F', '#{pane_id}'];
24
+ const result = spawnSync('tmux', args, { encoding: 'utf8' });
21
25
  if (result.status !== 0)
22
26
  return 0;
23
27
  return result.stdout.split('\n').filter((line) => line.trim() !== '').length;
24
28
  }
29
+ /** Back-compat alias: panes in the caller's current window. */
30
+ export function countPanesInCurrentWindow() {
31
+ return countPanesInWindow();
32
+ }
33
+ /** Does this tmux pane id still exist? `display-message` EXITS 0 with EMPTY
34
+ * output on an unresolvable pane, so test for non-empty stdout, not just `.ok`.
35
+ * False outside tmux / on error. */
36
+ export function paneAlive(pane) {
37
+ if (!isInTmux() || !/^%\d+$/.test(pane))
38
+ return false;
39
+ const r = spawnSync('tmux', ['display-message', '-p', '-t', pane, '#{pane_id}'], {
40
+ encoding: 'utf8',
41
+ });
42
+ return r.status === 0 && r.stdout.trim() !== '';
43
+ }
44
+ /** The active pane of the user's attached tmux client — where they are looking
45
+ * right now. `list-clients` first attached client, then its current pane. Used
46
+ * to surface a human prompt in the user's view when nothing in the asking
47
+ * node's graph is focused. null outside tmux / no client / on error. */
48
+ export function attachedClientPane() {
49
+ if (!isInTmux())
50
+ return null;
51
+ const clients = spawnSync('tmux', ['list-clients', '-F', '#{client_name}'], {
52
+ encoding: 'utf8',
53
+ });
54
+ if (clients.status !== 0)
55
+ return null;
56
+ const name = clients.stdout
57
+ .split('\n')
58
+ .map((l) => l.trim())
59
+ .find((l) => l !== '');
60
+ if (name === undefined)
61
+ return null;
62
+ const pane = spawnSync('tmux', ['display-message', '-p', '-c', name, '#{pane_id}'], {
63
+ encoding: 'utf8',
64
+ });
65
+ if (pane.status !== 0)
66
+ return null;
67
+ const id = pane.stdout.trim();
68
+ return id !== '' ? id : null;
69
+ }
25
70
  /**
26
71
  * Schedule a kill-pane on the *current* tmux pane after `delaySeconds`, detached
27
72
  * so the caller can return normally before the pane dies. No-op outside tmux,
@@ -53,6 +98,8 @@ export function spawnAndDetach(opts) {
53
98
  const splitArgs = [];
54
99
  if (opts.placement === 'new-window') {
55
100
  splitArgs.push('new-window');
101
+ if (opts.detached === true)
102
+ splitArgs.push('-d'); // don't switch the client to it
56
103
  if (opts.targetPane !== undefined && opts.targetPane !== '') {
57
104
  // -a = insert after target window; -t <pane> resolves to that pane's window.
58
105
  splitArgs.push('-a', '-t', opts.targetPane);
@@ -18,9 +18,15 @@
18
18
  // alt+c is a tmux display-menu (not a pi key), so prefix chords (m/e/1-9/custom)
19
19
  // are tmux menu items that route through `crtr canvas chord`.
20
20
  //
21
- // Two selection signals, both NO_COLOR-safe:
22
- // SELF row = reverse video (ESC[7m), full width — an attribute, not a color.
23
- // CURSOR = + bold on the row. Status stays on the colored dot.
21
+ // Selection / liveness signals:
22
+ // CURSOR (selected) = reverse-video bar (ESC[7m), full width — an attribute,
23
+ // not a colour, so it reads under NO_COLOR. Plus a caret.
24
+ // ACTIVE (running) = a coloured background bar (status 'active'); the dot
25
+ // glyph still carries the signal where colour is stripped.
26
+ // SELF = bold name — a quiet "you are here" marker.
27
+ //
28
+ // Folding is auto by default: a branch stays COLLAPSED unless its subtree holds
29
+ // a running ('active') agent or self. h/l override that per-node and persist.
24
30
  //
25
31
  // ⚑K pending-asks is PER-NODE, inline on each waiting node's own row (manager,
26
32
  // reports, tree rows; self shows a trailing ⚑ line in BASE). ⤳M direct-children
@@ -45,10 +51,13 @@ let liveTimer;
45
51
  * exactly one key tap exists (mirrors the liveTimer double-guard). */
46
52
  let liveUnsub;
47
53
  let view = 'base';
48
- /** Fold state node ids whose children are hidden in GRAPH. Survives renders
49
- * AND BASE↔GRAPH toggles. Keyed by id so a topology change never corrupts it;
50
- * stale ids are harmless (ignored when absent). */
51
- const collapsed = new Set();
54
+ /** Manual fold OVERRIDES in GRAPH, keyed by id (so a topology change can't
55
+ * corrupt them; stale ids are ignored). They override the default policy
56
+ * collapsed UNLESS the subtree holds a running ('active') agent or self (see
57
+ * computeDefaultExpanded). `h` collapses → userCollapsed; `l` expands →
58
+ * userExpanded. Both survive renders AND BASE↔GRAPH toggles. */
59
+ const userCollapsed = new Set();
60
+ const userExpanded = new Set();
52
61
  /** GRAPH cursor (a node id, not an index — indices shift as topology changes). */
53
62
  let cursorId;
54
63
  /** GRAPH viewport scroll offset (row index of the top visible row). */
@@ -69,16 +78,19 @@ const PI_MAX_WIDGET_LINES = 10;
69
78
  const VIEWPORT_FALLBACK_ROWS = 30;
70
79
  // ---------------------------------------------------------------------------
71
80
  // ANSI styling. pi renders embedded escapes in widget lines and measures width
72
- // ANSI-aware, so raw escapes are safe and need no pi-tui dependency. Selection
73
- // uses theme-agnostic ATTRIBUTES (reverse / bold), never colour alone, so it
74
- // reads under NO_COLOR and on any background; status uses the standard 8 colors
75
- // on the dot only.
81
+ // ANSI-aware, so raw escapes are safe and need no pi-tui dependency. The cursor
82
+ // (selected row) uses a theme-agnostic ATTRIBUTE (reverse), so it reads under
83
+ // NO_COLOR; the active-row tint is a background COLOUR, but the differing dot
84
+ // glyph (●/○/✓/✗) keeps the running signal even where colour is stripped.
76
85
  // ---------------------------------------------------------------------------
77
86
  const ESC = '\x1b[';
78
87
  const RESET = `${ESC}0m`;
79
88
  const BOLD = `${ESC}1m`;
80
89
  const DIM = `${ESC}2m`;
81
90
  const REVERSE = `${ESC}7m`;
91
+ /** Dark-green background bar marking a running ('active') node — distinct from
92
+ * the cursor's reverse-video bar; chosen so default-fg text stays readable. */
93
+ const BG_ACTIVE = `${ESC}48;5;22m`;
82
94
  const GREEN = `${ESC}32m`;
83
95
  const RED = `${ESC}31m`;
84
96
  const YELLOW = `${ESC}33m`;
@@ -137,15 +149,16 @@ function truncate(s, max = fillWidth()) {
137
149
  function fillWidth() {
138
150
  return Math.max(20, Math.min((process.stdout.columns ?? 80) - 2, 180));
139
151
  }
140
- /** Wrap `content` in a full-width reverse-video bar. REVERSE is re-asserted
141
- * after every embedded RESET so a colored cell (the status dot) doesn't punch
142
- * a hole in the bar; the visible width is padded out to `width`; the line
143
- * closes with a real RESET. */
144
- function reverseFill(content, width) {
152
+ /** Wrap `content` in a full-width background bar opened by `open` (REVERSE for
153
+ * the cursor, BG_ACTIVE for a running node). `open` is re-asserted after every
154
+ * embedded RESET so a coloured cell (the status dot) can't punch a hole in the
155
+ * bar; the visible width is padded out to `width`; the line closes with a real
156
+ * RESET so the style never bleeds into the editor below. */
157
+ function fillBar(content, width, open) {
145
158
  const clipped = truncate(content, width);
146
- const reasserted = clipped.replace(/\x1b\[0m/g, `${RESET}${REVERSE}`);
159
+ const reasserted = clipped.replace(/\x1b\[0m/g, `${RESET}${open}`);
147
160
  const pad = Math.max(0, width - visibleWidth(clipped));
148
- return `${REVERSE}${reasserted}${' '.repeat(pad)}${RESET}`;
161
+ return `${open}${reasserted}${' '.repeat(pad)}${RESET}`;
149
162
  }
150
163
  function readTelemetry(nodeId) {
151
164
  try {
@@ -294,21 +307,48 @@ function statusRank(id) {
294
307
  function sortedChildIds(id) {
295
308
  return convoChildIds(id).sort((a, b) => statusRank(a) - statusRank(b));
296
309
  }
310
+ /** Default fold policy: which nodes auto-EXPAND. A node expands only when one
311
+ * of its child subtrees holds a running ('active') agent or self — so the path
312
+ * to any live agent (and to you) is revealed while quiescent branches stay
313
+ * folded. One bottom-up O(N) pass from the ancestry root; cycle-guarded. */
314
+ function computeDefaultExpanded(root, self) {
315
+ const expand = new Set();
316
+ const seen = new Set();
317
+ // Returns whether subtree(id), INCLUDING id, holds an active node or self.
318
+ const visit = (id) => {
319
+ if (seen.has(id))
320
+ return id === self || getNode(id)?.status === 'active';
321
+ seen.add(id);
322
+ let childRevealing = false;
323
+ for (const c of convoChildIds(id))
324
+ if (visit(c))
325
+ childRevealing = true;
326
+ if (childRevealing)
327
+ expand.add(id); // a descendant is worth revealing → unfold id
328
+ return childRevealing || id === self || getNode(id)?.status === 'active';
329
+ };
330
+ visit(root);
331
+ return expand;
332
+ }
297
333
  function buildGraphModel(self) {
298
334
  const rootId = climbRoot(self);
335
+ const defaultExpanded = computeDefaultExpanded(rootId, self);
336
+ // userExpanded / userCollapsed override the auto policy; absent → policy decides.
337
+ const isFolded = (id) => userExpanded.has(id) ? false : userCollapsed.has(id) ? true : !defaultExpanded.has(id);
299
338
  const rows = [];
300
339
  const visited = new Set();
301
340
  const walk = (id, prefix, isRoot, isLast) => {
302
341
  if (visited.has(id)) {
303
342
  const connector = isRoot ? '' : isLast ? '└─ ' : '├─ ';
304
- rows.push({ id, hasKids: false, isSelf: id === self, branch: prefix + connector, cycle: true });
343
+ rows.push({ id, hasKids: false, isSelf: id === self, branch: prefix + connector, cycle: true, collapsed: false });
305
344
  return;
306
345
  }
307
346
  visited.add(id);
308
347
  const kids = sortedChildIds(id);
348
+ const folded = isFolded(id);
309
349
  const connector = isRoot ? '' : isLast ? '└─ ' : '├─ ';
310
- rows.push({ id, hasKids: kids.length > 0, isSelf: id === self, branch: prefix + connector, cycle: false });
311
- if (collapsed.has(id))
350
+ rows.push({ id, hasKids: kids.length > 0, isSelf: id === self, branch: prefix + connector, cycle: false, collapsed: folded });
351
+ if (folded)
312
352
  return; // folded — don't descend
313
353
  const childPrefix = isRoot ? '' : prefix + (isLast ? ' ' : '│ ');
314
354
  for (let i = 0; i < kids.length; i++)
@@ -317,22 +357,27 @@ function buildGraphModel(self) {
317
357
  walk(rootId, '', true, true);
318
358
  return rows;
319
359
  }
320
- /** Render one GRAPH row. SELF → reverse fill; CURSOR → ▸ + bold caret/name. */
360
+ /** Render one GRAPH row. CURSOR (selected) → reverse-video bar; an ACTIVE
361
+ * (running) node → a coloured background bar; SELF → bold name. The cursor
362
+ * outranks the active tint when both land on the same row. */
321
363
  function renderGraphRow(r, isCursor) {
364
+ const wrap = (line, active) => isCursor ? fillBar(line, fillWidth(), REVERSE)
365
+ : active ? fillBar(line, fillWidth(), BG_ACTIVE)
366
+ : truncate(line);
322
367
  if (r.cycle) {
323
368
  const line = `${r.branch} ${DIM}↺ ${shortId(r.id)}${RESET}`;
324
- return r.isSelf ? reverseFill(line, fillWidth()) : truncate(line);
369
+ return wrap(line, false);
325
370
  }
326
371
  const node = getNode(r.id);
327
372
  const dot = coloredGlyph(node);
328
373
  const rawName = node !== null ? fullName(node) : shortId(r.id);
329
- const name = isCursor ? `${BOLD}${rawName}${RESET}` : rawName;
374
+ const name = r.isSelf ? `${BOLD}${rawName}${RESET}` : rawName;
330
375
  const kind = `${DIM}${node?.kind ?? ''}${RESET}`;
331
376
  const tokens = `${DIM}${tokensCell(r.id)}${RESET}`;
332
377
  const caret = isCursor ? `${BOLD}▸${RESET} ` : ' ';
333
- const fold = r.hasKids && collapsed.has(r.id) ? ` ${DIM}[+${childCount(r.id)}]${RESET}` : '';
378
+ const fold = r.hasKids && r.collapsed ? ` ${DIM}[+${childCount(r.id)}]${RESET}` : '';
334
379
  const line = `${r.branch}${caret}${dot} ${name} ${kind} ${tokens}${childBadge(node)}${fold}${askBadge(r.id)}`;
335
- return r.isSelf ? reverseFill(line, fillWidth()) : truncate(line);
380
+ return wrap(line, node?.status === 'active');
336
381
  }
337
382
  /** Total lines the GRAPH widget may emit. pi hard-caps extension widgets at
338
383
  * MAX_WIDGET_LINES — anything past that pi truncates itself, eating our own
@@ -677,8 +722,9 @@ export function registerCanvasNav(pi) {
677
722
  return { consume: true };
678
723
  }
679
724
  if (isPlain(data, 'h')) {
680
- if (cur !== undefined && cur.hasKids && !collapsed.has(cur.id)) {
681
- collapsed.add(cur.id);
725
+ if (cur !== undefined && cur.hasKids && !cur.collapsed) {
726
+ userCollapsed.add(cur.id);
727
+ userExpanded.delete(cur.id);
682
728
  }
683
729
  else {
684
730
  const p = managerOf(cursorId ?? nodeId);
@@ -689,8 +735,9 @@ export function registerCanvasNav(pi) {
689
735
  return { consume: true };
690
736
  }
691
737
  if (isPlain(data, 'l')) {
692
- if (cur !== undefined && collapsed.has(cur.id)) {
693
- collapsed.delete(cur.id);
738
+ if (cur !== undefined && cur.collapsed && cur.hasKids) {
739
+ userExpanded.add(cur.id);
740
+ userCollapsed.delete(cur.id);
694
741
  }
695
742
  else if (cur !== undefined && cur.hasKids) {
696
743
  const c = sortedChildIds(cur.id)[0];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.17",
3
+ "version": "0.3.18",
4
4
  "description": "crtr — fast access to skills, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",