@crouton-kit/crouter 0.3.14 → 0.3.15

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 (220) hide show
  1. package/dist/build-root.d.ts +8 -0
  2. package/dist/build-root.js +30 -0
  3. package/dist/builtin-personas/design/base.md +3 -7
  4. package/dist/builtin-personas/design/orchestrator.md +4 -3
  5. package/dist/builtin-personas/developer/base.md +3 -7
  6. package/dist/builtin-personas/developer/orchestrator.md +5 -4
  7. package/dist/builtin-personas/explore/base.md +3 -7
  8. package/dist/builtin-personas/explore/orchestrator.md +1 -5
  9. package/dist/builtin-personas/general/base.md +2 -4
  10. package/dist/builtin-personas/general/orchestrator.md +2 -4
  11. package/dist/builtin-personas/lifecycle/resident.md +2 -0
  12. package/dist/builtin-personas/lifecycle/terminal.md +6 -0
  13. package/dist/builtin-personas/orchestration-kernel.md +42 -3
  14. package/dist/builtin-personas/plan/base.md +3 -5
  15. package/dist/builtin-personas/plan/orchestrator.md +5 -4
  16. package/dist/builtin-personas/plan/reviewers/architecture-fit/base.md +9 -0
  17. package/dist/builtin-personas/plan/reviewers/code-smells/base.md +9 -0
  18. package/dist/builtin-personas/plan/reviewers/pattern-consistency/base.md +9 -0
  19. package/dist/builtin-personas/plan/reviewers/requirements-coverage/base.md +9 -0
  20. package/dist/builtin-personas/plan/reviewers/security/base.md +9 -0
  21. package/dist/builtin-personas/review/base.md +3 -5
  22. package/dist/builtin-personas/review/orchestrator.md +2 -6
  23. package/dist/builtin-personas/runtime-base.md +3 -19
  24. package/dist/builtin-personas/spec/base.md +3 -5
  25. package/dist/builtin-personas/spec/orchestrator.md +4 -3
  26. package/dist/builtin-personas/spine/has-manager.md +10 -0
  27. package/dist/builtin-personas/spine/no-manager.md +2 -0
  28. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +96 -0
  29. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +49 -0
  30. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +49 -0
  31. package/dist/builtin-skills/skills/planning/SKILL.md +1 -1
  32. package/dist/builtin-skills/skills/spec/SKILL.md +2 -2
  33. package/dist/cli.js +6 -29
  34. package/dist/commands/attention.js +76 -7
  35. package/dist/commands/canvas-prune.d.ts +2 -0
  36. package/dist/commands/canvas-prune.js +66 -0
  37. package/dist/commands/canvas.js +5 -8
  38. package/dist/commands/chord.d.ts +2 -0
  39. package/dist/commands/chord.js +143 -0
  40. package/dist/commands/daemon.js +8 -5
  41. package/dist/commands/dashboard.js +2 -0
  42. package/dist/commands/human/prompts.js +28 -27
  43. package/dist/commands/human/queue.js +30 -14
  44. package/dist/commands/human/shared.d.ts +26 -21
  45. package/dist/commands/human/shared.js +44 -66
  46. package/dist/commands/human.js +4 -14
  47. package/dist/commands/node.d.ts +11 -0
  48. package/dist/commands/node.js +221 -98
  49. package/dist/commands/pkg/market-inspect.js +6 -4
  50. package/dist/commands/pkg/market-manage.js +10 -6
  51. package/dist/commands/pkg/market.js +2 -4
  52. package/dist/commands/pkg/plugin-inspect.js +6 -4
  53. package/dist/commands/pkg/plugin-manage.js +12 -7
  54. package/dist/commands/pkg/plugin.js +2 -4
  55. package/dist/commands/pkg.js +0 -4
  56. package/dist/commands/push.js +178 -15
  57. package/dist/commands/revive.js +5 -3
  58. package/dist/commands/skill/author.js +6 -4
  59. package/dist/commands/skill/find.js +8 -5
  60. package/dist/commands/skill/read.js +2 -0
  61. package/dist/commands/skill/state.js +6 -4
  62. package/dist/commands/skill.js +0 -6
  63. package/dist/commands/sys/config.js +21 -7
  64. package/dist/commands/sys/doctor.js +2 -0
  65. package/dist/commands/sys/update.js +4 -0
  66. package/dist/commands/sys.js +0 -6
  67. package/dist/commands/tmux-spread.d.ts +2 -0
  68. package/dist/commands/tmux-spread.js +130 -0
  69. package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
  70. package/dist/core/__tests__/child-followup.test.d.ts +1 -0
  71. package/dist/core/__tests__/child-followup.test.js +83 -0
  72. package/dist/core/__tests__/close.test.d.ts +1 -0
  73. package/dist/core/__tests__/close.test.js +148 -0
  74. package/dist/core/__tests__/context-intro.test.d.ts +1 -0
  75. package/dist/core/__tests__/context-intro.test.js +196 -0
  76. package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
  77. package/dist/core/__tests__/daemon-boot.test.js +93 -0
  78. package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
  79. package/dist/core/__tests__/daemon-liveness.test.js +223 -0
  80. package/dist/core/__tests__/focuses.test.d.ts +1 -0
  81. package/dist/core/__tests__/focuses.test.js +259 -0
  82. package/dist/core/__tests__/fork.test.d.ts +1 -0
  83. package/dist/core/__tests__/fork.test.js +91 -0
  84. package/dist/core/__tests__/home-session.test.d.ts +1 -0
  85. package/dist/core/__tests__/home-session.test.js +153 -0
  86. package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
  87. package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
  88. package/dist/core/__tests__/keystone.test.d.ts +1 -0
  89. package/dist/core/__tests__/keystone.test.js +185 -0
  90. package/dist/core/__tests__/kickoff.test.d.ts +1 -0
  91. package/dist/core/__tests__/kickoff.test.js +89 -0
  92. package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
  93. package/dist/core/__tests__/lifecycle.test.js +178 -0
  94. package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
  95. package/dist/core/__tests__/listing-completeness.test.js +31 -0
  96. package/dist/core/__tests__/memory.test.d.ts +1 -0
  97. package/dist/core/__tests__/memory.test.js +152 -0
  98. package/dist/core/__tests__/migration.test.d.ts +1 -0
  99. package/dist/core/__tests__/migration.test.js +238 -0
  100. package/dist/core/__tests__/pane-column.test.d.ts +1 -0
  101. package/dist/core/__tests__/pane-column.test.js +153 -0
  102. package/dist/core/__tests__/passive-subscription.test.js +24 -1
  103. package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
  104. package/dist/core/__tests__/persona-compose.test.js +53 -0
  105. package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
  106. package/dist/core/__tests__/persona-subkind.test.js +62 -0
  107. package/dist/core/__tests__/persona.test.d.ts +1 -0
  108. package/dist/core/__tests__/persona.test.js +107 -0
  109. package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
  110. package/dist/core/__tests__/placement-focus.test.js +244 -0
  111. package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
  112. package/dist/core/__tests__/placement-reconcile.test.js +212 -0
  113. package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
  114. package/dist/core/__tests__/placement-revive.test.js +238 -0
  115. package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
  116. package/dist/core/__tests__/placement-teardown.test.js +183 -0
  117. package/dist/core/__tests__/prune.test.d.ts +1 -0
  118. package/dist/core/__tests__/prune.test.js +116 -0
  119. package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
  120. package/dist/core/__tests__/push-final-guard.test.js +71 -0
  121. package/dist/core/__tests__/relaunch.test.d.ts +1 -0
  122. package/dist/core/__tests__/relaunch.test.js +328 -0
  123. package/dist/core/__tests__/reset.test.js +26 -7
  124. package/dist/core/__tests__/revive.test.d.ts +1 -0
  125. package/dist/core/__tests__/revive.test.js +217 -0
  126. package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
  127. package/dist/core/__tests__/spawn-root.test.js +73 -0
  128. package/dist/core/__tests__/steer-note.test.d.ts +1 -0
  129. package/dist/core/__tests__/steer-note.test.js +39 -0
  130. package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
  131. package/dist/core/__tests__/stop-guard.test.js +82 -0
  132. package/dist/core/__tests__/subcommand-tier.test.js +35 -33
  133. package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
  134. package/dist/core/__tests__/tmux-surface.test.js +106 -0
  135. package/dist/core/__tests__/unknown-path.test.js +8 -2
  136. package/dist/core/canvas/attention.d.ts +10 -0
  137. package/dist/core/canvas/attention.js +40 -0
  138. package/dist/core/canvas/canvas.d.ts +66 -7
  139. package/dist/core/canvas/canvas.js +209 -21
  140. package/dist/core/canvas/db.d.ts +8 -0
  141. package/dist/core/canvas/db.js +206 -4
  142. package/dist/core/canvas/focuses.d.ts +22 -0
  143. package/dist/core/canvas/focuses.js +80 -0
  144. package/dist/core/canvas/index.d.ts +3 -0
  145. package/dist/core/canvas/index.js +3 -0
  146. package/dist/core/canvas/labels.d.ts +27 -0
  147. package/dist/core/canvas/labels.js +36 -0
  148. package/dist/core/canvas/render.js +25 -10
  149. package/dist/core/canvas/telemetry.d.ts +14 -0
  150. package/dist/core/canvas/telemetry.js +35 -0
  151. package/dist/core/canvas/types.d.ts +115 -12
  152. package/dist/core/command.d.ts +25 -1
  153. package/dist/core/command.js +23 -15
  154. package/dist/core/config.js +36 -2
  155. package/dist/core/feed/feed.js +3 -3
  156. package/dist/core/feed/inbox.d.ts +3 -1
  157. package/dist/core/feed/inbox.js +45 -5
  158. package/dist/core/feed/passive.js +24 -11
  159. package/dist/core/help.d.ts +26 -13
  160. package/dist/core/help.js +44 -37
  161. package/dist/core/personas/index.d.ts +1 -1
  162. package/dist/core/personas/index.js +1 -1
  163. package/dist/core/personas/loader.d.ts +40 -1
  164. package/dist/core/personas/loader.js +63 -1
  165. package/dist/core/personas/resolve.d.ts +13 -6
  166. package/dist/core/personas/resolve.js +46 -34
  167. package/dist/core/runtime/bearings.d.ts +20 -0
  168. package/dist/core/runtime/bearings.js +92 -0
  169. package/dist/core/runtime/close.d.ts +14 -0
  170. package/dist/core/runtime/close.js +151 -0
  171. package/dist/core/runtime/demote.js +27 -10
  172. package/dist/core/runtime/front-door.js +1 -1
  173. package/dist/core/runtime/kickoff.d.ts +23 -6
  174. package/dist/core/runtime/kickoff.js +92 -36
  175. package/dist/core/runtime/launch.d.ts +24 -12
  176. package/dist/core/runtime/launch.js +75 -19
  177. package/dist/core/runtime/lifecycle.d.ts +13 -0
  178. package/dist/core/runtime/lifecycle.js +86 -0
  179. package/dist/core/runtime/memory.d.ts +43 -0
  180. package/dist/core/runtime/memory.js +165 -0
  181. package/dist/core/runtime/naming.d.ts +22 -0
  182. package/dist/core/runtime/naming.js +166 -0
  183. package/dist/core/runtime/nodes.d.ts +32 -1
  184. package/dist/core/runtime/nodes.js +60 -10
  185. package/dist/core/runtime/persona.d.ts +25 -0
  186. package/dist/core/runtime/persona.js +139 -0
  187. package/dist/core/runtime/placement.d.ts +287 -0
  188. package/dist/core/runtime/placement.js +663 -0
  189. package/dist/core/runtime/presence.d.ts +7 -15
  190. package/dist/core/runtime/presence.js +90 -66
  191. package/dist/core/runtime/promote.d.ts +14 -7
  192. package/dist/core/runtime/promote.js +57 -67
  193. package/dist/core/runtime/reset.d.ts +47 -4
  194. package/dist/core/runtime/reset.js +223 -52
  195. package/dist/core/runtime/revive.d.ts +26 -2
  196. package/dist/core/runtime/revive.js +166 -39
  197. package/dist/core/runtime/spawn.d.ts +20 -5
  198. package/dist/core/runtime/spawn.js +163 -43
  199. package/dist/core/runtime/stop-guard.d.ts +1 -1
  200. package/dist/core/runtime/stop-guard.js +18 -8
  201. package/dist/core/runtime/tmux.d.ts +100 -14
  202. package/dist/core/runtime/tmux.js +201 -28
  203. package/dist/core/spawn.js +15 -0
  204. package/dist/daemon/crtrd.d.ts +12 -1
  205. package/dist/daemon/crtrd.js +152 -34
  206. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
  207. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
  208. package/dist/pi-extensions/canvas-commands.js +16 -13
  209. package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
  210. package/dist/pi-extensions/canvas-context-intro.js +164 -0
  211. package/dist/pi-extensions/canvas-goal-capture.d.ts +3 -0
  212. package/dist/pi-extensions/canvas-goal-capture.js +15 -1
  213. package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
  214. package/dist/pi-extensions/canvas-nav.d.ts +12 -4
  215. package/dist/pi-extensions/canvas-nav.js +586 -262
  216. package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
  217. package/dist/pi-extensions/canvas-stophook.js +344 -228
  218. package/dist/types.d.ts +28 -0
  219. package/dist/types.js +16 -0
  220. package/package.json +1 -1
