@crouton-kit/crouter 0.3.12 → 0.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/builtin-personas/runtime-base.md +2 -2
  2. package/dist/commands/__tests__/human.test.js +73 -2
  3. package/dist/commands/human/queue.d.ts +1 -0
  4. package/dist/commands/human/queue.js +89 -2
  5. package/dist/commands/human/shared.d.ts +5 -0
  6. package/dist/commands/human/shared.js +15 -0
  7. package/dist/commands/human.js +4 -2
  8. package/dist/commands/node.js +239 -15
  9. package/dist/core/__tests__/passive-subscription.test.d.ts +1 -0
  10. package/dist/core/__tests__/passive-subscription.test.js +141 -0
  11. package/dist/core/__tests__/subcommand-tier.test.d.ts +1 -0
  12. package/dist/core/__tests__/subcommand-tier.test.js +97 -0
  13. package/dist/core/canvas/paths.d.ts +4 -0
  14. package/dist/core/canvas/paths.js +6 -0
  15. package/dist/core/command.js +40 -7
  16. package/dist/core/feed/feed.js +11 -9
  17. package/dist/core/feed/passive.d.ts +17 -0
  18. package/dist/core/feed/passive.js +79 -0
  19. package/dist/core/help.d.ts +45 -12
  20. package/dist/core/help.js +42 -4
  21. package/dist/core/runtime/demote.d.ts +14 -0
  22. package/dist/core/runtime/demote.js +103 -0
  23. package/dist/core/runtime/kickoff.d.ts +9 -0
  24. package/dist/core/runtime/kickoff.js +19 -1
  25. package/dist/core/runtime/launch.d.ts +12 -1
  26. package/dist/core/runtime/launch.js +18 -2
  27. package/dist/core/runtime/presence.d.ts +1 -1
  28. package/dist/core/runtime/presence.js +6 -4
  29. package/dist/core/runtime/promote.d.ts +4 -0
  30. package/dist/core/runtime/promote.js +21 -6
  31. package/dist/core/runtime/revive.js +6 -8
  32. package/dist/core/runtime/roadmap.d.ts +5 -4
  33. package/dist/core/runtime/roadmap.js +9 -16
  34. package/dist/core/runtime/spawn.d.ts +0 -2
  35. package/dist/core/runtime/spawn.js +26 -16
  36. package/dist/core/runtime/tmux.d.ts +18 -0
  37. package/dist/core/runtime/tmux.js +77 -0
  38. package/dist/pi-extensions/canvas-commands.d.ts +34 -0
  39. package/dist/pi-extensions/canvas-commands.js +100 -0
  40. package/dist/pi-extensions/canvas-goal-capture.d.ts +18 -0
  41. package/dist/pi-extensions/canvas-goal-capture.js +53 -0
  42. package/dist/pi-extensions/canvas-passive-context.d.ts +32 -0
  43. package/dist/pi-extensions/canvas-passive-context.js +114 -0
  44. package/dist/pi-extensions/canvas-stophook.js +42 -19
  45. package/package.json +1 -1
@@ -11,10 +11,11 @@ import { promote, requestYield } from '../core/runtime/promote.js';
11
11
  import { writeYieldMessage } from '../core/runtime/kickoff.js';
12
12
  import { reviveNode } from '../core/runtime/revive.js';
13
13
  import { focusNodeInPlace } from '../core/runtime/presence.js';
14
- import { windowAlive } from '../core/runtime/tmux.js';
14
+ import { demoteNode } from '../core/runtime/demote.js';
15
+ import { windowAlive, windowOfPane, currentTmux } from '../core/runtime/tmux.js';
15
16
  import { appendInbox } from '../core/feed/inbox.js';
16
17
  import { availableKinds } from '../core/personas/index.js';
17
- import { getNode, listNodes, subscriptionsOf, subscribersOf, } from '../core/canvas/index.js';
18
+ import { getNode, listNodes, subscribe, unsubscribe, subscriptionsOf, subscribersOf, } from '../core/canvas/index.js';
18
19
  /** Validate a `--kind` against the installed personas; throws a listing InputError. */
