@crouton-kit/crouter 0.3.14 → 0.3.16

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 (224) 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 +45 -67
  46. package/dist/commands/human.js +4 -14
  47. package/dist/commands/node.d.ts +11 -0
  48. package/dist/commands/node.js +221 -99
  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 +129 -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 +196 -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 +266 -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 +178 -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 +334 -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 +105 -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 +205 -4
  142. package/dist/core/canvas/focuses.d.ts +22 -0
  143. package/dist/core/canvas/focuses.js +81 -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 +24 -12
  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 +26 -12
  176. package/dist/core/runtime/launch.js +78 -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 +39 -1
  184. package/dist/core/runtime/nodes.js +69 -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 +299 -0
  188. package/dist/core/runtime/placement.js +688 -0
  189. package/dist/core/runtime/promote.d.ts +14 -7
  190. package/dist/core/runtime/promote.js +57 -67
  191. package/dist/core/runtime/reset.d.ts +47 -4
  192. package/dist/core/runtime/reset.js +223 -52
  193. package/dist/core/runtime/revive.d.ts +26 -2
  194. package/dist/core/runtime/revive.js +166 -39
  195. package/dist/core/runtime/spawn.d.ts +20 -5
  196. package/dist/core/runtime/spawn.js +163 -43
  197. package/dist/core/runtime/stop-guard.d.ts +1 -1
  198. package/dist/core/runtime/stop-guard.js +18 -8
  199. package/dist/core/runtime/tmux-chrome.d.ts +1 -0
  200. package/dist/core/runtime/tmux-chrome.js +4 -0
  201. package/dist/core/runtime/tmux.d.ts +113 -20
  202. package/dist/core/runtime/tmux.js +221 -39
  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 +594 -262
  216. package/dist/pi-extensions/canvas-resume.d.ts +22 -0
  217. package/dist/pi-extensions/canvas-resume.js +173 -0
  218. package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
  219. package/dist/pi-extensions/canvas-stophook.js +340 -228
  220. package/dist/types.d.ts +28 -0
  221. package/dist/types.js +16 -0
  222. package/package.json +2 -2
  223. package/dist/core/runtime/presence.d.ts +0 -38
  224. package/dist/core/runtime/presence.js +0 -154