@@ -6,16 +6,41 @@
6
6
  // the spine (`focus`/`msg`). The push/feed half lives under `crtr push`.
7
7
  import { defineLeaf, defineBranch } from '../core/command.js';
8
8
  import { InputError } from '../core/io.js';
9
- import { spawnChild, bootRoot } from '../core/runtime/spawn.js';
9
+ import { spawnChild } from '../core/runtime/spawn.js';
10
10
  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
- import { focusNodeInPlace } from '../core/runtime/presence.js';
14
13
  import { demoteNode } from '../core/runtime/demote.js';
14
+ import { detachToBackground, focus as placementFocus } from '../core/runtime/placement.js';
15
+ import { buildLaunchSpec } from '../core/runtime/launch.js';
16
+ import { closeNode } from '../core/runtime/close.js';
15
17
  import { windowAlive, windowOfPane, currentTmux } from '../core/runtime/tmux.js';
16
18
  import { appendInbox } from '../core/feed/inbox.js';
17
19
  import { availableKinds } from '../core/personas/index.js';
18
- import { getNode, listNodes, subscribe, unsubscribe, subscriptionsOf, subscribersOf, } from '../core/canvas/index.js';
20
+ import { getNode, updateNode, listNodes, subscribe, unsubscribe, subscriptionsOf, subscribersOf, readContextTokens, } from '../core/canvas/index.js';
21
+ // Past this much context, an ORCHESTRATOR that spawns a managed child is better
22
+ // off yielding than holding its fat window open for the child's result: the
23
+ // child's finish revives it fresh against its roadmap, so a clean window absorbs
24
+ // the result instead of this bloated one. Below the steering bands (130k+) on
25
+ // purpose — catch it at spawn, before the window is critically full.
26
+ const YIELD_NUDGE_THRESHOLD = 100_000;
27
+ const STD_CHILD_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: continue other independent work right now, or stop and end your turn — the wake brings you back.";
28
+ /** Decision road sign for a managed (non-root) child. Normally STD_CHILD_FOLLOW_UP,
29
+ * but when the SPAWNER is an orchestrator whose context has already grown past
30
+ * YIELD_NUDGE_THRESHOLD, steer it to yield now and let its fresh revive handle
31
+ * the child's result. */
32
+ export function childFollowUp(spawnerId) {
33
+ if (spawnerId === undefined || spawnerId === '')
34
+ return STD_CHILD_FOLLOW_UP;
35
+ const spawner = getNode(spawnerId);
36
+ if (spawner === null || spawner.mode !== 'orchestrator')
37
+ return STD_CHILD_FOLLOW_UP;
38
+ const ctxTokens = readContextTokens(spawnerId);
39
+ if (ctxTokens === null || ctxTokens < YIELD_NUDGE_THRESHOLD)
40
+ return STD_CHILD_FOLLOW_UP;
41
+ const k = Math.round(ctxTokens / 1000);
42
+ return `Child spawned — you're auto-subscribed, so its finish wakes you on its own; never wait or poll. But you're an orchestrator already carrying ~${k}k of context: rather than hold this window open for the result, checkpoint context/roadmap.md and \`crtr node yield\` now. Yielding ends this turn, and the child's completion then revives you fresh against your roadmap — let that clean revive absorb the result instead of this bloated context.`;
43
+ }
19
44
  /** Validate a `--kind` against the installed personas; throws a listing InputError. */