19
20
  function assertKind(kind) {
20
21
  const kinds = availableKinds();
@@ -42,9 +43,9 @@ const nodeNew = defineLeaf({
42
43
  { name: 'node_id', type: 'string', required: true, constraint: 'The new node id.' },
43
44
  { name: 'name', type: 'string', required: true, constraint: 'Display name.' },
44
45
  { name: 'window', type: 'string', required: false, constraint: 'tmux window id of the background window.' },
45
- { name: 'session', type: 'string', required: true, constraint: 'Root tmux session the node was placed in.' },
46
+ { name: 'session', type: 'string', required: true, constraint: 'The shared crtr tmux session the node was placed in.' },
46
47
  { name: 'status', type: 'string', required: true, constraint: 'Always "active" on spawn.' },
47
- { name: 'follow_up', type: 'string', required: true, constraint: 'A notification to the caller about the spawn: the child runs independently and its finish wakes you automatically, so treat it as fire-and-forget. Read it, then act.' },
48
+ { name: 'follow_up', type: 'string', required: true, constraint: 'Decision road sign for the caller: the child runs independently and its finish wakes you on its own, so never wait or poll on it either pick up other work now or end your turn. Read it, then act.' },
48
49
  ],
49
50
  outputKind: 'object',
50
51
  effects: [
@@ -70,7 +71,7 @@ const nodeNew = defineLeaf({
70
71
  window: res.window ?? undefined,
71
72
  session: res.session,
72
73
  status: res.node.status,
73
- follow_up: "Notification only — you're auto-subscribed, so the child's finish wakes you automatically; treat it as fire-and-forget. Carry on with other independent work now, or stop and end your turn. On wake: `crtr feed read`.",
74
+ follow_up: "Do not wait or poll on this child there is no result to await and stopping will not strand you. You're auto-subscribed, so its finish wakes you on its own. Two moves only: pick up other independent work right now, or stop and end your turn the wake brings you back. Sitting idle to watch it is wasted; pick one and act.",
74
75
  };
75
76
  },
76
77
  render: (r) => `<spawned name="${r['name']}" id="${r['node_id']}" status="${r['status']}">\n${r['follow_up']}\n</spawned>`,
@@ -182,13 +183,159 @@ const nodeFocus = defineLeaf({
182
183
  },
183
184
  });
184
185
  // ---------------------------------------------------------------------------
186
+ // node demote — detach the agent in your pane to the background session
187
+ // ---------------------------------------------------------------------------
188
+ /** First live node whose window id is `win` (each node owns one window). The
189
+ * queryable row projection omits `window`, so resolve full meta per candidate. */
190
+ function nodeByWindow(win) {
191
+ for (const row of listNodes({ status: ['active', 'idle'] })) {
192
+ if (getNode(row.node_id)?.window === win)
193
+ return row.node_id;
194
+ }
195
+ return undefined;
196
+ }
197
+ /** The live node occupying a tmux pane (pane → window → node), or undefined.
198
+ * Defaults to $TMUX_PANE / the caller's current pane when `pane` is omitted —
199
+ * shared by `node demote` and `node cycle`, both of which act on "the agent in
200
+ * front of you". */
201
+ function nodeInPane(pane) {
202
+ const resolvePane = pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
203
+ const win = resolvePane !== undefined && resolvePane !== '' ? windowOfPane(resolvePane) : null;
204
+ return win !== null ? nodeByWindow(win) : undefined;
205
+ }
206
+ const nodeDemote = defineLeaf({
207
+ name: 'demote',
208
+ help: {
209
+ name: 'node demote',
210
+ summary: 'finish the agent in your current pane and recycle the pane — push its last message as a final report to everyone waiting on it, mark it done, then boot a fresh crtr root in the same pane',
211
+ params: [
212
+ { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to finish. Defaults to the node occupying --pane (or your current pane).' },
213
+ { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id to recycle. Defaults to $TMUX_PANE / your current pane. The Alt+C menu passes this for you.' },
214
+ ],
215
+ output: [
216
+ { name: 'demoted', type: 'boolean', required: true, constraint: 'True when the pane was recycled into a fresh root.' },
217
+ { name: 'node_id', type: 'string', required: false, constraint: 'The finished node.' },
218
+ { name: 'finalized', type: 'boolean', required: false, constraint: 'True when a final report was pushed to its subscribers.' },
219
+ { name: 'delivered', type: 'number', required: false, constraint: 'How many subscribers/managers received the final report.' },
220
+ { name: 'new_root', type: 'string', required: false, constraint: 'The fresh root node booted into the pane.' },
221
+ ],
222
+ outputKind: 'object',
223
+ effects: ['Pushes a final report from the node (fans out to all subscribers) and marks it done.', 'Kills the agent\'s pi and respawns a fresh resident root in the same tmux pane.'],
224
+ },
225
+ run: async (input) => {
226
+ const pane = input['pane'] ?? process.env['TMUX_PANE'];
227
+ let id = input['node'];
228
+ if (id === undefined || id === '') {
229
+ // Derive the node from the pane: which node's window holds it?
230
+ id = nodeInPane(pane);
231
+ }
232
+ if (id === undefined || id === '') {
233
+ throw new InputError({ error: 'no_node', message: 'no node found in this pane to finish', next: 'Pass --node <id>, or run from inside the agent\'s pane.' });
234
+ }
235
+ if (getNode(id) === null) {
236
+ throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
237
+ }
238
+ const res = await demoteNode(id, pane);
239
+ return { demoted: res.demoted, node_id: id, finalized: res.finalized, delivered: res.delivered.length, new_root: res.newRoot ?? undefined };
240
+ },
241
+ render: (r) => r['demoted'] === true
242
+ ? `<demoted id="${r['node_id']}" finalized="${r['finalized']}" delivered="${r['delivered']}" new_root="${r['new_root'] ?? ''}"/>`
243
+ : `<demote-failed id="${r['node_id'] ?? ''}">not in tmux, or no agent in this pane</demote-failed>`,
244
+ });
245
+ // ---------------------------------------------------------------------------
246
+ // node cycle — DFS-walk the canvas one window at a time (Alt+] / Alt+[)
247
+ // ---------------------------------------------------------------------------
248
+ /** Every live node in DFS pre-order across the whole forest. The spawn tree is
249
+ * the `parent` field; children inherit their parent's row order (created), so
250
+ * the walk descends into a node's children before moving to its siblings —
251
+ * exactly "next in pre-order is your first child". Roots are live nodes with no
252
+ * live parent (a done/dead parent orphans its live children up to the top).
253
+ * Cycle-safe: a final pass appends any node a cycle kept from being reached. */
254
+ function liveDfsOrder() {
255
+ const rows = listNodes({ status: ['active', 'idle'] }); // ORDER BY created
256
+ const liveIds = new Set(rows.map((r) => r.node_id));
257
+ const childrenOf = new Map();
258
+ for (const r of rows) {
259
+ const p = r.parent;
260
+ if (p != null && liveIds.has(p)) {
261
+ const arr = childrenOf.get(p) ?? [];
262
+ arr.push(r.node_id);
263
+ childrenOf.set(p, arr);
264
+ }
265
+ }
266
+ const out = [];
267
+ const seen = new Set();
268
+ const visit = (id) => {
269
+ if (seen.has(id))
270
+ return;
271
+ seen.add(id);
272
+ out.push(id);
273
+ for (const c of childrenOf.get(id) ?? [])
274
+ visit(c);
275
+ };
276
+ for (const r of rows)
277
+ if (r.parent == null || !liveIds.has(r.parent))
278
+ visit(r.node_id);
279
+ for (const r of rows)
280
+ visit(r.node_id); // stragglers (parent cycles)
281
+ return out;
282
+ }
283
+ const nodeCycle = defineLeaf({
284
+ name: 'cycle',
285
+ help: {
286
+ name: 'node cycle',
287
+ summary: 'focus the next/previous live node in DFS pre-order — the canvas walked one window at a time, descending into a node\'s children before its siblings (bound to Alt+] forward / Alt+[ back)',
288
+ params: [
289
+ { kind: 'flag', name: 'dir', type: 'enum', choices: ['next', 'prev'], required: false, default: 'next', constraint: 'Direction along the pre-order: next (Alt+], rightward/deeper into children) or prev (Alt+[, back). Wraps at the ends.' },
290
+ { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane to cycle FROM. Defaults to $TMUX_PANE / your current pane. The Alt+] / Alt+[ bindings pass this for you.' },
291
+ ],
292
+ output: [
293
+ { name: 'focused', type: 'boolean', required: true, constraint: 'True when the neighbor was brought into view.' },
294
+ { name: 'node_id', type: 'string', required: false, constraint: 'The node now in front of you.' },
295
+ { name: 'name', type: 'string', required: false, constraint: 'Its display name.' },
296
+ { name: 'from', type: 'string', required: false, constraint: 'The node you cycled away from.' },
297
+ ],
298
+ outputKind: 'object',
299
+ effects: ['Swaps the neighbor\'s pane into the caller pane (like `node focus`); the node you were viewing drops to the background.', 'Revives the neighbor first if its window was released.'],
300
+ },
301
+ run: async (input) => {
302
+ const pane = input['pane'] ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane ?? undefined;
303
+ const dir = (input['dir'] ?? 'next');
304
+ const fromId = nodeInPane(pane);
305
+ if (fromId === undefined)
306
+ return { focused: false };
307
+ const order = liveDfsOrder();
308
+ const i = order.indexOf(fromId);
309
+ if (i === -1 || order.length < 2)
310
+ return { focused: false, node_id: fromId, from: fromId };
311
+ const step = dir === 'next' ? 1 : -1;
312
+ const targetId = order[(i + step + order.length) % order.length];
313
+ const target = getNode(targetId);
314
+ if (target === null)
315
+ return { focused: false, from: fromId };
316
+ // A live node may have had its window released — revive (resume) so there is
317
+ // a window to swap in, mirroring `node focus`.
318
+ if (!windowAlive(target.tmux_session, target.window)) {
319
+ try {
320
+ reviveNode(targetId, { resume: true });
321
+ }
322
+ catch { /* fall through */ }
323
+ }
324
+ const res = focusNodeInPlace(targetId, pane, fromId);
325
+ return { focused: res.focused, node_id: targetId, name: target.name, from: fromId };
326
+ },
327
+ render: (r) => r['focused'] === true
328
+ ? `<cycled to="${r['node_id']}" name="${r['name'] ?? ''}" from="${r['from'] ?? ''}"/>`
329
+ : `<cycle-noop>no other live node to focus</cycle-noop>`,
330
+ });
331
+ // ---------------------------------------------------------------------------
185
332
  // node session — boot a NEW root in its own tmux session (the explicit form)
186
333
  // ---------------------------------------------------------------------------
187
334
  const nodeSession = defineLeaf({
188
335
  name: 'session',
189
336
  help: {
190
337
  name: 'node session',
191
- summary: 'start a fresh root node in its own tmux session and switch to it (use from inside a node to start a new root without taking your pane)',
338
+ summary: 'start a fresh root node as its own window in the shared crtr session (use from inside a node to start a new root without taking your pane)',
192
339
  params: [
193
340
  { kind: 'stdin', name: 'prompt', required: false, constraint: 'Optional starter prompt; a root needs none.' },
194
341
  { kind: 'flag', name: 'kind', type: 'string', required: false, default: 'general', constraint: 'Persona kind for the root.' },
@@ -197,11 +344,11 @@ const nodeSession = defineLeaf({
197
344
  ],
198
345
  output: [
199
346
  { name: 'node_id', type: 'string', required: true, constraint: 'The root node id.' },
200
- { name: 'session', type: 'string', required: true, constraint: 'The dedicated tmux session created for this root.' },
347
+ { name: 'session', type: 'string', required: true, constraint: 'The shared crtr tmux session this root\'s window was placed in.' },
201
348
  { name: 'window', type: 'string', required: false, constraint: 'The root node\'s window id.' },
202
349
  ],
203
350
  outputKind: 'object',
204
- effects: ['Creates a detached tmux session and runs pi in it as a resident root node.'],
351
+ effects: ['Opens a detached window in the shared crtr session and runs pi in it as a resident root node.'],
205
352
  },
206
353
  run: async (input) => {
207
354
  const prompt = input['prompt'];
@@ -258,15 +405,86 @@ const nodeMsg = defineLeaf({
258
405
  },
259
406
  });
260
407
  // ---------------------------------------------------------------------------
408
+ // node subscribe / unsubscribe — wire the subscribes_to spine between any pair
409
+ // ---------------------------------------------------------------------------
410
+ /** Resolve the subscriber: explicit --subscriber wins, else the calling node. */
411
+ function resolveSubscriber(input) {
412
+ const sub = input['subscriber'] ?? process.env['CRTR_NODE_ID'];
413
+ if (sub === undefined || sub === '') {
414
+ throw new InputError({ error: 'no_subscriber', message: 'no subscriber (set CRTR_NODE_ID or pass --subscriber)', field: 'subscriber', next: 'Run from inside a node, or pass --subscriber <id>.' });
415
+ }
416
+ return sub;
417
+ }
418
+ const nodeSubscribe = defineLeaf({
419
+ name: 'subscribe',
420
+ help: {
421
+ name: 'node subscribe',
422
+ summary: 'wire a subscribes_to edge so one node receives another\'s pushes — the subscriber can be you (default) or, with --subscriber, ANY node, to ANY publisher. Re-running flips an existing edge\'s active/passive mode.',
423
+ params: [
424
+ { kind: 'positional', name: 'publisher', required: true, constraint: 'The node to subscribe TO — whose pushes get delivered to the subscriber.' },
425
+ { kind: 'flag', name: 'subscriber', type: 'string', required: false, constraint: 'Who receives the pushes. Defaults to the calling node (CRTR_NODE_ID). Pass any node id to wire a third party.' },
426
+ { kind: 'flag', name: 'passive', type: 'bool', required: false, constraint: 'Passive subscription: pushes ACCUMULATE without waking the subscriber, then auto-inject as timestamped XML pre-text on its next message. Omit for an active (wake-on-push) subscription.' },
427
+ ],
428
+ output: [
429
+ { name: 'subscribed', type: 'boolean', required: true, constraint: 'True when the edge was created/updated.' },
430
+ { name: 'subscriber', type: 'string', required: true, constraint: 'The receiving node.' },
431
+ { name: 'publisher', type: 'string', required: true, constraint: 'The node being subscribed to.' },
432
+ { name: 'mode', type: 'string', required: true, constraint: '"active" (wakes on push) or "passive" (accumulates, no wake).' },
433
+ ],
434
+ outputKind: 'object',
435
+ effects: ['Upserts a subscribes_to edge in canvas.db (active flag set from --passive).', 'Passive edges never wake the subscriber and do not hold it alive (excluded from the stop-guard).'],
436
+ },
437
+ run: async (input) => {
438
+ const publisher = input['publisher'];
439
+ const subscriber = resolveSubscriber(input);
440
+ const passive = input['passive'] === true;
441
+ if (subscriber === publisher) {
442
+ throw new InputError({ error: 'self_subscribe', message: 'a node cannot subscribe to itself', next: 'Pick a different publisher.' });
443
+ }
444
+ if (getNode(subscriber) === null)
445
+ throw new InputError({ error: 'not_found', message: `no node: ${subscriber}`, field: 'subscriber', next: 'List nodes with `crtr node inspect list`.' });
446
+ if (getNode(publisher) === null)
447
+ throw new InputError({ error: 'not_found', message: `no node: ${publisher}`, field: 'publisher', next: 'List nodes with `crtr node inspect list`.' });
448
+ subscribe(subscriber, publisher, !passive);
449
+ return { subscribed: true, subscriber, publisher, mode: passive ? 'passive' : 'active' };
450
+ },
451
+ render: (r) => `<subscribed subscriber="${r['subscriber']}" publisher="${r['publisher']}" mode="${r['mode']}"/>`,
452
+ });
453
+ const nodeUnsubscribe = defineLeaf({
454
+ name: 'unsubscribe',
455
+ help: {
456
+ name: 'node unsubscribe',
457
+ summary: 'drop a subscribes_to edge — the subscriber (you by default, or any node via --subscriber) stops receiving the publisher\'s pushes.',
458
+ params: [
459
+ { kind: 'positional', name: 'publisher', required: true, constraint: 'The node to stop subscribing to.' },
460
+ { kind: 'flag', name: 'subscriber', type: 'string', required: false, constraint: 'Who to detach. Defaults to the calling node (CRTR_NODE_ID).' },
461
+ ],
462
+ output: [
463
+ { name: 'unsubscribed', type: 'boolean', required: true, constraint: 'True when the edge was removed (idempotent — also true if none existed).' },
464
+ { name: 'subscriber', type: 'string', required: true, constraint: 'The detached node.' },
465
+ { name: 'publisher', type: 'string', required: true, constraint: 'The node it stopped subscribing to.' },
466
+ ],
467
+ outputKind: 'object',
468
+ effects: ['Deletes the subscribes_to edge from canvas.db.'],
469
+ },
470
+ run: async (input) => {
471
+ const publisher = input['publisher'];
472
+ const subscriber = resolveSubscriber(input);
473
+ unsubscribe(subscriber, publisher);
474
+ return { unsubscribed: true, subscriber, publisher };
475
+ },
476
+ render: (r) => `<unsubscribed subscriber="${r['subscriber']}" publisher="${r['publisher']}"/>`,
477
+ });
478
+ // ---------------------------------------------------------------------------
261
479
  // node promote — become a resident orchestrator (terminal → resident polymorph)
262
480
  // ---------------------------------------------------------------------------
263
481
  const nodePromote = defineLeaf({
264
482
  name: 'promote',
265
483
  help: {
266
484
  name: 'node promote',
267
- summary: 'promote yourself to a resident orchestrator of a chosen kind flips to that kind\'s orchestrator persona on next revive, dumps its orchestration + roadmap-shaping guidance now, and seeds a roadmap scaffold for you to author',
485
+ summary: 'promote yourself to a resident orchestrator do this when your task outgrows one context window (many phases to delegate and persist across refreshes); not for work that fits one window, and not merely because you spawned a child',
268
486
  params: [
269
- { kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Specialize as this kind of orchestrator: developer (own feature delivery), review, spec, design, plan, explore, general. Defaults to your current kind. Promoting from a generic kind? CHOOSE a concrete one — it picks the orchestrator persona you revive into and the roadmap-shaping skill dumped now.' },
487
+ { kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Specialize as this kind of orchestrator: developer (own feature delivery), review, spec, design, plan, explore, general. Defaults to your current kind. Promoting from a generic kind? CHOOSE a concrete one — it sets the orchestrator persona you revive into.' },
270
488
  { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to promote. Defaults to the caller (CRTR_NODE_ID).' },
271
489
  ],
272
490
  output: [
@@ -274,7 +492,9 @@ const nodePromote = defineLeaf({
274
492
  { name: 'kind', type: 'string', required: true, constraint: 'The kind it now orchestrates as.' },
275
493
  { name: 'mode', type: 'string', required: true, constraint: 'Now "orchestrator".' },
276
494
  { name: 'roadmap_written', type: 'boolean', required: true, constraint: 'True if a roadmap scaffold was seeded by this call.' },
277
- { name: 'guidance', type: 'string', required: true, constraint: 'Kind-specific orchestration + roadmap-shaping guidance and your roadmap scaffold read it, then AUTHOR your roadmap (goal, exit criteria, phases) this turn before delegating.' },
495
+ { name: 'roadmap_path', type: 'string', required: true, constraint: 'Absolute path to your roadmap doc (context/roadmap.md)edit it to author your plan.' },
496
+ { name: 'goal_path', type: 'string', required: true, constraint: 'Absolute path to your goal doc (context/initial-prompt.md) — the mandate you were spawned with.' },
497
+ { name: 'guidance', type: 'string', required: true, constraint: 'Instructions for your new role — read and act on them this turn.' },
278
498
  ],
279
499
  outputKind: 'object',
280
500
  effects: ['Flips lifecycle→resident, mode→orchestrator, kind→chosen; rewrites the launch spec to that kind\'s orchestrator persona; seeds context/roadmap.md scaffold if absent.'],
@@ -287,7 +507,7 @@ const nodePromote = defineLeaf({
287
507
  if (kind !== undefined)
288
508
  assertKind(kind);
289
509
  const res = promote(id, kind !== undefined ? { kind } : {});
290
- return { node_id: res.meta.node_id, kind: res.meta.kind, mode: res.meta.mode, roadmap_written: res.roadmapWritten, guidance: res.guidance };
510
+ return { node_id: res.meta.node_id, kind: res.meta.kind, mode: res.meta.mode, roadmap_written: res.roadmapWritten, roadmap_path: res.roadmapPath, goal_path: res.goalPath, guidance: res.guidance };
291
511
  },
292
512
  });
293
513
  // ---------------------------------------------------------------------------
@@ -340,15 +560,19 @@ export function registerNode() {
340
560
  'HOW: `crtr node new "<task>" --kind <kind>` returns a node id immediately and runs the worker in a background window. Match the kind to the work (see `node new -h`). You are woken when a child finishes; absorb what your children reported with `crtr feed read` (coalesced pointers — dereference the report paths that matter, don\'t act on a one-line summary). Integrate, then either delegate the next units or finish.\n\n' +
341
561
  'FINISH: a worker ends its own work with `crtr push final "<result>"` (writes the canonical result, marks done, closes the window) — stopping without it is not finishing. For a job too big for one context window, `node promote` to a resident orchestrator (holds a roadmap, delegates phases); when context fills, `node yield` to refresh against that roadmap.',
342
562
  children: [
343
- { name: 'new', desc: 'spawn a terminal worker as a background window', useWhen: 'delegating a self-contained unit of work' },
563
+ { name: 'new', desc: 'spawn a terminal worker as a background window', useWhen: 'delegating a self-contained unit of work', tier: 'important' },
344
564
  { name: 'inspect', desc: 'read the graph (list nodes / show one)', useWhen: 'surveying the canvas or inspecting a node' },
345
565
  { name: 'focus', desc: 'bring a node window forefront', useWhen: 'jumping to a node to watch or steer it' },
566
+ { name: 'cycle', desc: 'DFS-walk to the next/prev live node in place', useWhen: 'sweeping the canvas one window at a time (Alt+] forward / Alt+[ back)' },
567
+ { name: 'demote', desc: 'finish the agent in your pane + recycle it into a fresh root', useWhen: 'wrapping up the agent in front of you and starting fresh (Alt+C → d)' },
346
568
  { name: 'session', desc: 'open a fresh root in its own tmux session', useWhen: 'starting a new top-level session from inside a node' },
347
569
  { name: 'msg', desc: 'direct-message any node at a wake tier', useWhen: 'steering or pinging a specific node (wakes it)' },
348
- { name: 'promote', desc: 'become a resident orchestrator of a chosen kind', useWhen: 'your task is bigger than one context window and you must delegate + persist' },
570
+ { name: 'subscribe', desc: 'wire a subscribes_to edge between any pair (active or --passive)', useWhen: 'making a node (you or another) receive another node\'s pushes' },
571
+ { name: 'unsubscribe', desc: 'drop a subscribes_to edge', useWhen: 'detaching a subscriber from a publisher' },
572
+ { name: 'promote', desc: 'become a resident orchestrator of a chosen kind', useWhen: 'your task is bigger than one context window and you must delegate + persist', tier: 'important' },
349
573
  { name: 'yield', desc: 'refresh your context against your roadmap', useWhen: 'your context window is filling up' },
350
574
  ],
351
575
  },
352
- children: [nodeNew, nodeInspect, nodeFocus, nodeSession, nodeMsg, nodePromote, nodeYield],
576
+ children: [nodeNew, nodeInspect, nodeFocus, nodeCycle, nodeDemote, nodeSession, nodeMsg, nodeSubscribe, nodeUnsubscribe, nodePromote, nodeYield],
353
577
  });
354
578
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,141 @@
1
+ // End-to-end tests for passive subscriptions:
2
+ // 1. A passive subscriber's pushes land in passive.jsonl, NOT inbox.jsonl
3
+ // (so the inbox-watcher never wakes it); an active subscriber's land in
4
+ // inbox.jsonl as before.
5
+ // 2. drainPassive reads + clears the accumulator (surfaces exactly once).
6
+ // 3. canvas-passive-context formats drained entries as timestamped XML and
7
+ // transforms an `input` event into pre-text + the original message.
8
+ //
9
+ // Run: node --import tsx/esm --test src/core/__tests__/passive-subscription.test.ts
10
+ import { test, before, beforeEach, after } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { createNode, subscribe } from '../canvas/canvas.js';
16
+ import { closeDb } from '../canvas/db.js';
17
+ import { inboxPath, passivePath } from '../canvas/paths.js';
18
+ import { push } from '../feed/feed.js';
19
+ import { appendPassive, readPassive, drainPassive } from '../feed/passive.js';
20
+ import { readInboxSince } from '../feed/inbox.js';
21
+ import registerCanvasPassiveContext, { formatPassive } from '../../pi-extensions/canvas-passive-context.js';
22
+ let home;
23
+ function node(id, over = {}) {
24
+ return {
25
+ node_id: id,
26
+ name: id,
27
+ created: new Date().toISOString(),
28
+ cwd: '/tmp/work',
29
+ kind: 'general',
30
+ mode: 'base',
31
+ lifecycle: 'terminal',
32
+ status: 'active',
33
+ ...over,
34
+ };
35
+ }
36
+ before(() => {
37
+ home = mkdtempSync(join(tmpdir(), 'crtr-passive-'));
38
+ process.env['CRTR_HOME'] = home;
39
+ });
40
+ beforeEach(() => {
41
+ closeDb();
42
+ rmSync(home, { recursive: true, force: true });
43
+ });
44
+ after(() => {
45
+ closeDb();
46
+ rmSync(home, { recursive: true, force: true });
47
+ delete process.env['CRTR_HOME'];
48
+ delete process.env['CRTR_NODE_ID'];
49
+ });
50
+ test('passive push accumulates in passive.jsonl, not inbox.jsonl', async () => {
51
+ createNode(node('pub'));
52
+ createNode(node('observer'));
53
+ subscribe('observer', 'pub', false); // PASSIVE
54
+ await push('pub', { kind: 'update', body: 'first observation\nmore detail' });
55
+ // No inbox entry → the inbox-watcher would never see it → no wake.
56
+ assert.equal(existsSync(inboxPath('observer')), false);
57
+ assert.equal(readInboxSince('observer').length, 0);
58
+ // It landed in the passive accumulator instead.
59
+ const acc = readPassive('observer');
60
+ assert.equal(acc.length, 1);
61
+ assert.equal(acc[0].from, 'pub');
62
+ assert.equal(acc[0].label, 'first observation');
63
+ assert.ok(acc[0].ref && acc[0].ref.endsWith('-update.md'));
64
+ });
65
+ test('active push still lands in inbox.jsonl (wakes)', async () => {
66
+ createNode(node('pub'));
67
+ createNode(node('worker-mgr'));
68
+ subscribe('worker-mgr', 'pub', true); // ACTIVE
69
+ await push('pub', { kind: 'update', body: 'active report' });
70
+ assert.equal(existsSync(passivePath('worker-mgr')), false);
71
+ const inbox = readInboxSince('worker-mgr');
72
+ assert.equal(inbox.length, 1);
73
+ assert.equal(inbox[0].from, 'pub');
74
+ });
75
+ test('mixed active + passive subscribers route to their own stores', async () => {
76
+ createNode(node('pub'));
77
+ createNode(node('active-sub'));
78
+ createNode(node('passive-sub'));
79
+ subscribe('active-sub', 'pub', true);
80
+ subscribe('passive-sub', 'pub', false);
81
+ const res = await push('pub', { kind: 'urgent', body: 'something happened' });
82
+ assert.deepEqual(new Set(res.deliveredTo), new Set(['active-sub', 'passive-sub']));
83
+ assert.equal(readInboxSince('active-sub').length, 1);
84
+ assert.equal(existsSync(passivePath('active-sub')), false);
85
+ assert.equal(readPassive('passive-sub').length, 1);
86
+ assert.equal(existsSync(inboxPath('passive-sub')), false);
87
+ });
88
+ test('drainPassive reads then clears (surfaces exactly once)', () => {
89
+ createNode(node('observer'));
90
+ appendPassive('observer', { from: 'a', tier: 'normal', kind: 'update', label: 'one' });
91
+ appendPassive('observer', { from: 'b', tier: 'normal', kind: 'update', label: 'two' });
92
+ const drained = drainPassive('observer');
93
+ assert.equal(drained.length, 2);
94
+ assert.deepEqual(drained.map((e) => e.label), ['one', 'two']); // oldest first
95
+ // Cleared — a second drain is empty.
96
+ assert.equal(drainPassive('observer').length, 0);
97
+ assert.equal(readPassive('observer').length, 0);
98
+ });
99
+ test('formatPassive renders timestamped XML update blocks', () => {
100
+ const entries = [
101
+ { ts: '2026-06-03T12:00:00.000Z', from: 'pub-a', tier: 'normal', kind: 'update', label: 'alpha happened' },
102
+ { ts: '2026-06-03T12:05:00.000Z', from: 'pub-b', tier: 'urgent', kind: 'final', label: 'beta done' },
103
+ ];
104
+ const xml = formatPassive(entries);
105
+ assert.match(xml, /<passive-subscription-backlog count="2"/);
106
+ assert.match(xml, /<update from="pub-a" kind="update" at="2026-06-03T12:00:00.000Z">/);
107
+ assert.match(xml, /alpha happened/);
108
+ assert.match(xml, /<update from="pub-b" kind="final" at="2026-06-03T12:05:00.000Z">/);
109
+ assert.match(xml, /<\/passive-subscription-backlog>/);
110
+ });
111
+ function makeFakePi() {
112
+ return { on(e, h) { if (e === 'input')
113
+ this.handler = h; } };
114
+ }
115
+ test('input handler injects drained backlog as pre-text, then clears it', async () => {
116
+ createNode(node('pub'));
117
+ createNode(node('observer'));
118
+ subscribe('observer', 'pub', false);
119
+ await push('pub', { kind: 'update', body: 'the body of the report\nsecond line' });
120
+ process.env['CRTR_NODE_ID'] = 'observer';
121
+ const pi = makeFakePi();
122
+ registerCanvasPassiveContext(pi);
123
+ assert.ok(pi.handler, 'input handler registered');
124
+ // First message → backlog drains in as pre-text before the user's text.
125
+ const out = pi.handler({ type: 'input', text: 'hey what happened', source: 'interactive' });
126
+ assert.equal(out.action, 'transform');
127
+ assert.match(out.text, /<passive-subscription-backlog/);
128
+ assert.match(out.text, /the body of the report/); // dereferenced report body
129
+ assert.match(out.text, /hey what happened$/); // original message preserved at the end
130
+ // Second message → nothing accumulated → left untouched.
131
+ const out2 = pi.handler({ type: 'input', text: 'still there?', source: 'interactive' });
132
+ assert.ok(out2 === undefined || out2.action === 'continue');
133
+ });
134
+ test('input handler is inert when nothing is accumulated', () => {
135
+ createNode(node('observer'));
136
+ process.env['CRTR_NODE_ID'] = 'observer';
137
+ const pi = makeFakePi();
138
+ registerCanvasPassiveContext(pi);
139
+ const out = pi.handler({ type: 'input', text: 'plain message', source: 'interactive' });
140
+ assert.ok(out === undefined || out.action === 'continue');
141
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,97 @@
1
+ // Tests for the subcommand visibility tier (hidden | normal | common | important)
2
+ // and the "[+N subcommands]" affordance.
3
+ // Run with: node --import tsx/esm --test src/core/__tests__/subcommand-tier.test.ts
4
+ //
5
+ // Contract:
6
+ // - renderRoot promotes a subtree's `important` children (name + shortform
7
+ // desc) and `common` children (bare qualified path) into that command's
8
+ // block, then names how many other non-hidden subcommands stay behind
9
+ // `crtr <name> -h`.
10
+ // - `hidden` children never appear (not even in the subtree's own -h) and are
11
+ // not counted in any "[+N]" remainder.
12
+ // - renderBranch drops hidden children and flags branch children that own
13
+ // subcommands with "[+N subcommands]".
14
+ import { test, describe } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import { defineRoot, defineBranch, defineLeaf } from '../command.js';
17
+ import { renderRoot, renderBranch } from '../help.js';
18
+ const leaf = (name) => defineLeaf({
19
+ name,
20
+ help: { name, summary: name, output: [], outputKind: 'object', effects: ['None. Read-only.'] },
21
+ run: async () => ({}),
22
+ });
23
+ // A nested branch so we can assert the "[+N subcommands]" depth flag.
24
+ const inspect = defineBranch({
25
+ name: 'inspect',
26
+ help: {
27
+ name: 'thing inspect',
28
+ summary: 'inspect',
29
+ children: [
30
+ { name: 'list', desc: 'list', useWhen: 'x' },
31
+ { name: 'show', desc: 'show', useWhen: 'x' },
32
+ ],
33
+ },
34
+ children: [leaf('list'), leaf('show')],
35
+ });
36
+ const thing = defineBranch({
37
+ name: 'thing',
38
+ rootEntry: { concept: 'a thing', desc: 'things', useWhen: 'doing things' },
39
+ help: {
40
+ name: 'thing',
41
+ summary: 'do things',
42
+ children: [
43
+ { name: 'make', desc: 'make a thing', useWhen: 'x', tier: 'important' },
44
+ { name: 'promote', desc: 'promote a thing', useWhen: 'x', tier: 'common' },
45
+ { name: 'inspect', desc: 'inspect things', useWhen: 'x' },
46
+ { name: 'secret', desc: 'secret op', useWhen: 'x', tier: 'hidden' },
47
+ { name: 'plain', desc: 'plain op', useWhen: 'x' },
48
+ ],
49
+ },
50
+ children: [leaf('make'), leaf('promote'), inspect, leaf('secret'), leaf('plain')],
51
+ });
52
+ const root = defineRoot({ tagline: 'test runtime', globals: [], subtrees: [thing] });
53
+ describe('renderRoot: subcommand promotion', () => {
54
+ const out = renderRoot(root.help);
55
+ test('important child surfaces with its shortform desc', () => {
56
+ assert.match(out, /\n {2}thing make {2,}make a thing\n/);
57
+ });
58
+ test('common child surfaces as a bare qualified path (no desc)', () => {
59
+ assert.match(out, /\n {2}thing promote\n/);
60
+ assert.doesNotMatch(out, /thing promote {2,}promote a thing/);
61
+ });
62
+ test('hidden child is never promoted and not counted', () => {
63
+ assert.doesNotMatch(out, /secret/);
64
+ // 5 children, 1 hidden => 4 listable, 2 promoted => 2 remaining.
65
+ assert.match(out, /\[\+2 other subcommands — `crtr thing -h`\]/);
66
+ });
67
+ });
68
+ describe('renderRoot: commands with no promotions', () => {
69
+ test('still advertise their subcommand count', () => {
70
+ const bare = defineBranch({
71
+ name: 'bare',
72
+ rootEntry: { concept: 'bare', desc: 'bare', useWhen: 'x' },
73
+ help: { name: 'bare', summary: 'bare', children: [{ name: 'one', desc: 'one', useWhen: 'x' }] },
74
+ children: [leaf('one')],
75
+ });
76
+ const r = defineRoot({ tagline: 't', globals: [], subtrees: [bare] });
77
+ const out = renderRoot(r.help);
78
+ assert.match(out, /\[\+1 subcommand — `crtr bare -h`\]/); // singular, no "other"
79
+ });
80
+ });
81
+ describe('renderBranch: hidden filter + depth flag', () => {
82
+ const out = renderBranch(thing.help);
83
+ test('hidden child is dropped from the branch listing', () => {
84
+ assert.doesNotMatch(out, /secret/);
85
+ });
86
+ test('all non-hidden children are listed', () => {
87
+ for (const n of ['make', 'promote', 'inspect', 'plain']) {
88
+ assert.match(out, new RegExp(`\\n {2}${n} `));
89
+ }
90
+ });
91
+ test('a branch child flags how many subcommands it owns', () => {
92
+ assert.match(out, /inspect .* \[\+2 subcommands\]/);
93
+ });
94
+ test('leaf children carry no subcommand flag', () => {
95
+ assert.doesNotMatch(out, /make .* \[\+\d+ subcommands\]/);
96
+ });
97
+ });
@@ -8,6 +8,10 @@ export declare function jobDir(nodeId: string): string;
8
8
  export declare function reportsDir(nodeId: string): string;
9
9
  export declare function nodeMetaPath(nodeId: string): string;
10
10
  export declare function inboxPath(nodeId: string): string;
11
+ /** Passive-subscription accumulator. Pushes from publishers this node subscribes
12
+ * to PASSIVELY land here instead of inbox.jsonl — the inbox-watcher never polls
13
+ * it, so they never wake the node. Drained as XML pre-text on the next message. */
14
+ export declare function passivePath(nodeId: string): string;
11
15
  export declare function transcriptPath(nodeId: string): string;
12
16
  export declare function sessionPtrPath(nodeId: string): string;
13
17
  /** Create the full directory skeleton for a node. Idempotent. */
@@ -44,6 +44,12 @@ export function nodeMetaPath(nodeId) {
44
44
  export function inboxPath(nodeId) {
45
45
  return join(nodeDir(nodeId), 'inbox.jsonl');
46
46
  }
47
+ /** Passive-subscription accumulator. Pushes from publishers this node subscribes
48
+ * to PASSIVELY land here instead of inbox.jsonl — the inbox-watcher never polls
49
+ * it, so they never wake the node. Drained as XML pre-text on the next message. */
50
+ export function passivePath(nodeId) {
51
+ return join(nodeDir(nodeId), 'passive.jsonl');
52
+ }
47
53
  export function transcriptPath(nodeId) {
48
54
  return join(nodeDir(nodeId), 'transcript.jsonl');
49
55
  }