@@ -0,0 +1,166 @@
1
+ // Session naming — turn a node's first prompt into a short, human-readable
2
+ // handle for the editor label.
3
+ //
4
+ // A node's editor label is `<kind> (<mode>) <name> <cycle>` (see editorLabel in
5
+ // launch.ts). The `<name>` is a 3-5 word kebab-case "description" derived from
6
+ // the first prompt by asking pi headlessly (`pi -p`), persisted on the node's
7
+ // meta so it survives revives and shows in every cycle.
8
+ //
9
+ // Two entry points:
10
+ // • generateSessionName — synchronous (spawnSync). For the CLI spawn paths
11
+ // (spawnChild / bootRoot) that run outside any pi event loop, where a brief
12
+ // block before launching the worker is fine and lets the FIRST pi session
13
+ // already carry the name.
14
+ // • generateAndPersistName — async (execFile, non-blocking). For the bare-root
15
+ // case where the prompt only arrives as the first interactive message inside
16
+ // a live pi process; it must never block the event loop. Persists the name
17
+ // to meta so the label picks it up on the next cycle.
18
+ //
19
+ // Both are best-effort: a failed/slow/garbled pi call falls back to a local slug
20
+ // of the prompt, so a node always gets a sane name.
21
+ import { spawnSync, execFile } from 'node:child_process';
22
+ import { getNode, updateNode } from '../canvas/index.js';
23
+ /** Cap on prompt text fed to the namer — a name needs only the gist. */
24
+ const PROMPT_CAP = 2000;
25
+ /** Wall-clock budget for the headless pi call before we fall back to a slug. */
26
+ const NAME_TIMEOUT_MS = 20_000;
27
+ const NAME_SYSTEM_PROMPT = 'You name coding-agent work sessions. This name is a label used to identify the ' +
28
+ 'session at a glance among many other concurrent programming sessions, so it must ' +
29
+ 'describe what the task is about. Reply with ONLY a concise 3-5 word name in ' +
30
+ 'kebab-case: lowercase words joined by single hyphens (e.g. `refactor-auth-token-flow`, ' +
31
+ '`add-csv-export-endpoint`). No punctuation, quotes, prose, or trailing text. ' +
32
+ 'Output JUST the name, nothing else.';
33
+ /** Put the raw task text FIRST in a delimited block, then the instruction, so the
34
+ * model reads the content before being told what to do and never mistakes the
35
+ * prompt's own text for the instruction. The prompt is capped first, so the
36
+ * closing tag is always present. */
37
+ function nameUserPrompt(prompt) {
38
+ return `<prompt>\n${prompt.slice(0, PROMPT_CAP)}\n</prompt>\n\nName this session based on the task above. The name should describe what the task is about, so it can be identified among many other programming sessions. Output JUST the name, nothing else.`;
39
+ }
40
+ /** A short stop-word set so the local-slug fallback skips filler words. */
41
+ const STOPWORDS = new Set([
42
+ 'the', 'a', 'an', 'and', 'or', 'but', 'to', 'of', 'in', 'on', 'for', 'with',
43
+ 'is', 'are', 'be', 'this', 'that', 'it', 'as', 'at', 'by', 'from', 'into',
44
+ 'please', 'can', 'you', 'i', 'we', 'my', 'our', 'me', 'so', 'then',
45
+ ]);
46
+ /** Coerce arbitrary text into a 3-5 word kebab-case name, or '' if nothing
47
+ * usable survives. Lowercases, keeps [a-z0-9], collapses everything else to a
48
+ * single hyphen, and clamps to the first 5 words. */
49
+ export function sanitizeSessionName(raw) {
50
+ const firstLine = (raw ?? '').split('\n').map((l) => l.trim()).find((l) => l !== '') ?? '';
51
+ const words = firstLine
52
+ .toLowerCase()
53
+ .replace(/[^a-z0-9]+/g, '-')
54
+ .split('-')
55
+ .filter((w) => w !== '');
56
+ return words.slice(0, 5).join('-');
57
+ }
58
+ /** Local fallback: derive a name straight from the prompt (no pi call). Drops
59
+ * stop-words, takes the first few content words. */
60
+ export function slugFromPrompt(prompt) {
61
+ const words = (prompt ?? '')
62
+ .toLowerCase()
63
+ .replace(/[^a-z0-9]+/g, ' ')
64
+ .split(' ')
65
+ .filter((w) => w !== '' && !STOPWORDS.has(w));
66
+ const picked = (words.length > 0 ? words : (prompt ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').split(' ').filter(Boolean))
67
+ .slice(0, 3);
68
+ return sanitizeSessionName(picked.join('-')) || 'session';
69
+ }
70
+ /** Default namer model — Anthropic's small/fast model. Naming is a one-line
71
+ * classification, so we pin Haiku (cheap, quick) instead of inheriting the
72
+ * node's heavyweight default. Override with CRTR_NAME_MODEL. */
73
+ const DEFAULT_NAME_MODEL = 'anthropic/claude-haiku-4-5';
74
+ /** The pi argv for a headless name request. Stripped down (no tools, session,
75
+ * context files, extensions, skills, templates, themes) so it's fast and
76
+ * side-effect free. Pinned to Haiku with thinking off — naming is a trivial
77
+ * classification that never needs a reasoning budget. Override the model with
78
+ * CRTR_NAME_MODEL. */
79
+ function nameArgs(prompt) {
80
+ const override = process.env['CRTR_NAME_MODEL'];
81
+ const model = override !== undefined && override.trim() !== '' ? override.trim() : DEFAULT_NAME_MODEL;
82
+ const argv = [
83
+ '-p',
84
+ '--no-session',
85
+ '--no-context-files',
86
+ '--no-extensions',
87
+ '--no-skills',
88
+ '--no-prompt-templates',
89
+ '--no-themes',
90
+ '--no-tools',
91
+ '--mode', 'text',
92
+ // Naming is a trivial one-line classification — no thinking budget, ever.
93
+ '--thinking', 'off',
94
+ '--model', model,
95
+ ];
96
+ argv.push('--system-prompt', NAME_SYSTEM_PROMPT);
97
+ argv.push(nameUserPrompt(prompt));
98
+ return argv;
99
+ }
100
+ /** Synchronously ask pi for a 2-4 word kebab name for `prompt`. Blocks up to
101
+ * NAME_TIMEOUT_MS; on any failure (non-zero exit, timeout, empty/garbled
102
+ * output) falls back to a local slug. Returns '' only for an empty prompt. */
103
+ export function generateSessionName(prompt) {
104
+ const body = (prompt ?? '').trim();
105
+ if (body === '')
106
+ return '';
107
+ try {
108
+ const r = spawnSync('pi', nameArgs(body), {
109
+ encoding: 'utf8',
110
+ timeout: NAME_TIMEOUT_MS,
111
+ // Don't inherit a TUI; capture stdout only.
112
+ stdio: ['ignore', 'pipe', 'ignore'],
113
+ });
114
+ if (r.status === 0 && typeof r.stdout === 'string') {
115
+ const name = sanitizeSessionName(r.stdout);
116
+ if (name !== '')
117
+ return name;
118
+ }
119
+ }
120
+ catch {
121
+ // fall through to slug
122
+ }
123
+ return slugFromPrompt(body);
124
+ }
125
+ /** Asynchronously generate a name for `prompt` and persist it to the node's
126
+ * meta as `description` — only if the node has none yet (so a later message
127
+ * never clobbers it). Non-blocking: safe to call from inside a live pi event
128
+ * loop. Best-effort; swallows all errors.
129
+ *
130
+ * `onNamed` (optional) fires with the freshly-persisted meta the moment the
131
+ * name lands — the bare-root path passes a callback that calls
132
+ * pi.setSessionName(editorLabel(meta)) so the LIVE editor label updates in the
133
+ * same session, instead of waiting for the next revive/cycle. */
134
+ export function generateAndPersistName(nodeId, prompt, onNamed) {
135
+ const body = (prompt ?? '').trim();
136
+ if (body === '')
137
+ return;
138
+ const persist = (name) => {
139
+ try {
140
+ const meta = getNode(nodeId);
141
+ if (meta === null)
142
+ return;
143
+ if ((meta.description ?? '').trim() !== '')
144
+ return; // already named
145
+ const clean = sanitizeSessionName(name);
146
+ const updated = updateNode(nodeId, { description: clean !== '' ? clean : slugFromPrompt(body) });
147
+ onNamed?.(updated);
148
+ }
149
+ catch {
150
+ // best-effort
151
+ }
152
+ };
153
+ try {
154
+ execFile('pi', nameArgs(body), { encoding: 'utf8', timeout: NAME_TIMEOUT_MS }, (err, stdout) => {
155
+ if (err || typeof stdout !== 'string') {
156
+ persist(slugFromPrompt(body));
157
+ return;
158
+ }
159
+ const name = sanitizeSessionName(stdout);
160
+ persist(name !== '' ? name : slugFromPrompt(body));
161
+ });
162
+ }
163
+ catch {
164
+ persist(slugFromPrompt(body));
165
+ }
166
+ }
@@ -1,9 +1,41 @@
1
1
  import { type NodeMeta, type Mode, type Lifecycle, type LaunchSpec } from '../canvas/index.js';
2
2
  /** Generate a node id in the same shape as job ids (time-sortable + random). */
3
3
  export declare function newNodeId(): string;
4
+ /** The single, shared tmux session that ALL canvas node windows live in.
5
+ * Overridable with CRTR_NODE_SESSION (default `crtr`). Every root and every
6
+ * child opens a window here rather than cluttering the user's own working
7
+ * session — switch to it to browse the whole live graph, ignore it otherwise.
8
+ * Pure policy (env only, no tmux call), so it lives in the node layer, not the
9
+ * driver; the tmux driver imports it from here for installMenuBinding's use. */
10
+ export declare function nodeSession(): string;
11
+ /** Resolve the tmux session a freshly-born node's window/pane opens into — and
12
+ * thus its durable REVIVE-HOME (`home_session`). Pure so the birth decision is
13
+ * unit-testable without a live tmux:
14
+ * - managed background child (`adoptCaller=false`) → the shared backstage:
15
+ * the inherited `CRTR_ROOT_SESSION`, else `nodeSession()` (`crtr`).
16
+ * - independent `--root` / inline front door (`adoptCaller=true`) → the
17
+ * caller's CURRENT session when inside tmux (`here`), else the backstage.
18
+ * This is exactly the session each birth site already places the node into;
19
+ * centralizing it keeps `home_session` and the actual placement in lockstep. */
20
+ export declare function resolveBirthSession(opts: {
21
+ /** True for an independent root or the inline front door (both adopt the
22
+ * caller's session); false for a managed background child. */
23
+ adoptCaller: boolean;
24
+ /** The caller's current tmux location, or null when not inside tmux. */
25
+ here: {
26
+ session: string;
27
+ } | null;
28
+ /** The inherited CRTR_ROOT_SESSION (the backstage the subtree flows into). */
29
+ rootSession?: string | null;
30
+ }): string;
31
+ /** A node's durable REVIVE-HOME, with the legacy back-compat default. Nodes born
32
+ * before `home_session` existed have no such field in meta — they fall back to
33
+ * their last live LOCATION (`tmux_session`), then to the shared backstage
34
+ * (`nodeSession()`). The defaulted read for the placement layer; a present
35
+ * `home_session` is always returned verbatim. */
36
+ export declare function homeSessionOf(nodeId: string): string;
4
37
  export interface NodeContext {
5
38
  nodeId: string | null;
6
- parentNodeId: string | null;
7
39
  kind: string | null;
8
40
  mode: Mode | null;
9
41
  }
@@ -21,8 +53,14 @@ export interface SpawnNodeOpts {
21
53
  lifecycle?: Lifecycle;
22
54
  cwd: string;
23
55
  name?: string;
56
+ /** Editor-label handle (2-4 word kebab-case) for the node's first prompt. */
57
+ description?: string;
24
58
  /** Parent node id. Omit for a user-opened root. */
25
59
  parent?: string | null;
60
+ /** Who spawned me (the `spawned_by` provenance edge), when it differs from
61
+ * `parent` — e.g. an independent root (parent=null) still records its
62
+ * spawner. Defaults to `parent`. */
63
+ spawnedBy?: string | null;
26
64
  /** New subscriptions this node opens default to passive when true. */
27
65
  passiveDefault?: boolean;
28
66
  /** Resolved pi launch recipe (from resolve(kind,mode)). */
@@ -18,6 +18,47 @@ import { createNode, getNode, subscribe, recordSpawn, } from '../canvas/index.js
18
18
  export function newNodeId() {
19
19
  return `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
20
20
  }
21
+ /** The single, shared tmux session that ALL canvas node windows live in.
22
+ * Overridable with CRTR_NODE_SESSION (default `crtr`). Every root and every
23
+ * child opens a window here rather than cluttering the user's own working
24
+ * session — switch to it to browse the whole live graph, ignore it otherwise.
25
+ * Pure policy (env only, no tmux call), so it lives in the node layer, not the
26
+ * driver; the tmux driver imports it from here for installMenuBinding's use. */
27
+ export function nodeSession() {
28
+ const v = process.env['CRTR_NODE_SESSION'];
29
+ return v !== undefined && v !== '' ? v : 'crtr';
30
+ }
31
+ // ---------------------------------------------------------------------------
32
+ // REVIVE-HOME (home_session) — the durable session a node is (re)opened into
33
+ // ---------------------------------------------------------------------------
34
+ /** Resolve the tmux session a freshly-born node's window/pane opens into — and
35
+ * thus its durable REVIVE-HOME (`home_session`). Pure so the birth decision is
36
+ * unit-testable without a live tmux:
37
+ * - managed background child (`adoptCaller=false`) → the shared backstage:
38
+ * the inherited `CRTR_ROOT_SESSION`, else `nodeSession()` (`crtr`).
39
+ * - independent `--root` / inline front door (`adoptCaller=true`) → the
40
+ * caller's CURRENT session when inside tmux (`here`), else the backstage.
41
+ * This is exactly the session each birth site already places the node into;
42
+ * centralizing it keeps `home_session` and the actual placement in lockstep. */
43
+ export function resolveBirthSession(opts) {
44
+ const backstage = opts.rootSession !== undefined && opts.rootSession !== null && opts.rootSession !== ''
45
+ ? opts.rootSession
46
+ : nodeSession();
47
+ if (opts.adoptCaller && opts.here !== null)
48
+ return opts.here.session;
49
+ return backstage;
50
+ }
51
+ /** A node's durable REVIVE-HOME, with the legacy back-compat default. Nodes born
52
+ * before `home_session` existed have no such field in meta — they fall back to
53
+ * their last live LOCATION (`tmux_session`), then to the shared backstage
54
+ * (`nodeSession()`). The defaulted read for the placement layer; a present
55
+ * `home_session` is always returned verbatim. */
56
+ export function homeSessionOf(nodeId) {
57
+ const meta = getNode(nodeId);
58
+ if (meta === null)
59
+ return nodeSession();
60
+ return meta.home_session ?? meta.tmux_session ?? nodeSession();
61
+ }
21
62
  /** Read the current node's identity from the environment. A spawned pi process
22
63
  * runs with CRTR_NODE_ID set; its own `crtr` invocations spawn children under
23
64
  * it by reading CRTR_NODE_ID as the parent. */
@@ -25,7 +66,6 @@ export function currentNodeContext() {
25
66
  const env = process.env;
26
67
  return {
27
68
  nodeId: env['CRTR_NODE_ID'] ?? null,
28
- parentNodeId: env['CRTR_NODE_ID'] ?? null, // a child's parent is the live node
29
69
  kind: env['CRTR_KIND'] ?? null,
30
70
  mode: env['CRTR_MODE'] ?? null,
31
71
  };
@@ -63,33 +103,52 @@ export function nodeEnv(meta) {
63
103
  export function spawnNode(opts) {
64
104
  const parent = opts.parent ?? null;
65
105
  const isRoot = parent === null;
106
+ // Provenance is independent of the spine: a root has no parent but still
107
+ // records who spawned it. A child's spawner is its parent unless overridden.
108
+ const spawnedBy = opts.spawnedBy ?? parent;
109
+ const mode = opts.mode ?? 'base';
110
+ // A user-opened root is resident (a conversation you live in); a spawned node
111
+ // is terminal until it must persist (promotion handles that later).
112
+ const lifecycle = opts.lifecycle ?? (isRoot ? 'resident' : 'terminal');
66
113
  const meta = {
67
114
  node_id: opts.nodeId ?? newNodeId(),
68
115
  name: opts.name ?? opts.kind,
116
+ description: opts.description,
117
+ cycles: 0,
69
118
  created: new Date().toISOString(),
70
119
  cwd: opts.cwd,
71
120
  kind: opts.kind,
72
- mode: opts.mode ?? 'base',
73
- // A user-opened root is resident (a conversation you live in); a spawned
74
- // node is terminal until it must persist (promotion handles that later).
75
- lifecycle: opts.lifecycle ?? (isRoot ? 'resident' : 'terminal'),
121
+ mode,
122
+ lifecycle,
123
+ // Born already acked to its initial persona: a fresh node has been "given
124
+ // guidance" for the state it starts in (its bearings carry it), so the
125
+ // persona injector sees no drift on its first turn boundary.
126
+ persona_ack: { mode, lifecycle },
76
127
  status: 'active',
77
128
  parent,
129
+ spawned_by: spawnedBy,
78
130
  passive_default: opts.passiveDefault ?? false,
79
131
  intent: null,
80
132
  pi_session_id: null,
133
+ pi_session_file: null,
81
134
  launch: opts.launch,
82
135
  };
136
+ // Validate BEFORE minting: a bad parent must leave no half-born orphan row or
137
+ // dirs behind, so the parent's existence is checked before createNode
138
+ // scaffolds anything on disk or in the db.
139
+ if (parent !== null && getNode(parent) === null) {
140
+ throw new Error(`cannot spawn under unknown parent node: ${parent}`);
141
+ }
83
142
  createNode(meta);
84
143
  if (parent !== null) {
85
- if (getNode(parent) === null) {
86
- throw new Error(`cannot spawn under unknown parent node: ${parent}`);
87
- }
88
144
  // The load-bearing seed: parent subscribes (active) to child so it learns
89
145
  // when the work finishes. This mirrors spawn structure into the spine.
146
+ // A root (parent=null) gets NO subscription — nobody is woken by it.
90
147
  subscribe(parent, meta.node_id, true);
91
- // Audit-only provenance.
92
- recordSpawn(meta.node_id, parent);
148
+ }
149
+ // Audit-only provenance edge — recorded for a root too (from its spawner).
150
+ if (spawnedBy !== null && spawnedBy !== undefined && getNode(spawnedBy) !== null) {
151
+ recordSpawn(meta.node_id, spawnedBy);
93
152
  }
94
153
  return meta;
95
154
  }
@@ -0,0 +1,25 @@
1
+ import { type Mode, type Lifecycle } from '../canvas/index.js';
2
+ /** The two-axis persona state the injector keys on. */
3
+ export interface Persona {
4
+ mode: Mode;
5
+ lifecycle: Lifecycle;
6
+ }
7
+ export interface PersonaDriftResult {
8
+ from: Persona;
9
+ to: Persona;
10
+ /** The built transition guidance to inject for `to`. */
11
+ guidance: string;
12
+ }
13
+ /** Build the injected transition prompt for a `from → to` persona change.
14
+ * Concatenates the relevant section per changed axis (both when both changed).
15
+ * Pure read of the node's roadmap/memory for the base→orchestrator case. */
16
+ export declare function transitionGuidance(nodeId: string, from: Persona, to: Persona): string;
17
+ /** Compare a node's live {mode,lifecycle} against its `persona_ack` (the last
18
+ * state it was given guidance for). Returns the transition + built guidance
19
+ * when they differ, else null. Does NOT mutate — the caller delivers the
20
+ * guidance, then `commitPersonaAck`s the new state. An unset `persona_ack`
21
+ * (legacy node) defaults to the current persona, so it reads as no drift and
22
+ * never fabricates spurious guidance. */
23
+ export declare function personaDrift(nodeId: string): PersonaDriftResult | null;
24
+ /** Commit the persona state the node has now been given guidance for. */
25
+ export declare function commitPersonaAck(nodeId: string, to: Persona): void;
@@ -0,0 +1,139 @@
1
+ // persona.ts — the CENTRALIZED persona-transition injector.
2
+ //
3
+ // A node has two orthogonal, independently switchable axes:
4
+ // • mode — base (hands-on, finishes in one window) ↔ orchestrator
5
+ // (delegates, holds a roadmap, survives refresh cycles + yields)
6
+ // • lifecycle — terminal (owes a final up the spine, reaps when done) ↔
7
+ // resident (interactable, stays dormant, never forced to submit)
8
+ //
9
+ // Whenever EITHER axis changes from the value the node was last GIVEN guidance
10
+ // for, the node must be prompt-injected with guidance for its new state —
11
+ // automatically, here, not by each state-changing command. Commands just call
12
+ // `updateNode({ mode|lifecycle })`; this module is the single source of the
13
+ // transition prose, delivered from exactly two sites:
14
+ // • the stophook turn_end hook (self-changes this turn + external changes
15
+ // while the node is active), and
16
+ // • the revive kickoff (external changes made while the node was dormant).
17
+ //
18
+ // The `persona_ack` meta field records the last {mode,lifecycle} the node was
19
+ // given guidance for (born equal to its initial persona at spawn, so a fresh
20
+ // worker never gets spurious guidance). `personaDrift` compares live meta to it;
21
+ // the caller delivers the guidance, then commits the ack.
22
+ import { getNode, updateNode } from '../canvas/index.js';
23
+ import { loadKernel, loadPersona, loadLifecycleFragment } from '../personas/index.js';
24
+ import { resolveSkill } from '../resolver.js';
25
+ import { readText } from '../fs-utils.js';
26
+ import { parseFrontmatter } from '../frontmatter.js';
27
+ import { readRoadmap, roadmapPath } from './roadmap.js';
28
+ import { orchestratorContextNote } from './bearings.js';
29
+ import { memoryPath, memoryDir, userMemoryPath, userMemoryDir, projectMemoryPath, projectMemoryDir, } from './memory.js';
30
+ // ---------------------------------------------------------------------------
31
+ // base→orchestrator guidance (the roadmap-shaping dump) — MOVED here from
32
+ // promote.ts so the injector is the one place that builds it.
33
+ // ---------------------------------------------------------------------------
34
+ /** Load a skill's body text by name, or null if it can't be resolved. Used to
35
+ * inline a kind's roadmap-shaping skill into the orchestration guidance. */
36
+ function loadSkillBody(name) {
37
+ try {
38
+ const skill = resolveSkill(name, {});
39
+ return parseFrontmatter(readText(skill.path)).body.trim();
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ /** The base→orchestrator guidance dump, specialized to the node's kind: the
46
+ * shared kernel + that kind's roadmap-shaping skill + the roadmap scaffold the
47
+ * node must author + the orchestrator context-dir framing + the three memory
48
+ * stores. The node is now a delegator whose scarce resource is its own context
49
+ * window. (Lifecycle is left to its own section — promotion no longer forces
50
+ * resident, so this never asserts residency.) */
51
+ function orchestrationGuidance(nodeId, kind, cwd) {
52
+ const kernel = loadKernel();
53
+ const orch = loadPersona(kind, 'orchestrator');
54
+ const roadmapSkill = typeof orch?.frontmatter?.['roadmapSkill'] === 'string'
55
+ ? orch.frontmatter['roadmapSkill']
56
+ : undefined;
57
+ const skillBody = roadmapSkill ? loadSkillBody(roadmapSkill) : null;
58
+ const roadmap = readRoadmap(nodeId) ?? '(no roadmap yet)';
59
+ const rmPath = roadmapPath(nodeId);
60
+ const parts = [
61
+ `You are now a ${kind.toUpperCase()} ORCHESTRATOR. Your scarce resource is your own context window.`,
62
+ 'Your job is to manage context and delegate — not to do the goal yourself.',
63
+ '',
64
+ kernel,
65
+ ];
66
+ if (skillBody) {
67
+ parts.push('', `--- How to shape a ${kind} roadmap (skill: ${roadmapSkill}) ---`, '', skillBody);
68
+ }
69
+ parts.push('', `Your roadmap scaffold (\`${rmPath}\`) — author it now: state the goal, exit criteria, and the phase skeleton, using the approach above. Current contents:`, '', roadmap, '',
70
+ // The orchestrator framing for the context dir — the missing guidance a
71
+ // promoted node never got at spawn (it spawned as a base worker). Same note
72
+ // a born-orchestrator gets in its <crtr-context> bearings block.
73
+ orchestratorContextNote(nodeId), '', 'Your long-term memory now exists across three seeded stores (write to them directly), each a different scope per "Your long-term memory" above:', ` • user-global \`${userMemoryDir()}\` (index \`${userMemoryPath()}\`) — who the human is, how they like to work; loaded into every orchestrator everywhere.`, ` • project \`${projectMemoryDir(cwd)}\` (index \`${projectMemoryPath(cwd)}\`) — facts bound to this repo; loaded into every orchestrator working here.`, ` • node-local \`${memoryDir(nodeId)}\` (index \`${memoryPath(nodeId)}\`) — facts specific to this goal; they die with this node.`, 'A memory\'s `type` decides which store it lands in (see "Your long-term memory"). These same paths ride into every future wake in your `<crtr-context>` block.', '', 'Then delegate each phase with `crtr node new --kind <kind>`. When your context fills, run `crtr node yield` to refresh against this roadmap.');
74
+ return parts.join('\n');
75
+ }
76
+ // ---------------------------------------------------------------------------
77
+ // The other three transitions — short, prescriptive, audience = the node's
78
+ // agent (decision-first; one well-placed "don't").
79
+ // ---------------------------------------------------------------------------
80
+ /** orchestrator → base (demote): hands-on again, finish in-window. */
81
+ function baseModeGuidance() {
82
+ return ('You are HANDS-ON again — base mode. Do the work yourself in THIS window and finish it here; ' +
83
+ 'stop delegating by default. You no longer drive a roadmap, so `crtr node yield` is not your exit. ' +
84
+ 'Spawn a child only for a cleanly separable unit, never as your first move.');
85
+ }
86
+ // The lifecycle transition prose is the SAME contract that's baked into the
87
+ // static system prompt at birth — so both load from the one source
88
+ // (`personas/lifecycle/{terminal,resident}.md`) and can never drift. The flip
89
+ // only re-delivers that fragment as the node's new-state steer.
90
+ /** terminal → resident: interactable, never forced to submit. */
91
+ function residentLifecycleGuidance() {
92
+ return loadLifecycleFragment('resident');
93
+ }
94
+ /** resident → terminal: owes a final, reaps when done. */
95
+ function terminalLifecycleGuidance() {
96
+ return loadLifecycleFragment('terminal');
97
+ }
98
+ /** Build the injected transition prompt for a `from → to` persona change.
99
+ * Concatenates the relevant section per changed axis (both when both changed).
100
+ * Pure read of the node's roadmap/memory for the base→orchestrator case. */
101
+ export function transitionGuidance(nodeId, from, to) {
102
+ const sections = [];
103
+ if (from.mode !== to.mode) {
104
+ if (to.mode === 'orchestrator') {
105
+ const node = getNode(nodeId);
106
+ sections.push(orchestrationGuidance(nodeId, node?.kind ?? 'general', node?.cwd ?? process.cwd()));
107
+ }
108
+ else {
109
+ sections.push(baseModeGuidance());
110
+ }
111
+ }
112
+ if (from.lifecycle !== to.lifecycle) {
113
+ sections.push(to.lifecycle === 'resident' ? residentLifecycleGuidance() : terminalLifecycleGuidance());
114
+ }
115
+ return sections.join('\n\n');
116
+ }
117
+ // ---------------------------------------------------------------------------
118
+ // Detector + ack commit.
119
+ // ---------------------------------------------------------------------------
120
+ /** Compare a node's live {mode,lifecycle} against its `persona_ack` (the last
121
+ * state it was given guidance for). Returns the transition + built guidance
122
+ * when they differ, else null. Does NOT mutate — the caller delivers the
123
+ * guidance, then `commitPersonaAck`s the new state. An unset `persona_ack`
124
+ * (legacy node) defaults to the current persona, so it reads as no drift and
125
+ * never fabricates spurious guidance. */
126
+ export function personaDrift(nodeId) {
127
+ const meta = getNode(nodeId);
128
+ if (meta === null)
129
+ return null;
130
+ const to = { mode: meta.mode, lifecycle: meta.lifecycle };
131
+ const from = meta.persona_ack ?? { mode: meta.mode, lifecycle: meta.lifecycle };
132
+ if (from.mode === to.mode && from.lifecycle === to.lifecycle)
133
+ return null;
134
+ return { from, to, guidance: transitionGuidance(nodeId, from, to) };
135
+ }
136
+ /** Commit the persona state the node has now been given guidance for. */
137
+ export function commitPersonaAck(nodeId, to) {
138
+ updateNode(nodeId, { persona_ack: to });
139
+ }