20
45
  function assertKind(kind) {
21
46
  const kinds = availableKinds();
@@ -28,30 +53,35 @@ function assertKind(kind) {
28
53
  // ---------------------------------------------------------------------------
29
54
  const nodeNew = defineLeaf({
30
55
  name: 'new',
56
+ description: 'spawn a node — a managed child (default), or an independent root with --root',
57
+ whenToUse: 'you have a self-contained unit of work — reach for this instead of doing it inline, so the reading and the tokens land in a fresh window and only the conclusion comes back: mapping an unfamiliar part of the codebase, writing a spec, designing an approach, breaking a job into a plan, implementing a change, or running a review. Match `--kind` to the work (explore/spec/design/plan/developer/review/general) and fan independent units out as concurrent children. Default is a managed child you auto-subscribe to, so its finish wakes you; pass `--mode orchestrator` when the unit is itself too big for one window (e.g. a large multi-area review) so it boots as a sub-orchestrator with its own roadmap instead of a base worker you hope promotes itself; pass `--root` to hand off an INDEPENDENT node you neither manage nor get woken by (e.g. one a human will sit and drive), not for ordinary delegation',
58
+ tier: 'important',
31
59
  help: {
32
60
  name: 'node new',
33
61
  summary: 'spawn a terminal worker onto the canvas as a background window — returns its node id',
34
62
  params: [
35
63
  { kind: 'stdin', name: 'prompt', required: true, constraint: 'First user message for the spawned node. Piped on stdin or passed as a positional.' },
36
64
  { kind: 'flag', name: 'kind', type: 'string', required: false, default: 'general', constraint: 'Persona kind — match the work: explore (map/investigate a codebase), spec (write a spec), design (architect a solution), plan (break work into steps), developer (implement a change), review (validate/critique), general (anything else).' },
37
- { kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, default: 'base', constraint: 'Persona mode. Almost always base; orchestrator is reserved for promoted/resident nodes.' },
65
+ { kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, default: 'base', constraint: 'Persona mode. base for a worker that finishes in one window; orchestrator to create the child directly as a sub-orchestrator (it boots with the orchestrator persona + a seeded roadmap and fans its scope out) — use it when the unit is too large for one window, e.g. a big review, instead of spawning a base worker and counting on it to promote itself.' },
38
66
  { kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Dir the node is pinned to. Defaults to the caller cwd.' },
39
67
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Display name (tmux window + resume picker). Defaults to the kind.' },
40
68
  { kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id. Defaults to the calling node (CRTR_NODE_ID).' },
69
+ { kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an INDEPENDENT root instead of a managed child: no parent (top-level on the canvas), NO subscription back to you (you are NOT woken by it), resident lifecycle. It records spawned_by=you for provenance and is brought forefront so it can be driven directly. Use for a node you hand off and do not manage (e.g. a sub-orchestrator a human will discuss with).' },
70
+ { kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'FORK the new node from an existing pi conversation instead of starting it fresh: pass a node id (forks from that node\'s session), an absolute session `.jsonl` path, or a partial pi session uuid. pi copies that whole history into a NEW session for the child (the source is untouched), then the prompt is delivered as the next message — i.e. the child wakes up as a continuation of that conversation. Use to branch exploratory work off a node that already built up the context you need, instead of re-deriving it. One-shot at birth: the fork resumes its own session thereafter.' },
41
71
  ],
42
72
  output: [
43
73
  { name: 'node_id', type: 'string', required: true, constraint: 'The new node id.' },
44
74
  { name: 'name', type: 'string', required: true, constraint: 'Display name.' },
45
75
  { name: 'window', type: 'string', required: false, constraint: 'tmux window id of the background window.' },
46
- { name: 'session', type: 'string', required: true, constraint: 'The shared crtr tmux session the node was placed in.' },
76
+ { name: 'session', type: 'string', required: true, constraint: 'The tmux session the node was placed in — the shared crtr session for a child; your current session for an in-tmux --root.' },
47
77
  { name: 'status', type: 'string', required: true, constraint: 'Always "active" on spawn.' },
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.' },
78
+ { 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. If you are an orchestrator already deep in context (>100k), it instead steers you to `crtr node yield` now so your fresh revive absorbs the child\'s result. Read it, then act.' },
49
79
  ],
50
80
  outputKind: 'object',
51
81
  effects: [
52
82
  'Creates a node under ~/.crtr/nodes/<id>/ and indexes it in canvas.db.',
53
- 'Parent auto-subscribes (active) to the child so it is woken on the child\'s pushes.',
54
- 'Opens a background (non-focus-stealing) tmux window running pi.',
83
+ 'Default (managed child): parent auto-subscribes (active) and is woken on the child\'s pushes. With --root: no subscription — records a spawned_by edge for provenance only.',
84
+ 'Opens a tmux window running pi: a background (non-focus-stealing) window in the shared crtr session for a child; with --root, a new window in your current session (in-tmux) or the shared session (outside tmux), with the client switched to it.',
55
85
  ],
56
86
  },
57
87
  run: async (input) => {
@@ -64,14 +94,18 @@ const nodeNew = defineLeaf({
64
94
  const cwd = input['cwd'] ?? process.cwd();
65
95
  const name = input['name'];
66
96
  const parent = input['parent'];
67
- const res = spawnChild({ kind, mode, cwd, name, prompt, parent });
97
+ const root = input['root'] === true;
98
+ const forkFrom = input['forkFrom'];
99
+ const res = spawnChild({ kind, mode, cwd, name, prompt, parent, root, forkFrom });
68
100
  return {
69
101
  node_id: res.node.node_id,
70
102
  name: res.node.name,
71
103
  window: res.window ?? undefined,
72
104
  session: res.session,
73
105
  status: res.node.status,
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.",
106
+ follow_up: root
107
+ ? "Independent root spawned — it is NOT under you. You are not subscribed, so its finish will NOT wake you and it does not hold you alive; it carries spawned_by=you for lineage only. It opened in its own window and the client was switched to it so it can be driven directly. Hand it off and move on — you will not be notified of its progress."
108
+ : childFollowUp(parent ?? process.env['CRTR_NODE_ID']),
75
109
  };
76
110
  },
77
111
  render: (r) => `<spawned name="${r['name']}" id="${r['node_id']}" status="${r['status']}">\n${r['follow_up']}\n</spawned>`,
@@ -81,11 +115,13 @@ const nodeNew = defineLeaf({
81
115
  // ---------------------------------------------------------------------------
82
116
  const nodeList = defineLeaf({
83
117
  name: 'list',
118
+ description: 'list nodes on the canvas',
119
+ whenToUse: 'you want a flat roster of the nodes on the canvas, optionally sliced by status (active/idle/done/dead/canceled): a quick read of what exists and what is still running. Use `node inspect show` instead to drill into one node and its spine neighbors, `canvas dashboard` for the tree SHAPE, and `canvas attention` to find which nodes are blocked on a human',
84
120
  help: {
85
121
  name: 'node inspect list',
86
122
  summary: 'list nodes on the canvas, optionally by status',
87
123
  params: [
88
- { kind: 'flag', name: 'status', type: 'string', required: false, constraint: 'Filter: active | idle | done | dead. Comma-separated for several.' },
124
+ { kind: 'flag', name: 'status', type: 'string', required: false, constraint: 'Filter: active | idle | done | dead | canceled. Comma-separated for several.' },
89
125
  ],
90
126
  output: [
91
127
  { name: 'nodes', type: 'object[]', required: true, constraint: 'Rows: {node_id, name, kind, mode, lifecycle, status, cwd, parent, created}.' },
@@ -105,6 +141,8 @@ const nodeList = defineLeaf({
105
141
  // ---------------------------------------------------------------------------
106
142
  const nodeShow = defineLeaf({
107
143
  name: 'show',
144
+ description: 'show a node + its spine neighbors',
145
+ whenToUse: 'you want one node in depth: its meta plus its spine neighbors — who it reports to (subscriptions) and who manages it (subscribers). Use `node inspect list` instead for the flat roster of every node, or `canvas dashboard` to see the whole-tree shape',
108
146
  help: {
109
147
  name: 'node inspect show',
110
148
  summary: 'show a node\'s meta plus its subscriptions (reports) and subscribers (managers)',
@@ -133,13 +171,11 @@ const nodeShow = defineLeaf({
133
171
  // ---------------------------------------------------------------------------
134
172
  const nodeInspect = defineBranch({
135
173
  name: 'inspect',
174
+ description: 'read the graph (list nodes / show one)',
175
+ whenToUse: 'reading the graph: enumerate the nodes on the canvas (`list`) or drill into one and its spine neighbors (`show`). Use `canvas dashboard` instead to render the tree SHAPE, or `canvas attention` to find which nodes are blocked on a human',
136
176
  help: {
137
177
  name: 'node inspect',
138
178
  summary: 'read the canvas graph — enumerate nodes or inspect one with its spine neighbors',
139
- children: [
140
- { name: 'list', desc: 'list nodes on the canvas', useWhen: 'surveying what exists' },
141
- { name: 'show', desc: 'show a node + its spine neighbors', useWhen: 'inspecting one node' },
142
- ],
143
179
  },
144
180
  children: [nodeList, nodeShow],
145
181
  });
@@ -148,11 +184,14 @@ const nodeInspect = defineBranch({
148
184
  // ---------------------------------------------------------------------------
149
185
  const nodeFocus = defineLeaf({
150
186
  name: 'focus',
187
+ description: 'bring a node window forefront',
188
+ whenToUse: 'you want to bring a specific node into view — swapped into your current pane — to watch or steer it directly, reviving it first if dormant. Use `node cycle` instead to walk neighbors one window at a time rather than jump to a named node, and `node msg` to steer a node without leaving where you are',
151
189
  help: {
152
190
  name: 'node focus',
153
191
  summary: 'bring a node into your CURRENT pane in place (swap-pane) — the agent appears where you are instead of navigating you to its window',
154
192
  params: [
155
193
  { kind: 'positional', name: 'node', required: true, constraint: 'Node id to focus.' },
194
+ { kind: 'flag', name: 'new-pane', type: 'bool', required: false, constraint: 'Open the node in a NEW viewport SIDE-BY-SIDE with your current pane (a second focus) instead of swapping it into your pane. Two agents on screen at once (F4).' },
156
195
  ],
157
196
  output: [
158
197
  { name: 'focused', type: 'boolean', required: true, constraint: 'True when the node was brought into view.' },
@@ -161,25 +200,23 @@ const nodeFocus = defineLeaf({
161
200
  { name: 'in_place', type: 'boolean', required: true, constraint: 'True when the node was swapped into the caller pane; false when it fell back to window focus (no caller pane).' },
162
201
  ],
163
202
  outputKind: 'object',
164
- effects: ['Swaps the node\'s pane into the caller\'s current pane (tmux swap-pane -d) and updates the focus pointer.', 'Falls back to select-window (+ switch-client across roots) when there is no caller pane.', 'Revives a dormant node (resume) if it has no live window, then focuses it.'],
203
+ effects: ['Swaps the node\'s pane into the caller\'s current pane (tmux swap-pane -d) and retargets the caller\'s focus to it (focus pointer updated).', 'With --new-pane: splits a new viewport beside the caller (a second live focus) instead of swapping in place.', 'Revives a dormant node (resume) into the backstage if it has no live pane, then swaps it into the focus.'],
165
204
  },
166
205
  run: async (input) => {
167
206
  const id = input['node'];
168
207
  const node = getNode(id);
169
208
  if (node === null)
170
209
  throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
171
- // A dormant node (done/dead/window released) has no live window revive it
172
- // (resume the saved conversation) so there is something to focus.
173
- let revived = false;
174
- if (!windowAlive(node.tmux_session, node.window)) {
175
- try {
176
- reviveNode(id, { resume: true });
177
- revived = true;
178
- }
179
- catch { /* fall through; focus reports focused:false */ }
180
- }
181
- const res = focusNodeInPlace(id);
182
- return { focused: res.focused, session: res.session, revived, in_place: res.inPlace };
210
+ // Placement owns the whole act (§2.3): resolve the caller's focus (or open a
211
+ // new viewport with --new-pane), revive the target into the backstage if it
212
+ // is dormant, then hot-swap it onto the focus. The reviver is injected so
213
+ // placement need not import revive.ts.
214
+ const res = placementFocus(id, {
215
+ newPane: input['newPane'] === true,
216
+ callerNode: process.env['CRTR_NODE_ID'],
217
+ revive: (nid) => { reviveNode(nid, { resume: true }); },
218
+ });
219
+ return { focused: res.focused, session: res.session, revived: res.revived, in_place: res.inPlace };
183
220
  },
184
221
  });
185
222
  // ---------------------------------------------------------------------------
@@ -197,14 +234,17 @@ function nodeByWindow(win) {
197
234
  /** The live node occupying a tmux pane (pane → window → node), or undefined.
198
235
  * Defaults to $TMUX_PANE / the caller's current pane when `pane` is omitted —
199
236
  * shared by `node demote` and `node cycle`, both of which act on "the agent in
200
- * front of you". */
201
- function nodeInPane(pane) {
237
+ * front of you". Exported for the `canvas chord` / `canvas tmux-spread` leaves,
238
+ * which resolve the active pane's node the same way. */
239
+ export function nodeInPane(pane) {
202
240
  const resolvePane = pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
203
241
  const win = resolvePane !== undefined && resolvePane !== '' ? windowOfPane(resolvePane) : null;
204
242
  return win !== null ? nodeByWindow(win) : undefined;
205
243
  }
206
244
  const nodeDemote = defineLeaf({
207
245
  name: 'demote',
246
+ description: 'finish the agent in your pane + recycle it into a fresh root',
247
+ whenToUse: 'you are at an agent\'s pane and done with it: finish it cleanly and recycle the pane in one move — 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 to keep working. The human-driver way to end an agent and immediately start over in place. Use `node close` instead to tear a node and its subtree down WITHOUT finishing (no report, revivable), and `push final` when the agent should finish ITSELF from inside its own turn',
208
248
  help: {
209
249
  name: 'node demote',
210
250
  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',
@@ -243,6 +283,52 @@ const nodeDemote = defineLeaf({
243
283
  : `<demote-failed id="${r['node_id'] ?? ''}">not in tmux, or no agent in this pane</demote-failed>`,
244
284
  });
245
285
  // ---------------------------------------------------------------------------
286
+ // node close — close a node + cascade-cancel its exclusive subtree (Alt+C → x)
287
+ // ---------------------------------------------------------------------------
288
+ const nodeClose = defineLeaf({
289
+ name: 'close',
290
+ description: 'close a node + cascade-cancel its exclusive subtree (revivable)',
291
+ whenToUse: 'you want to tear a node down WITHOUT finishing it, cascade-cancelling every descendant it exclusively owns: abandoning a line of work, killing a stuck or wrong-turn subtree, clearing a branch you no longer need. Windows die but nothing is deleted — each closed node keeps its pi session and can be revived later (`canvas revive`). Use `node demote` instead to FINISH the agent in your pane with a final report, and `push final` when a worker should end its own work normally (Alt+C → x)',
292
+ help: {
293
+ name: 'node close',
294
+ summary: 'close a node and cascade-cancel its subtree — kill its tmux window plus those of every descendant it EXCLUSIVELY owns (down the subscribes_to spine), mark them all canceled, and leave each a notice it reads on resume. A descendant still subscribed to by a manager outside the subtree is left running. Nothing is deleted: every closed node keeps its pi session and can be revived later (`crtr canvas revive`)',
295
+ params: [
296
+ { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to close. Defaults to the node occupying --pane (or your current pane).' },
297
+ { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id whose node to close. Defaults to $TMUX_PANE / your current pane. The Alt+C menu passes this for you.' },
298
+ ],
299
+ output: [
300
+ { name: 'closed', type: 'boolean', required: true, constraint: 'True when the node (and its exclusive subtree) was closed.' },
301
+ { name: 'node_id', type: 'string', required: false, constraint: 'The node that was closed — the cascade root.' },
302
+ { name: 'count', type: 'number', required: false, constraint: 'How many nodes were closed (root + cascaded descendants).' },
303
+ { name: 'closed_ids', type: 'string[]', required: false, constraint: 'All closed node ids, kill order (leaves first, root last).' },
304
+ { name: 'spared', type: 'string[]', required: false, constraint: 'Descendants left alive because a manager outside the subtree still subscribes to them.' },
305
+ ],
306
+ outputKind: 'object',
307
+ effects: [
308
+ 'Marks the node and its exclusive descendants `canceled` and clears intent (the daemon never revives a canceled node).',
309
+ 'Kills each closed node\'s tmux window; their pi sessions and canvas edges persist for a later revive.',
310
+ 'Appends a cancellation notice to each closed node\'s inbox, surfaced on its next resume.',
311
+ ],
312
+ },
313
+ run: async (input) => {
314
+ const pane = input['pane'] ?? process.env['TMUX_PANE'];
315
+ let id = input['node'];
316
+ if (id === undefined || id === '')
317
+ id = nodeInPane(pane);
318
+ if (id === undefined || id === '') {
319
+ throw new InputError({ error: 'no_node', message: 'no node found in this pane to close', next: 'Pass --node <id>, or run from inside the agent\'s pane.' });
320
+ }
321
+ if (getNode(id) === null) {
322
+ throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
323
+ }
324
+ const res = closeNode(id);
325
+ return { closed: true, node_id: res.root, count: res.closed.length, closed_ids: res.closed, spared: res.spared };
326
+ },
327
+ render: (r) => r['closed'] === true
328
+ ? `<closed id="${r['node_id']}" count="${r['count']}" spared="${r['spared']?.length ?? 0}"/>`
329
+ : `<close-failed/>`,
330
+ });
331
+ // ---------------------------------------------------------------------------
246
332
  // node cycle — DFS-walk the canvas one window at a time (Alt+] / Alt+[)
247
333
  // ---------------------------------------------------------------------------
248
334
  /** Every live node in DFS pre-order across the whole forest. The spawn tree is
@@ -282,6 +368,8 @@ function liveDfsOrder() {
282
368
  }
283
369
  const nodeCycle = defineLeaf({
284
370
  name: 'cycle',
371
+ description: 'DFS-walk to the next/prev live node in place',
372
+ whenToUse: 'sweeping the canvas one window at a time, descending into children before siblings (bound to Alt+] forward / Alt+[ back). Use `node focus` instead to jump straight to a named node',
285
373
  help: {
286
374
  name: 'node cycle',
287
375
  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)',
@@ -313,15 +401,14 @@ const nodeCycle = defineLeaf({
313
401
  const target = getNode(targetId);
314
402
  if (target === null)
315
403
  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);
404
+ // Placement retargets the caller pane's focus to the neighbor (§2.3),
405
+ // reviving it into the backstage first if its pane was released. callerNode
406
+ // is the node we cycled AWAY from — the current occupant of the caller pane.
407
+ const res = placementFocus(targetId, {
408
+ pane,
409
+ callerNode: fromId,
410
+ revive: (nid) => { reviveNode(nid, { resume: true }); },
411
+ });
325
412
  return { focused: res.focused, node_id: targetId, name: target.name, from: fromId };
326
413
  },
327
414
  render: (r) => r['focused'] === true
@@ -329,41 +416,12 @@ const nodeCycle = defineLeaf({
329
416
  : `<cycle-noop>no other live node to focus</cycle-noop>`,
330
417
  });
331
418
  // ---------------------------------------------------------------------------
332
- // node session — boot a NEW root in its own tmux session (the explicit form)
333
- // ---------------------------------------------------------------------------
334
- const nodeSession = defineLeaf({
335
- name: 'session',
336
- help: {
337
- name: 'node session',
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)',
339
- params: [
340
- { kind: 'stdin', name: 'prompt', required: false, constraint: 'Optional starter prompt; a root needs none.' },
341
- { kind: 'flag', name: 'kind', type: 'string', required: false, default: 'general', constraint: 'Persona kind for the root.' },
342
- { kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Dir to pin the root to. Defaults to the caller cwd.' },
343
- { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Display name.' },
344
- ],
345
- output: [
346
- { name: 'node_id', type: 'string', required: true, constraint: 'The root node id.' },
347
- { name: 'session', type: 'string', required: true, constraint: 'The shared crtr tmux session this root\'s window was placed in.' },
348
- { name: 'window', type: 'string', required: false, constraint: 'The root node\'s window id.' },
349
- ],
350
- outputKind: 'object',
351
- effects: ['Opens a detached window in the shared crtr session and runs pi in it as a resident root node.'],
352
- },
353
- run: async (input) => {
354
- const prompt = input['prompt'];
355
- const kind = input['kind'] ?? 'general';
356
- const cwd = input['cwd'] ?? process.cwd();
357
- const name = input['name'];
358
- const meta = bootRoot({ cwd, kind, name, prompt, placement: 'session' });
359
- return { node_id: meta.node_id, session: meta.tmux_session ?? '', window: meta.window ?? undefined };
360
- },
361
- });
362
- // ---------------------------------------------------------------------------
363
419
  // node msg — direct-address any node at a wake tier (wakes a dormant target)
364
420
  // ---------------------------------------------------------------------------
365
421
  const nodeMsg = defineLeaf({
366
422
  name: 'msg',
423
+ description: 'direct-message any node at a wake tier',
424
+ whenToUse: 'you want to address a specific node directly — steer it mid-flight, hand it a correction, ping it, or pass it new information — and have it land regardless of subscriptions, reviving a dormant target. Set `--tier` by urgency: critical interrupts with a new turn, urgent steers mid-turn, normal is a follow-up, deferred waits for its next cycle. Use `node subscribe` instead to wire ongoing push delivery rather than send a one-off, and `push` to report UP your own spine',
367
425
  help: {
368
426
  name: 'node msg',
369
427
  summary: 'send a direct message to any node\'s inbox at a wake tier — a direct message wakes the node regardless of subscriptions (reviving it if dormant)',
@@ -417,6 +475,8 @@ function resolveSubscriber(input) {
417
475
  }
418
476
  const nodeSubscribe = defineLeaf({
419
477
  name: 'subscribe',
478
+ description: 'wire a subscribes_to edge between any pair (active or --passive)',
479
+ whenToUse: 'you want to wire who-wakes-whom on the graph: make a node receive the pushes another node emits — yourself by default, or any node to any publisher via `--subscriber` (e.g. point a manager at a `--root` worker you spawned, or fan a reviewer to a second orchestrator). Active by default, so a push WAKES the subscriber; pass `--passive` to have pushes accumulate and auto-inject on its next message without waking it. You already auto-subscribe to any child you spawn, so reach for this for edges spawn did not create. Inverse is `node unsubscribe`',
420
480
  help: {
421
481
  name: 'node subscribe',
422
482
  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.',
@@ -452,6 +512,8 @@ const nodeSubscribe = defineLeaf({
452
512
  });
453
513
  const nodeUnsubscribe = defineLeaf({
454
514
  name: 'unsubscribe',
515
+ description: 'drop a subscribes_to edge',
516
+ whenToUse: 'you want to stop a node receiving another\'s pushes: detach yourself (default) or any node via `--subscriber` from a publisher — quiet a feed you no longer track, or cut a manager loose from a finished worker. Idempotent. The inverse of `node subscribe`',
455
517
  help: {
456
518
  name: 'node unsubscribe',
457
519
  summary: 'drop a subscribes_to edge — the subscriber (you by default, or any node via --subscriber) stops receiving the publisher\'s pushes.',
@@ -476,28 +538,35 @@ const nodeUnsubscribe = defineLeaf({
476
538
  render: (r) => `<unsubscribed subscriber="${r['subscriber']}" publisher="${r['publisher']}"/>`,
477
539
  });
478
540
  // ---------------------------------------------------------------------------
479
- // node promote — become a resident orchestrator (terminalresident polymorph)
541
+ // node promote — become an orchestrator (the workerorchestrator polymorph)
480
542
  // ---------------------------------------------------------------------------
481
543
  const nodePromote = defineLeaf({
482
544
  name: 'promote',
545
+ description: 'become an orchestrator of a chosen kind',
546
+ whenToUse: 'your task has outgrown a single context window — many phases to delegate and persist across refreshes — so become an orchestrator: a long-lived, roadmap-holding node that fans work out to children and survives context refreshes (`node yield`). Choose `--kind` to specialize (developer/review/spec/design/plan/explore/general). Pass `--resident` to ALSO make it interactable (stays dormant, woken by inbox/human, never forced to submit a final); without it you stay terminal/orchestrator — still reporting a final up the spine and reaping when done. Do NOT reach for this for work that fits one window, or merely because you spawned a child — a base worker that spawns a helper and ends with `push final` never needs to promote',
547
+ tier: 'important',
483
548
  help: {
484
549
  name: 'node promote',
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',
550
+ summary: 'promote yourself to an 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. Mode only — lifecycle stays as-is unless you pass --resident',
486
551
  params: [
487
552
  { 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.' },
553
+ { kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'ALSO flip lifecycle→resident: make the node interactable — it stays dormant, woken by inbox/human, and is never forced to submit a final. Omit to stay terminal/orchestrator (delegates + holds a roadmap, but still owes a final up the spine and reaps when done).' },
488
554
  { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to promote. Defaults to the caller (CRTR_NODE_ID).' },
489
555
  ],
490
556
  output: [
491
557
  { name: 'node_id', type: 'string', required: true, constraint: 'The promoted node.' },
492
558
  { name: 'kind', type: 'string', required: true, constraint: 'The kind it now orchestrates as.' },
493
559
  { name: 'mode', type: 'string', required: true, constraint: 'Now "orchestrator".' },
560
+ { name: 'lifecycle', type: 'string', required: true, constraint: '"resident" if you passed --resident, else unchanged (typically "terminal").' },
494
561
  { name: 'roadmap_written', type: 'boolean', required: true, constraint: 'True if a roadmap scaffold was seeded by this call.' },
495
562
  { name: 'roadmap_path', type: 'string', required: true, constraint: 'Absolute path to your roadmap doc (context/roadmap.md) — edit it to author your plan.' },
496
563
  { 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 roleread and act on them this turn.' },
564
+ { name: 'memory_path', type: 'string', required: true, constraint: 'Absolute path to your NODE-LOCAL memory index (context/memory/MEMORY.md) facts specific to this goal; dies with this node.' },
565
+ { name: 'user_memory_path', type: 'string', required: true, constraint: 'Absolute path to your USER-GLOBAL memory index (<crtrHome>/memory/MEMORY.md) — who the human is, how they like to work; loaded into every orchestrator everywhere.' },
566
+ { name: 'project_memory_path', type: 'string', required: true, constraint: 'Absolute path to your PROJECT memory index (<crtrHome>/projects/<key>/memory/MEMORY.md) — facts bound to this repo; loaded into every orchestrator working here.' },
498
567
  ],
499
568
  outputKind: 'object',
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.'],
569
+ effects: ['Flips mode→orchestrator + kind→chosen (lifecycle unchanged unless --resident, which also flips lifecycle→resident); rewrites the launch spec to that kind\'s orchestrator persona; seeds context/roadmap.md scaffold + all three scoped memory stores (user-global, project, node-local) if absent.', 'Your new-role guidance is injected automatically at the turn boundary by the persona injector — the command no longer returns it.'],
501
570
  },
502
571
  run: async (input) => {
503
572
  const id = input['node'] ?? process.env['CRTR_NODE_ID'];
@@ -506,25 +575,90 @@ const nodePromote = defineLeaf({
506
575
  const kind = input['kind'];
507
576
  if (kind !== undefined)
508
577
  assertKind(kind);
509
- const res = promote(id, kind !== undefined ? { kind } : {});
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 };
578
+ const resident = input['resident'] === true;
579
+ const res = promote(id, { ...(kind !== undefined ? { kind } : {}), ...(resident ? { resident: true } : {}) });
580
+ return { node_id: res.meta.node_id, kind: res.meta.kind, mode: res.meta.mode, lifecycle: res.meta.lifecycle, roadmap_written: res.roadmapWritten, roadmap_path: res.roadmapPath, goal_path: res.goalPath, memory_path: res.memoryPath, user_memory_path: res.userMemoryPath, project_memory_path: res.projectMemoryPath };
511
581
  },
512
582
  });
513
583
  // ---------------------------------------------------------------------------
584
+ // node lifecycle — flip the lifecycle axis (terminal ↔ resident), independent
585
+ // of mode. The persona injector delivers the transition guidance.
586
+ // ---------------------------------------------------------------------------
587
+ const nodeLifecycle = defineLeaf({
588
+ name: 'lifecycle',
589
+ description: 'switch a node between terminal and resident',
590
+ whenToUse: 'you want to flip a node\'s LIFECYCLE independent of its mode: make a node RESIDENT so it becomes interactable — it stays dormant, wakes on inbox/human, and is never forced to submit a final; or make a node TERMINAL so it owes a final result up the spine and reaps when done. Orthogonal to `node promote`, which changes MODE (base↔orchestrator), not lifecycle. The new-state guidance is injected automatically at the next turn boundary. Pass `--detach` to ALSO send a still-running agent to the background crtr session, freeing your pane while it finishes — the human-driver demote (Alt+C → d demotes in place) and detach (Alt+C → D demotes + backgrounds)',
591
+ help: {
592
+ name: 'node lifecycle',
593
+ summary: 'set a node\'s lifecycle axis — terminal (owes a final up the spine, reaps when done) or resident (interactable, stays dormant, woken by inbox/human, never forced to submit). Orthogonal to mode; promotion does not touch it. `--detach` also relocates a live agent to the background crtr session',
594
+ params: [
595
+ { kind: 'positional', name: 'lifecycle', required: true, constraint: 'terminal | resident.' },
596
+ { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to change. Defaults to the node in --pane, else the caller (CRTR_NODE_ID).' },
597
+ { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id whose node to change, when --node is omitted. Defaults to $TMUX_PANE. The Alt+C menu passes this for you.' },
598
+ { kind: 'flag', name: 'detach', type: 'bool', required: false, constraint: 'After flipping lifecycle, send the still-running agent to the background crtr session (break its pane out of the foreground). The pi keeps generating and — now terminal — pushes a final up the spine when done. The human-driver "I am done foregrounding this" move (Alt+C → D).' },
599
+ ],
600
+ output: [
601
+ { name: 'node_id', type: 'string', required: true, constraint: 'The node.' },
602
+ { name: 'lifecycle', type: 'string', required: true, constraint: 'Its new lifecycle (terminal | resident).' },
603
+ { name: 'detached', type: 'boolean', required: false, constraint: 'True when --detach relocated the agent to the background crtr session.' },
604
+ ],
605
+ outputKind: 'object',
606
+ effects: ['Sets lifecycle on the node meta and rebuilds its launch spec so a future revive boots with the new lifecycle\'s prompt baked in.', 'The persona injector delivers the transition guidance at the next turn boundary (or on the node\'s next revive if it is dormant).', 'With --detach: relocates the agent\'s live pane to the background crtr session (break-pane) WITHOUT killing the pi — it keeps generating in the background.'],
607
+ },
608
+ run: async (input) => {
609
+ const value = input['lifecycle']?.trim().toLowerCase();
610
+ if (value !== 'terminal' && value !== 'resident') {
611
+ throw new InputError({ error: 'bad_lifecycle', message: `invalid lifecycle: ${value ?? ''}`, field: 'lifecycle', next: 'Pass `terminal` or `resident`.' });
612
+ }
613
+ // Resolve the node: explicit --node, else the node occupying --pane (the
614
+ // Alt+C menu passes #{pane_id}), else the caller (CRTR_NODE_ID).
615
+ const pane = input['pane'] ?? process.env['TMUX_PANE'];
616
+ let id = input['node'];
617
+ if (id === undefined || id === '')
618
+ id = nodeInPane(pane);
619
+ if (id === undefined || id === '')
620
+ id = process.env['CRTR_NODE_ID'];
621
+ if (id === undefined || id === '')
622
+ throw new InputError({ error: 'no_node', message: 'no node (set CRTR_NODE_ID, pass --node, or run from the agent\'s pane)', next: 'Run from inside a node, pass --node <id>, or --pane <pane>.' });
623
+ if (getNode(id) === null)
624
+ throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
625
+ // Rebuild the launch spec so a future revive comes back with the new
626
+ // lifecycle's prompt baked in (the live session is steered by the persona
627
+ // injector; this fixes the static prompt the daemon replays). Spine is fixed
628
+ // by parent-ness, so it carries through unchanged.
629
+ const target = getNode(id);
630
+ const { launch } = buildLaunchSpec(target.kind, target.mode, {
631
+ lifecycle: value,
632
+ hasManager: target.parent !== null,
633
+ });
634
+ const meta = updateNode(id, { lifecycle: value, launch });
635
+ // --detach: shove the still-running agent into the background crtr session,
636
+ // freeing the foreground pane. The pi is untouched (it keeps generating); now
637
+ // terminal, it pushes a final up the spine when it finishes.
638
+ let detached = false;
639
+ if (input['detach'] === true)
640
+ detached = detachToBackground(id, pane);
641
+ return { node_id: meta.node_id, lifecycle: meta.lifecycle, detached };
642
+ },
643
+ render: (r) => `<lifecycle node="${r['node_id']}" set="${r['lifecycle']}"${r['detached'] === true ? ' detached="true"' : ''}/>`,
644
+ });
645
+ // ---------------------------------------------------------------------------
514
646
  // node yield — refresh: discard context, revive fresh against the roadmap
515
647
  // ---------------------------------------------------------------------------
516
648
  const nodeYield = defineLeaf({
517
649
  name: 'yield',
650
+ description: 'refresh your context against your roadmap',
651
+ whenToUse: 'your context window is filling up but the mandate is unfinished: request a refresh — end your turn and revive fresh against your roadmap, leaving a note to your future self for the moment you wake. A base node auto-promotes to orchestrator first (a yield needs a roadmap to refresh against). Use `node promote` instead when you need to BECOME an orchestrator with no refresh pending',
518
652
  help: {
519
653
  name: 'node yield',
520
- summary: 'request a context refresh — you will be respawned fresh against your roadmap on your next stop (a terminal node auto-promotes to resident first)',
654
+ summary: 'request a context refresh — you will be respawned fresh against your roadmap on your next stop (a base node auto-promotes to orchestrator first)',
521
655
  params: [
522
- { kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'If this yield auto-promotes a terminal node, specialize it as this kind of orchestrator (developer, review, spec, design, plan, explore, general). Defaults to your current kind.' },
523
- { kind: 'stdin', name: 'message', required: false, constraint: 'A short note to your future self — what to do (if anything) the moment you wake fresh. Surfaced as <yield-message> in the next revive. Pass as a positional or pipe via heredoc.' },
656
+ { kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'If this yield auto-promotes a base node, specialize it as this kind of orchestrator (developer, review, spec, design, plan, explore, general). Defaults to your current kind.' },
657
+ { kind: 'stdin', name: 'message', required: true, constraint: 'A note to your future self — what to do the moment you wake fresh. Surfaced as <yield-message> in the next revive. Pass as a positional or pipe via heredoc.' },
524
658
  ],
525
659
  output: [
526
660
  { name: 'node_id', type: 'string', required: true, constraint: 'The yielding node.' },
527
- { name: 'promoted', type: 'boolean', required: true, constraint: 'True if this yield promoted a terminal node to resident.' },
661
+ { name: 'promoted', type: 'boolean', required: true, constraint: 'True if this yield promoted a base node to orchestrator.' },
528
662
  { name: 'will_refresh', type: 'boolean', required: true, constraint: 'Always true: end your turn and you will revive fresh against your roadmap.' },
529
663
  ],
530
664
  outputKind: 'object',
@@ -538,8 +672,10 @@ const nodeYield = defineLeaf({
538
672
  if (kind !== undefined)
539
673
  assertKind(kind);
540
674
  const message = typeof input['message'] === 'string' ? input['message'].trim() : '';
541
- if (message !== '')
542
- writeYieldMessage(id, message);
675
+ if (message === '') {
676
+ throw new InputError({ error: 'empty_message', message: 'a yield message is required (stdin or positional)', next: 'Pass a note to your future self as an argument or pipe it on stdin.' });
677
+ }
678
+ writeYieldMessage(id, message);
543
679
  const res = requestYield(id, kind !== undefined ? { kind } : {});
544
680
  return { node_id: res.meta.node_id, promoted: res.promoted, will_refresh: res.willRefresh };
545
681
  },
@@ -557,22 +693,9 @@ export function registerNode() {
557
693
  summary: 'spawn and navigate canvas nodes',
558
694
  model: 'Every agent is a node in one directed graph (the canvas); `subscribes_to` is the spine — spawn a child and you auto-subscribe (active) to it, so its finish wakes you.\n\n' +
559
695
  'WHEN TO DELEGATE: hand any self-contained unit of work to a child instead of doing it inline — it keeps your own context window (your scarce resource) free for steering. Spawn independent units in PARALLEL; a wake with idle workers is wasted. Serialize only true dependencies, and never let two live children edit the same files.\n\n' +
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' +
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.',
562
- children: [
563
- { name: 'new', desc: 'spawn a terminal worker as a background window', useWhen: 'delegating a self-contained unit of work', tier: 'important' },
564
- { name: 'inspect', desc: 'read the graph (list nodes / show one)', useWhen: 'surveying the canvas or inspecting a node' },
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)' },
568
- { name: 'session', desc: 'open a fresh root in its own tmux session', useWhen: 'starting a new top-level session from inside a node' },
569
- { name: 'msg', desc: 'direct-message any node at a wake tier', useWhen: 'steering or pinging a specific node (wakes it)' },
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' },
573
- { name: 'yield', desc: 'refresh your context against your roadmap', useWhen: 'your context window is filling up' },
574
- ],
696
+ '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 the wake message ALREADY IS the coalesced digest (the watcher drains your inbox to wake you), so don\'t re-run `crtr feed read` to "open" it (it would read empty, the cursor already advanced); instead dereference the report paths in that digest that matter, don\'t act on a one-line label. (`crtr feed read` is for proactively polling before a wake, or inspecting a child\'s inbox via `--node`; `--all` re-reads history with full message bodies.) Integrate, then either delegate the next units or finish.\n\n' +
697
+ '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 an orchestrator (holds a roadmap, delegates phases); when context fills, `node yield` to refresh against that roadmap.',
575
698
  },
576
- children: [nodeNew, nodeInspect, nodeFocus, nodeCycle, nodeDemote, nodeSession, nodeMsg, nodeSubscribe, nodeUnsubscribe, nodePromote, nodeYield],
699
+ children: [nodeNew, nodeInspect, nodeFocus, nodeCycle, nodeDemote, nodeClose, nodeMsg, nodeSubscribe, nodeUnsubscribe, nodePromote, nodeLifecycle, nodeYield],
577
700
  });
578
701
  }
@@ -8,6 +8,8 @@ import { resolveScopeArg, projectScopeRoot } from '../../core/scope.js';
8
8
  // ---------------------------------------------------------------------------
9
9
  const marketList = defineLeaf({
10
10
  name: 'list',
11
+ description: 'list registered marketplaces',
12
+ whenToUse: 'listing which marketplaces are registered, with their git URL, ref, and scope',
11
13
  help: {
12
14
  name: 'pkg market inspect list',
13
15
  summary: 'list registered marketplaces',
@@ -76,6 +78,8 @@ const marketList = defineLeaf({
76
78
  // ---------------------------------------------------------------------------
77
79
  const marketBrowse = defineLeaf({
78
80
  name: 'browse',
81
+ description: 'list plugins available in a marketplace',
82
+ whenToUse: 'exploring what a marketplace offers so you can decide before installing — lists every plugin in a marketplace index with its description, keywords, version, and whether it is already installed. Reach for this to pick which plugin to pull, then install it by name with `pkg market manage install`',
79
83
  help: {
80
84
  name: 'pkg market inspect browse',
81
85
  summary: 'list plugins available in a marketplace',
@@ -145,13 +149,11 @@ const marketBrowse = defineLeaf({
145
149
  });
146
150
  export const marketInspectBranch = defineBranch({
147
151
  name: 'inspect',
152
+ description: 'list or browse marketplaces',
153
+ whenToUse: 'reading marketplace metadata to decide before you install — list registered marketplaces, or browse the plugins available in one marketplace. Read-only; switch to `pkg market manage` to add a marketplace or install a plugin from it',
148
154
  help: {
149
155
  name: 'pkg market inspect',
150
156
  summary: 'read marketplace metadata without modifying state',
151
- children: [
152
- { name: 'list', desc: 'list registered marketplaces', useWhen: 'seeing which marketplaces are configured' },
153
- { name: 'browse', desc: 'list plugins available in a marketplace', useWhen: 'exploring what a marketplace offers before installing' },
154
- ],
155
157
  },
156
158
  children: [marketList, marketBrowse],
157
159
  });