@crouton-kit/crouter 0.3.11 → 0.3.13

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 (182) hide show
  1. package/bin/crtrd +2 -0
  2. package/dist/builtin-personas/design/base.md +9 -0
  3. package/dist/builtin-personas/design/orchestrator.md +10 -0
  4. package/dist/builtin-personas/developer/base.md +9 -0
  5. package/dist/builtin-personas/developer/orchestrator.md +12 -0
  6. package/dist/builtin-personas/explore/base.md +9 -0
  7. package/dist/builtin-personas/explore/orchestrator.md +9 -0
  8. package/dist/builtin-personas/general/base.md +5 -0
  9. package/dist/builtin-personas/general/orchestrator.md +7 -0
  10. package/dist/builtin-personas/orchestration-kernel.md +71 -0
  11. package/dist/builtin-personas/plan/base.md +7 -0
  12. package/dist/builtin-personas/plan/orchestrator.md +12 -0
  13. package/dist/builtin-personas/review/base.md +7 -0
  14. package/dist/builtin-personas/review/orchestrator.md +9 -0
  15. package/dist/builtin-personas/runtime-base.md +39 -0
  16. package/dist/builtin-personas/spec/base.md +7 -0
  17. package/dist/builtin-personas/spec/orchestrator.md +10 -0
  18. package/dist/builtin-skills/skills/design/SKILL.md +51 -0
  19. package/dist/builtin-skills/skills/development/SKILL.md +109 -0
  20. package/dist/builtin-skills/skills/planning/SKILL.md +59 -0
  21. package/dist/builtin-skills/skills/spec/SKILL.md +83 -0
  22. package/dist/cli.js +14 -6
  23. package/dist/commands/{mode.d.ts → attention.d.ts} +1 -1
  24. package/dist/commands/attention.js +152 -0
  25. package/dist/commands/canvas.d.ts +2 -0
  26. package/dist/commands/canvas.js +35 -0
  27. package/dist/commands/daemon.d.ts +2 -0
  28. package/dist/commands/daemon.js +111 -0
  29. package/dist/commands/dashboard.d.ts +2 -0
  30. package/dist/commands/dashboard.js +65 -0
  31. package/dist/commands/human/prompts.d.ts +5 -0
  32. package/dist/commands/human/prompts.js +269 -0
  33. package/dist/commands/human/queue.d.ts +3 -0
  34. package/dist/commands/human/queue.js +133 -0
  35. package/dist/commands/human/shared.d.ts +43 -0
  36. package/dist/commands/human/shared.js +107 -0
  37. package/dist/commands/human.js +10 -454
  38. package/dist/commands/node.d.ts +2 -0
  39. package/dist/commands/node.js +407 -0
  40. package/dist/commands/pkg/market-inspect.d.ts +1 -0
  41. package/dist/commands/pkg/market-inspect.js +157 -0
  42. package/dist/commands/pkg/market-manage.d.ts +1 -0
  43. package/dist/commands/pkg/market-manage.js +316 -0
  44. package/dist/commands/pkg/market.d.ts +1 -0
  45. package/dist/commands/pkg/market.js +16 -0
  46. package/dist/commands/pkg/plugin-inspect.d.ts +1 -0
  47. package/dist/commands/pkg/plugin-inspect.js +142 -0
  48. package/dist/commands/pkg/plugin-manage.d.ts +1 -0
  49. package/dist/commands/pkg/plugin-manage.js +294 -0
  50. package/dist/commands/pkg/plugin.d.ts +1 -0
  51. package/dist/commands/pkg/plugin.js +16 -0
  52. package/dist/commands/pkg/shared.d.ts +5 -0
  53. package/dist/commands/pkg/shared.js +61 -0
  54. package/dist/commands/pkg.js +3 -1004
  55. package/dist/commands/push.d.ts +3 -0
  56. package/dist/commands/push.js +159 -0
  57. package/dist/commands/revive.d.ts +2 -0
  58. package/dist/commands/revive.js +64 -0
  59. package/dist/commands/skill/author.d.ts +3 -0
  60. package/dist/commands/skill/author.js +147 -0
  61. package/dist/commands/skill/find.d.ts +4 -0
  62. package/dist/commands/skill/find.js +254 -0
  63. package/dist/commands/skill/read.d.ts +1 -0
  64. package/dist/commands/skill/read.js +89 -0
  65. package/dist/commands/skill/shared.d.ts +19 -0
  66. package/dist/commands/skill/shared.js +207 -0
  67. package/dist/commands/skill/state.d.ts +3 -0
  68. package/dist/commands/skill/state.js +69 -0
  69. package/dist/commands/skill.js +6 -691
  70. package/dist/commands/sys/config.d.ts +1 -0
  71. package/dist/commands/sys/config.js +186 -0
  72. package/dist/commands/sys/doctor.d.ts +1 -0
  73. package/dist/commands/sys/doctor.js +369 -0
  74. package/dist/commands/sys/shared.d.ts +3 -0
  75. package/dist/commands/sys/shared.js +24 -0
  76. package/dist/commands/sys/update.d.ts +2 -0
  77. package/dist/commands/sys/update.js +114 -0
  78. package/dist/commands/sys.js +4 -694
  79. package/dist/core/__tests__/argv-parser.test.js +19 -1
  80. package/dist/core/__tests__/canvas-inbox-watcher.test.js +100 -0
  81. package/dist/core/__tests__/canvas.test.js +154 -0
  82. package/dist/core/__tests__/reset.test.js +105 -0
  83. package/dist/core/canvas/attention.d.ts +24 -0
  84. package/dist/core/canvas/attention.js +94 -0
  85. package/dist/core/canvas/canvas.d.ts +40 -0
  86. package/dist/core/canvas/canvas.js +210 -0
  87. package/dist/core/canvas/db.d.ts +7 -0
  88. package/dist/core/canvas/db.js +61 -0
  89. package/dist/core/canvas/index.d.ts +4 -0
  90. package/dist/core/canvas/index.js +6 -0
  91. package/dist/core/canvas/paths.d.ts +16 -0
  92. package/dist/core/canvas/paths.js +62 -0
  93. package/dist/core/canvas/render.d.ts +30 -0
  94. package/dist/core/canvas/render.js +186 -0
  95. package/dist/core/canvas/types.d.ts +87 -0
  96. package/dist/core/canvas/types.js +8 -0
  97. package/dist/core/command.d.ts +5 -0
  98. package/dist/core/command.js +35 -10
  99. package/dist/core/feed/feed.d.ts +43 -0
  100. package/dist/core/feed/feed.js +116 -0
  101. package/dist/core/feed/inbox.d.ts +50 -0
  102. package/dist/core/feed/inbox.js +124 -0
  103. package/dist/core/help.js +5 -3
  104. package/dist/core/io.d.ts +15 -1
  105. package/dist/core/io.js +56 -6
  106. package/dist/core/personas/index.d.ts +12 -0
  107. package/dist/core/personas/index.js +10 -0
  108. package/dist/core/personas/loader.d.ts +44 -0
  109. package/dist/core/personas/loader.js +157 -0
  110. package/dist/core/personas/resolve.d.ts +36 -0
  111. package/dist/core/personas/resolve.js +110 -0
  112. package/dist/core/render.d.ts +11 -0
  113. package/dist/core/render.js +126 -0
  114. package/dist/core/resolver.d.ts +10 -0
  115. package/dist/core/resolver.js +109 -1
  116. package/dist/core/runtime/front-door.d.ts +10 -0
  117. package/dist/core/runtime/front-door.js +97 -0
  118. package/dist/core/runtime/kickoff.d.ts +23 -0
  119. package/dist/core/runtime/kickoff.js +134 -0
  120. package/dist/core/runtime/launch.d.ts +34 -0
  121. package/dist/core/runtime/launch.js +85 -0
  122. package/dist/core/runtime/nodes.d.ts +38 -0
  123. package/dist/core/runtime/nodes.js +95 -0
  124. package/dist/core/runtime/presence.d.ts +55 -0
  125. package/dist/core/runtime/presence.js +198 -0
  126. package/dist/core/runtime/promote.d.ts +30 -0
  127. package/dist/core/runtime/promote.js +105 -0
  128. package/dist/core/runtime/reset.d.ts +13 -0
  129. package/dist/core/runtime/reset.js +97 -0
  130. package/dist/core/runtime/revive.d.ts +26 -0
  131. package/dist/core/runtime/revive.js +87 -0
  132. package/dist/core/runtime/roadmap.d.ts +12 -0
  133. package/dist/core/runtime/roadmap.js +52 -0
  134. package/dist/core/runtime/spawn.d.ts +31 -0
  135. package/dist/core/runtime/spawn.js +123 -0
  136. package/dist/core/runtime/stop-guard.d.ts +18 -0
  137. package/dist/core/runtime/stop-guard.js +33 -0
  138. package/dist/core/runtime/tmux.d.ts +107 -0
  139. package/dist/core/runtime/tmux.js +244 -0
  140. package/dist/core/spawn.d.ts +17 -197
  141. package/dist/core/spawn.js +16 -539
  142. package/dist/daemon/crtrd-cli.js +4 -0
  143. package/dist/daemon/crtrd.d.ts +20 -0
  144. package/dist/daemon/crtrd.js +200 -0
  145. package/dist/daemon/manage.d.ts +17 -0
  146. package/dist/daemon/manage.js +57 -0
  147. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +16 -0
  148. package/dist/pi-extensions/canvas-inbox-watcher.js +229 -0
  149. package/dist/pi-extensions/canvas-nav.d.ts +32 -0
  150. package/dist/pi-extensions/canvas-nav.js +536 -0
  151. package/dist/pi-extensions/canvas-stophook.d.ts +17 -0
  152. package/dist/pi-extensions/canvas-stophook.js +396 -0
  153. package/package.json +6 -5
  154. package/dist/commands/agent.d.ts +0 -6
  155. package/dist/commands/agent.js +0 -585
  156. package/dist/commands/debug.d.ts +0 -3
  157. package/dist/commands/debug.js +0 -192
  158. package/dist/commands/job.d.ts +0 -11
  159. package/dist/commands/job.js +0 -384
  160. package/dist/commands/mode.js +0 -231
  161. package/dist/commands/plan.d.ts +0 -4
  162. package/dist/commands/plan.js +0 -322
  163. package/dist/commands/spec.d.ts +0 -3
  164. package/dist/commands/spec.js +0 -299
  165. package/dist/core/__tests__/flow-leaves.test.js +0 -248
  166. package/dist/core/__tests__/job.test.js +0 -310
  167. package/dist/core/__tests__/jobs.test.js +0 -98
  168. package/dist/core/__tests__/spawn.test.js +0 -138
  169. package/dist/core/__tests__/subagents.test.d.ts +0 -1
  170. package/dist/core/__tests__/subagents.test.js +0 -75
  171. package/dist/core/jobs.d.ts +0 -107
  172. package/dist/core/jobs.js +0 -565
  173. package/dist/core/subagents.d.ts +0 -18
  174. package/dist/core/subagents.js +0 -163
  175. package/dist/prompts/agent.d.ts +0 -27
  176. package/dist/prompts/agent.js +0 -184
  177. package/dist/prompts/debug.d.ts +0 -8
  178. package/dist/prompts/debug.js +0 -44
  179. /package/dist/core/__tests__/{flow-leaves.test.d.ts → canvas-inbox-watcher.test.d.ts} +0 -0
  180. /package/dist/core/__tests__/{job.test.d.ts → canvas.test.d.ts} +0 -0
  181. /package/dist/core/__tests__/{jobs.test.d.ts → reset.test.d.ts} +0 -0
  182. /package/dist/{core/__tests__/spawn.test.d.ts → daemon/crtrd-cli.d.ts} +0 -0
@@ -0,0 +1,198 @@
1
+ // presence.ts — focus pointer + per-node liveness helpers.
2
+ //
3
+ // The focus pointer (`<crtrHome>/focus.ptr`) is a plain-text file holding the
4
+ // node id that currently "has focus" — meaning the user's terminal is showing
5
+ // that node's tmux window. It is written on every explicit `focusNode()` call
6
+ // and read by the dashboard / status-line to highlight the active node.
7
+ //
8
+ // This is intentionally a simple file-based pointer rather than a database
9
+ // column: focus is transient UI state, not durable business data. A crash
10
+ // leaves a stale pointer that the next focusNode() clobbers — harmless.
11
+ //
12
+ // focusNode() does two things:
13
+ // 1. Ensures the user's terminal lands on the right tmux window by calling
14
+ // switchClient (cross-session) then selectWindow (in-session). Both are
15
+ // best-effort; we set the pointer regardless so the dashboard stays in sync.
16
+ // 2. Persists the node id to focus.ptr so any process can quickly read "what
17
+ // is the user looking at?".
18
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
+ import { dirname } from 'node:path';
20
+ import { join } from 'node:path';
21
+ import { crtrHome, getNode, updateNode } from '../canvas/index.js';
22
+ import { selectWindow, switchClient, windowAlive, currentTmux, paneOfWindow, swapPaneInPlace, windowOfPane, openShellWindow, closeWindow, ensureSession, nodeSession } from './tmux.js';
23
+ // ---------------------------------------------------------------------------
24
+ // Focus pointer
25
+ // ---------------------------------------------------------------------------
26
+ /** Absolute path to the focus pointer file. */
27
+ function focusPtrPath() {
28
+ return join(crtrHome(), 'focus.ptr');
29
+ }
30
+ /** Persist `nodeId` as the currently focused node. Best-effort; never throws. */
31
+ export function setFocus(nodeId) {
32
+ try {
33
+ const p = focusPtrPath();
34
+ mkdirSync(dirname(p), { recursive: true });
35
+ writeFileSync(p, nodeId, 'utf8');
36
+ }
37
+ catch {
38
+ /* focus pointer is best-effort; never surface */
39
+ }
40
+ }
41
+ /** Read the currently focused node id, or null if the pointer is absent or
42
+ * empty (no active focus). Best-effort; never throws. */
43
+ export function getFocus() {
44
+ try {
45
+ const raw = readFileSync(focusPtrPath(), 'utf8').trim();
46
+ return raw !== '' ? raw : null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ // ---------------------------------------------------------------------------
53
+ // Liveness
54
+ // ---------------------------------------------------------------------------
55
+ /** True when the node's tmux window is alive. A falsy tmux_session/window
56
+ * always returns false so callers don't need to null-guard. */
57
+ export function nodeLive(meta) {
58
+ return windowAlive(meta.tmux_session, meta.window);
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // Focus
62
+ // ---------------------------------------------------------------------------
63
+ /** Bring a node's tmux window to the foreground and record it as focused.
64
+ *
65
+ * Strategy:
66
+ * - If the node has no live window (`nodeLive` is false), still write the
67
+ * focus pointer — the caller (e.g. revive logic) uses `focused:false` to
68
+ * know it needs to open a window first.
69
+ * - Otherwise call `switchClient` (lands us in the right session) then
70
+ * `selectWindow` (picks the right window within it). Both calls are
71
+ * best-effort; the focus pointer is always written regardless.
72
+ *
73
+ * Returns:
74
+ * focused — whether the tmux focus actually succeeded.
75
+ * session — the tmux session name if one was attempted, null otherwise. */
76
+ export function focusNode(nodeId) {
77
+ const meta = getNode(nodeId);
78
+ // Always write the pointer so the dashboard reflects intent even when focus
79
+ // fails (e.g. we're not currently inside tmux).
80
+ setFocus(nodeId);
81
+ if (meta === null || !nodeLive(meta)) {
82
+ // Node not found or window is gone — caller may need to revive.
83
+ return { focused: false, session: meta?.tmux_session ?? null };
84
+ }
85
+ // Both fields are non-null thanks to nodeLive() returning true.
86
+ const session = meta.tmux_session;
87
+ const window = meta.window;
88
+ // Cross-session hop first, then window selection within the session.
89
+ // switchClient may be a no-op when already in the same session but is
90
+ // always safe to call — tmux handles it gracefully.
91
+ const clientOk = switchClient(session);
92
+ const windowOk = selectWindow(session, window);
93
+ return { focused: clientOk && windowOk, session };
94
+ }
95
+ /** Focus a node IN PLACE: bring its pane into the caller's current pane slot
96
+ * (swap-pane) instead of navigating the client to the node's own window. This
97
+ * is the default for `crtr node focus` and the nav-chrome spine jump — the
98
+ * agent appears where you are.
99
+ *
100
+ * Falls back to window focus when there is no caller pane (not inside tmux) or
101
+ * the target pane can't be resolved. `inPlace` reports which path ran. */
102
+ export function focusNodeInPlace(nodeId, callerPane) {
103
+ const meta = getNode(nodeId);
104
+ // Always write the pointer so the dashboard reflects intent even on failure.
105
+ setFocus(nodeId);
106
+ if (meta === null || !nodeLive(meta)) {
107
+ return { focused: false, session: meta?.tmux_session ?? null, inPlace: false };
108
+ }
109
+ const session = meta.tmux_session;
110
+ const window = meta.window;
111
+ const pane = callerPane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
112
+ // No caller pane (not in tmux) — best we can do is bring the window forefront.
113
+ if (pane === undefined || pane === '') {
114
+ const ok = switchClient(session) && selectWindow(session, window);
115
+ return { focused: ok, session, inPlace: false };
116
+ }
117
+ const targetPane = paneOfWindow(session, window);
118
+ if (targetPane === null) {
119
+ const ok = switchClient(session) && selectWindow(session, window);
120
+ return { focused: ok, session, inPlace: false };
121
+ }
122
+ if (targetPane === pane)
123
+ return { focused: true, session, inPlace: true }; // already here
124
+ // The window the caller's pane currently sits in — the slot the target's pane
125
+ // is about to be swapped INTO.
126
+ const callerWindow = windowOfPane(pane);
127
+ const ok = swapPaneInPlace(targetPane, pane);
128
+ // Keep the canvas window mapping in sync with the physical swap. swap-pane
129
+ // exchanges the two PANES between their windows (pane ids are stable, windows
130
+ // are slots): after the swap the target's pane occupies the caller's window
131
+ // and the caller's pane occupies the target's old window. Without this update
132
+ // meta.window goes stale, and a later paneOfWindow(session, meta.window)
133
+ // resolves the WRONG pane — the bug that made focusing back to a manager a
134
+ // no-op (it kept resolving the pane already in view) and made a focused node's
135
+ // exit collapse the visible window instead of its background one.
136
+ if (ok && callerWindow !== null && callerWindow !== window) {
137
+ try {
138
+ updateNode(nodeId, { window: callerWindow });
139
+ }
140
+ catch { /* best-effort */ }
141
+ // The caller is the node running this focus (its pi process owns callerPane).
142
+ // Its pane moved to the target's old window, so re-point its window there.
143
+ const callerNodeId = process.env['CRTR_NODE_ID'];
144
+ if (callerNodeId !== undefined && callerNodeId.trim() !== '' && callerNodeId !== nodeId) {
145
+ try {
146
+ updateNode(callerNodeId, { window });
147
+ }
148
+ catch { /* best-effort */ }
149
+ }
150
+ }
151
+ return { focused: ok, session, inPlace: true };
152
+ }
153
+ // ---------------------------------------------------------------------------
154
+ // Demote — detach the agent in the caller's pane to the background
155
+ // ---------------------------------------------------------------------------
156
+ /** Send a node's running pi OUT of the caller's pane and into a window in the
157
+ * shared global session, leaving a fresh shell where it was — the pane
158
+ * "becomes a terminal" and the agent keeps running, detached, in the
159
+ * background. The inverse of `focusNodeInPlace`; reversible via `node focus`.
160
+ *
161
+ * Mechanism: open a shell window in the global session, then swap that shell
162
+ * pane INTO the caller's pane — tmux exchanges the two panes, so the node's pi
163
+ * pane lands in the shell's window (global session) and the shell lands in the
164
+ * caller's pane. The node's meta is re-pointed to the new window so the daemon
165
+ * keeps supervising it.
166
+ *
167
+ * Best-effort; `demoted:false` when not in tmux or any tmux step fails. */
168
+ export function demoteNode(nodeId, callerPane) {
169
+ const meta = getNode(nodeId);
170
+ if (meta === null)
171
+ return { demoted: false, session: null, window: null };
172
+ const pane = callerPane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
173
+ if (pane === undefined || pane === '') {
174
+ return { demoted: false, session: meta.tmux_session ?? null, window: meta.window ?? null };
175
+ }
176
+ const session = nodeSession();
177
+ ensureSession(session, meta.cwd);
178
+ const shell = openShellWindow({ session, name: meta.name, cwd: meta.cwd });
179
+ if (shell === null)
180
+ return { demoted: false, session, window: meta.window ?? null };
181
+ // Swap the fresh shell into the caller's pane; the node's pi pane is exchanged
182
+ // out into the shell's window (now living in the global session).
183
+ const ok = swapPaneInPlace(shell.pane, pane);
184
+ if (!ok) {
185
+ closeWindow(shell.window);
186
+ return { demoted: false, session, window: meta.window ?? null };
187
+ }
188
+ // The node's pi now occupies the shell window; re-point its meta there so
189
+ // liveness checks resolve the right window.
190
+ try {
191
+ updateNode(nodeId, { tmux_session: session, window: shell.window });
192
+ }
193
+ catch { /* best-effort */ }
194
+ // The caller pane reverted to a terminal — if this node held focus, clear it.
195
+ if (getFocus() === nodeId)
196
+ setFocus('');
197
+ return { demoted: true, session, window: shell.window };
198
+ }
@@ -0,0 +1,30 @@
1
+ import { type NodeMeta } from '../canvas/index.js';
2
+ export interface PromoteResult {
3
+ meta: NodeMeta;
4
+ /** Orchestration guidance to surface into the node's current context now. */
5
+ guidance: string;
6
+ roadmapWritten: boolean;
7
+ }
8
+ /** Promote a node to resident orchestrator, optionally specializing its kind
9
+ * (e.g. a `general` worker becoming a `developer.orchestrator`). Idempotent:
10
+ * re-promoting just rewrites the spec + returns fresh guidance. Seeds a
11
+ * roadmap SCAFFOLD if absent (a boss with no map is a failure mode) — no goal
12
+ * is forced here; authoring the goal + roadmap is the node's next act. */
13
+ export declare function promote(nodeId: string, opts?: {
14
+ kind?: string;
15
+ }): PromoteResult;
16
+ export interface YieldResult {
17
+ meta: NodeMeta;
18
+ promoted: boolean;
19
+ /** Always true on success — the node will refresh-revive on its next stop. */
20
+ willRefresh: boolean;
21
+ }
22
+ /** Request a refresh-yield: discard in-memory context and revive fresh against
23
+ * the roadmap. A *terminal* node that yields is choosing to persist — it
24
+ * promotes first (refresh-with-open-work is the canonical promotion trigger),
25
+ * so it comes back as an orchestrator, optionally specializing its kind. Sets
26
+ * intent='refresh'; the stophook shuts the process down on the next stop and
27
+ * the daemon revives it fresh. */
28
+ export declare function requestYield(nodeId: string, opts?: {
29
+ kind?: string;
30
+ }): YieldResult;
@@ -0,0 +1,105 @@
1
+ // Promotion — terminal → resident, and the worker→orchestrator polymorph.
2
+ //
3
+ // Two stages (the pi-mode-switch pattern):
4
+ // 1. Promotion → guidance dump (mid-turn, ephemeral). This call flips the
5
+ // node's mode/lifecycle and (optionally) its KIND, REWRITES its launch
6
+ // spec to that kind's orchestrator persona (so the next revive comes back
7
+ // as that orchestrator), seeds a roadmap scaffold, and RETURNS kind-
8
+ // specific orchestration + roadmap-shaping guidance — which enters the
9
+ // current context so the node can author its roadmap before any refresh.
10
+ // 2. Refresh → persona swap (permanent). On the next fresh revive the node
11
+ // starts with the orchestrator system prompt baked in (because the launch
12
+ // spec now says orchestrator). The guidance dump bridges until then.
13
+ //
14
+ // Trigger is persistence-need (deliberate, or a refresh-yield with open work),
15
+ // never the mere act of spawning a child.
16
+ import { getNode, updateNode, hasActiveLiveSubscription } from '../canvas/index.js';
17
+ import { buildLaunchSpec } from './launch.js';
18
+ import { loadKernel, loadPersona } from '../personas/index.js';
19
+ import { resolveSkill } from '../resolver.js';
20
+ import { readText } from '../fs-utils.js';
21
+ import { parseFrontmatter } from '../frontmatter.js';
22
+ import { hasRoadmap, seedRoadmap, readRoadmap } from './roadmap.js';
23
+ /** Load a skill's body text by name, or null if it can't be resolved. Used to
24
+ * inline a kind's roadmap-shaping skill into the promotion guidance dump. */
25
+ function loadSkillBody(name) {
26
+ try {
27
+ const skill = resolveSkill(name, {});
28
+ return parseFrontmatter(readText(skill.path)).body.trim();
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ /** Build the mid-turn guidance dump, specialized to the node's (possibly
35
+ * just-chosen) kind: the shared kernel + that kind's roadmap-shaping skill
36
+ * (auto-loaded now, before the persona swap bakes in on revive) + the roadmap
37
+ * scaffold the node must author. No goal is assumed — writing it is step one. */
38
+ function orchestrationGuidance(nodeId, kind) {
39
+ const kernel = loadKernel();
40
+ const orch = loadPersona(kind, 'orchestrator');
41
+ const roadmapSkill = typeof orch?.frontmatter?.['roadmapSkill'] === 'string'
42
+ ? orch.frontmatter['roadmapSkill']
43
+ : undefined;
44
+ const skillBody = roadmapSkill ? loadSkillBody(roadmapSkill) : null;
45
+ const roadmap = readRoadmap(nodeId) ?? '(no roadmap yet)';
46
+ const parts = [
47
+ `You are now a RESIDENT ${kind.toUpperCase()} ORCHESTRATOR. Your scarce resource is your own context window.`,
48
+ 'Your job is to manage context and delegate — not to do the goal yourself.',
49
+ '',
50
+ kernel,
51
+ ];
52
+ if (skillBody) {
53
+ parts.push('', `--- How to shape a ${kind} roadmap (skill: ${roadmapSkill}) ---`, '', skillBody);
54
+ }
55
+ parts.push('', 'Your roadmap scaffold (`context/roadmap.md`) — author it now: state the goal, exit criteria, scope assumptions, and the phase skeleton, using the approach above:', '', roadmap, '', 'Then delegate each phase with `crtr node new --kind <kind>`. When your context fills, run `crtr node yield` to refresh against this roadmap.');
56
+ return parts.join('\n');
57
+ }
58
+ /** Promote a node to resident orchestrator, optionally specializing its kind
59
+ * (e.g. a `general` worker becoming a `developer.orchestrator`). Idempotent:
60
+ * re-promoting just rewrites the spec + returns fresh guidance. Seeds a
61
+ * roadmap SCAFFOLD if absent (a boss with no map is a failure mode) — no goal
62
+ * is forced here; authoring the goal + roadmap is the node's next act. */
63
+ export function promote(nodeId, opts = {}) {
64
+ const node = getNode(nodeId);
65
+ if (node === null)
66
+ throw new Error(`unknown node: ${nodeId}`);
67
+ // The node may specialize as it promotes; default to its current kind.
68
+ const targetKind = opts.kind ?? node.kind;
69
+ // Rewrite the launch spec to the target kind's orchestrator persona so the
70
+ // *next* revive comes back orchestrating in that kind (polymorph stage 2).
71
+ // nodeEnv reads meta.{kind,mode}, so CRTR_KIND/CRTR_MODE flip immediately for
72
+ // the live process's children too.
73
+ const { launch } = buildLaunchSpec(targetKind, 'orchestrator');
74
+ // Seed a roadmap scaffold if absent so the file exists for a refresh. The
75
+ // node fills in the goal/body next, guided by the kind skill dumped below.
76
+ let roadmapWritten = false;
77
+ if (!hasRoadmap(nodeId)) {
78
+ seedRoadmap(nodeId);
79
+ roadmapWritten = true;
80
+ }
81
+ const meta = updateNode(nodeId, { kind: targetKind, lifecycle: 'resident', mode: 'orchestrator', launch });
82
+ return { meta, guidance: orchestrationGuidance(nodeId, targetKind), roadmapWritten };
83
+ }
84
+ /** Request a refresh-yield: discard in-memory context and revive fresh against
85
+ * the roadmap. A *terminal* node that yields is choosing to persist — it
86
+ * promotes first (refresh-with-open-work is the canonical promotion trigger),
87
+ * so it comes back as an orchestrator, optionally specializing its kind. Sets
88
+ * intent='refresh'; the stophook shuts the process down on the next stop and
89
+ * the daemon revives it fresh. */
90
+ export function requestYield(nodeId, opts = {}) {
91
+ const node = getNode(nodeId);
92
+ if (node === null)
93
+ throw new Error(`unknown node: ${nodeId}`);
94
+ let promoted = false;
95
+ if (node.lifecycle === 'terminal') {
96
+ // Yielding with open work ⇒ must survive a context reset ⇒ promote
97
+ // (optionally specializing the kind).
98
+ promote(nodeId, opts.kind !== undefined ? { kind: opts.kind } : {});
99
+ promoted = true;
100
+ }
101
+ // Mark the intent; the stophook enacts the shutdown, the daemon the revive.
102
+ const meta = updateNode(nodeId, { intent: 'refresh' });
103
+ void hasActiveLiveSubscription; // (open-work signal, reserved for future gating)
104
+ return { meta, promoted, willRefresh: true };
105
+ }
@@ -0,0 +1,13 @@
1
+ export interface ResetRootResult {
2
+ /** Descendant node ids torn down (window killed + marked dead). */
3
+ reaped: string[];
4
+ /** Direct subscriptions dropped off the root. */
5
+ detached: string[];
6
+ /** True when the node was a root and a full reset ran. */
7
+ reset: boolean;
8
+ }
9
+ /** Reset a root node to a pristine, empty graph (the `/new` semantics).
10
+ *
11
+ * For a non-root (spawned child), a `/new` is not a graph reset — we only
12
+ * refresh its session id so a later `--resume` wakes the right conversation. */
13
+ export declare function resetRoot(nodeId: string, newSessionId?: string): ResetRootResult;
@@ -0,0 +1,97 @@
1
+ // Root reset — the `/new` equivalent.
2
+ //
3
+ // A live pi process is bound to one node via CRTR_NODE_ID (set at launch, not
4
+ // rebindable mid-process). When the user runs `/new`, the conversation is reset
5
+ // but the process — and thus the node id — stays the same. To make `/new`
6
+ // behave like re-running `crtr` (a brand-new graph on the canvas) we reset the
7
+ // root in place: reap its entire descendant sub-DAG, detach its subscriptions,
8
+ // and wipe its working state, then re-point it at a fresh base persona and the
9
+ // new pi session id. The node keeps its id; from the dashboard/nav it is a
10
+ // pristine root with an empty graph.
11
+ //
12
+ // Best-effort throughout: a tmux/fs failure on one node never aborts the reset.
13
+ import { existsSync, rmSync } from 'node:fs';
14
+ import { getNode, updateNode, setStatus, subscriptionsOf, unsubscribe, view, reportsDir, inboxPath, } from '../canvas/index.js';
15
+ import { closeWindow, windowAlive } from './tmux.js';
16
+ import { buildLaunchSpec } from './launch.js';
17
+ import { roadmapPath } from './roadmap.js';
18
+ /** Reset a root node to a pristine, empty graph (the `/new` semantics).
19
+ *
20
+ * For a non-root (spawned child), a `/new` is not a graph reset — we only
21
+ * refresh its session id so a later `--resume` wakes the right conversation. */
22
+ export function resetRoot(nodeId, newSessionId) {
23
+ const meta = getNode(nodeId);
24
+ if (meta === null)
25
+ return { reaped: [], detached: [], reset: false };
26
+ // Only roots own a graph in the "ran crtr again" sense.
27
+ if (meta.parent != null) {
28
+ if (newSessionId !== undefined) {
29
+ try {
30
+ updateNode(nodeId, { pi_session_id: newSessionId });
31
+ }
32
+ catch { /* */ }
33
+ }
34
+ return { reaped: [], detached: [], reset: false };
35
+ }
36
+ // 1) Reap the descendant sub-DAG. Mark dead + clear intent FIRST, then kill
37
+ // the window: the daemon revives on a window-gone + intent==='refresh'
38
+ // (or 'idle-release'), so flipping to dead before the window dies closes
39
+ // the race where a descendant mid-yield gets revived as we tear it down.
40
+ const reaped = [];
41
+ for (const id of view(nodeId)) {
42
+ try {
43
+ const dmeta = getNode(id);
44
+ setStatus(id, 'dead');
45
+ updateNode(id, { intent: null });
46
+ if (dmeta !== null && windowAlive(dmeta.tmux_session, dmeta.window)) {
47
+ closeWindow(dmeta.window);
48
+ }
49
+ reaped.push(id);
50
+ }
51
+ catch {
52
+ /* one bad node never aborts the reset */
53
+ }
54
+ }
55
+ // 2) Detach the root's own subscriptions so its view is empty.
56
+ const detached = [];
57
+ for (const sub of subscriptionsOf(nodeId)) {
58
+ try {
59
+ unsubscribe(nodeId, sub.node_id);
60
+ detached.push(sub.node_id);
61
+ }
62
+ catch {
63
+ /* */
64
+ }
65
+ }
66
+ // 3) Wipe the root's working state (reports / inbox / roadmap).
67
+ for (const p of [
68
+ reportsDir(nodeId),
69
+ inboxPath(nodeId),
70
+ `${inboxPath(nodeId)}.cursor`,
71
+ roadmapPath(nodeId),
72
+ ]) {
73
+ try {
74
+ if (existsSync(p))
75
+ rmSync(p, { recursive: true, force: true });
76
+ }
77
+ catch {
78
+ /* */
79
+ }
80
+ }
81
+ // 4) Re-point the root at a fresh base persona + the new pi session id.
82
+ try {
83
+ const { launch } = buildLaunchSpec(meta.kind, 'base');
84
+ updateNode(nodeId, {
85
+ mode: 'base',
86
+ lifecycle: 'resident',
87
+ intent: null,
88
+ status: 'active',
89
+ launch,
90
+ ...(newSessionId !== undefined ? { pi_session_id: newSessionId } : {}),
91
+ });
92
+ }
93
+ catch {
94
+ /* */
95
+ }
96
+ return { reaped, detached, reset: true };
97
+ }
@@ -0,0 +1,26 @@
1
+ export interface ReviveResult {
2
+ /** The new tmux window id, or null if openNodeWindow failed. */
3
+ window: string | null;
4
+ /** The tmux session the node was placed in. */
5
+ session: string;
6
+ /** True when pi was instructed to resume its saved conversation (`--resume`). */
7
+ resumed: boolean;
8
+ }
9
+ /** Open a fresh background tmux window for `nodeId` and update canvas meta.
10
+ *
11
+ * Throws if the node does not exist. All other failures (e.g. tmux not
12
+ * available) propagate as-is — callers (daemon, command) decide how to handle.
13
+ */
14
+ export declare function reviveNode(nodeId: string, opts: {
15
+ resume: boolean;
16
+ }): ReviveResult;
17
+ /** Re-exec a node's pi FRESH in its EXISTING tmux pane (the refresh-yield
18
+ * path). Unlike `reviveNode`, this opens no new window: the pane's current pi
19
+ * is replaced in place via `respawn-pane -k`, so a foreground/interactive
20
+ * session keeps its terminal and a background node keeps its window. Always
21
+ * fresh (no resume) — the node re-reads its roadmap/context dir.
22
+ *
23
+ * `pane` is the target pane id (the yielding node reads it from $TMUX_PANE).
24
+ * Throws on unknown node or when the respawn could not be dispatched, so the
25
+ * caller can fall back to a plain shutdown (daemon revives in a new window). */
26
+ export declare function reviveInPlace(nodeId: string, pane: string): ReviveResult;
@@ -0,0 +1,87 @@
1
+ // The revive primitive — restores a node to active status under a fresh tmux
2
+ // window. Used by both the supervisor daemon (on crash/refresh detection) and
3
+ // the explicit `crtr canvas revive` command.
4
+ //
5
+ // A revive always opens a NEW window: the old one is gone (crashed, or the
6
+ // node exited with intent=refresh). The node's persisted LaunchSpec and cwd
7
+ // are the canonical recipe; reviveNode replays them faithfully.
8
+ //
9
+ // resume=true → `pi --resume <pi_session_id>` — wakes the saved conversation.
10
+ // resume=false → fresh pi invocation — the node re-reads its roadmap/context dir.
11
+ import { getNode, updateNode, } from '../canvas/index.js';
12
+ import { buildPiArgv } from './launch.js';
13
+ import { buildReviveKickoff } from './kickoff.js';
14
+ import { ensureSession, openNodeWindow, piCommand, respawnPane, nodeSession, } from './tmux.js';
15
+ // ---------------------------------------------------------------------------
16
+ // reviveNode
17
+ // ---------------------------------------------------------------------------
18
+ /** Open a fresh background tmux window for `nodeId` and update canvas meta.
19
+ *
20
+ * Throws if the node does not exist. All other failures (e.g. tmux not
21
+ * available) propagate as-is — callers (daemon, command) decide how to handle.
22
+ */
23
+ export function reviveNode(nodeId, opts) {
24
+ const meta = getNode(nodeId);
25
+ if (meta === null) {
26
+ throw new Error(`reviveNode: unknown node ${nodeId}`);
27
+ }
28
+ // The node lives in the shared global session. Prefer its stored session
29
+ // (an inline root tracks its own real terminal session); fall back to the
30
+ // shared node session.
31
+ const session = meta.tmux_session ?? nodeSession();
32
+ ensureSession(session, meta.cwd);
33
+ // Decide whether to wake the saved pi conversation or start fresh.
34
+ const resumeId = opts.resume && meta.pi_session_id != null
35
+ ? meta.pi_session_id
36
+ : undefined;
37
+ // A fresh revive (no resume) gets a kickoff prompt so it re-reads its roadmap
38
+ // and continues; resuming a saved conversation needs none.
39
+ const inv = resumeId !== undefined
40
+ ? buildPiArgv(meta, { resumeSessionId: resumeId })
41
+ : buildPiArgv(meta, { prompt: buildReviveKickoff(meta) });
42
+ const env = { ...inv.env, CRTR_ROOT_SESSION: session };
43
+ const window = openNodeWindow({
44
+ session,
45
+ name: meta.name,
46
+ cwd: meta.cwd,
47
+ env,
48
+ command: piCommand(inv.argv),
49
+ });
50
+ updateNode(nodeId, {
51
+ status: 'active',
52
+ intent: null,
53
+ window,
54
+ tmux_session: session,
55
+ });
56
+ return { window, session, resumed: resumeId !== undefined };
57
+ }
58
+ // ---------------------------------------------------------------------------
59
+ // reviveInPlace — refresh-yield without churning the window
60
+ // ---------------------------------------------------------------------------
61
+ /** Re-exec a node's pi FRESH in its EXISTING tmux pane (the refresh-yield
62
+ * path). Unlike `reviveNode`, this opens no new window: the pane's current pi
63
+ * is replaced in place via `respawn-pane -k`, so a foreground/interactive
64
+ * session keeps its terminal and a background node keeps its window. Always
65
+ * fresh (no resume) — the node re-reads its roadmap/context dir.
66
+ *
67
+ * `pane` is the target pane id (the yielding node reads it from $TMUX_PANE).
68
+ * Throws on unknown node or when the respawn could not be dispatched, so the
69
+ * caller can fall back to a plain shutdown (daemon revives in a new window). */
70
+ export function reviveInPlace(nodeId, pane) {
71
+ const meta = getNode(nodeId);
72
+ if (meta === null) {
73
+ throw new Error(`reviveInPlace: unknown node ${nodeId}`);
74
+ }
75
+ const session = meta.tmux_session ?? nodeSession();
76
+ // Fresh re-exec: same recipe as a no-resume reviveNode, with the kickoff so
77
+ // the node rebuilds its bearings from disk.
78
+ const inv = buildPiArgv(meta, { prompt: buildReviveKickoff(meta) });
79
+ const env = { ...inv.env, CRTR_ROOT_SESSION: session };
80
+ const ok = respawnPane({ pane, cwd: meta.cwd, env, command: piCommand(inv.argv) });
81
+ if (!ok) {
82
+ throw new Error(`reviveInPlace: respawn-pane dispatch failed for ${nodeId}`);
83
+ }
84
+ updateNode(nodeId, { status: 'active', intent: null, tmux_session: session });
85
+ // Window is unchanged (we re-execed in place); report the existing one.
86
+ return { window: meta.window ?? null, session, resumed: false };
87
+ }
@@ -0,0 +1,12 @@
1
+ export declare function roadmapPath(nodeId: string): string;
2
+ export declare function hasRoadmap(nodeId: string): boolean;
3
+ export declare function readRoadmap(nodeId: string): string | null;
4
+ /** Seed a fresh roadmap SCAFFOLD. No goal is required — promotion lays this
5
+ * down so the file exists for a refresh, and the owner authors the goal +
6
+ * body as its next act (guided by its kind's roadmap skill). `goal`/
7
+ * `exitCriteria` are optional overrides. Idempotent only if you intend it —
8
+ * call sites guard on hasRoadmap to avoid clobbering an evolved map. */
9
+ export declare function seedRoadmap(nodeId: string, opts?: {
10
+ goal?: string;
11
+ exitCriteria?: string;
12
+ }): string;
@@ -0,0 +1,52 @@
1
+ // The roadmap — one document, two temperatures. A small frozen core (goal +
2
+ // exit criteria) and an evolving body (scope, strategy, active context) the
3
+ // owner keeps current. It holds how you intend to reach the goal and where you
4
+ // are right now — not a journal of what you did or a queue of what's next. It
5
+ // is what lets a resident node survive a refresh-yield: revive with no memory,
6
+ // re-read the map, continue.
7
+ //
8
+ // Written at resident-promotion (a born-resident root, or a spawned node's
9
+ // first refresh-with-open-work). Leaf/terminal workers write nothing.
10
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { contextDir } from '../canvas/index.js';
13
+ export function roadmapPath(nodeId) {
14
+ return join(contextDir(nodeId), 'roadmap.md');
15
+ }
16
+ export function hasRoadmap(nodeId) {
17
+ return existsSync(roadmapPath(nodeId));
18
+ }
19
+ export function readRoadmap(nodeId) {
20
+ const p = roadmapPath(nodeId);
21
+ return existsSync(p) ? readFileSync(p, 'utf8') : null;
22
+ }
23
+ /** Seed a fresh roadmap SCAFFOLD. No goal is required — promotion lays this
24
+ * down so the file exists for a refresh, and the owner authors the goal +
25
+ * body as its next act (guided by its kind's roadmap skill). `goal`/
26
+ * `exitCriteria` are optional overrides. Idempotent only if you intend it —
27
+ * call sites guard on hasRoadmap to avoid clobbering an evolved map. */
28
+ export function seedRoadmap(nodeId, opts = {}) {
29
+ const dir = contextDir(nodeId);
30
+ mkdirSync(dir, { recursive: true });
31
+ const body = `# Roadmap
32
+
33
+ <!-- frozen core: set once, rarely changes -->
34
+ ## Goal
35
+ ${opts.goal?.trim() ?? '- (state the high-level goal you now own — write this as your first act)'}
36
+
37
+ ## Exit criteria
38
+ ${opts.exitCriteria?.trim() ?? '- (define what "done" looks like)'}
39
+
40
+ <!-- evolving body: keep this current as you learn scope + intent -->
41
+ ## Scope assumptions / non-goals
42
+ - (record what's out of scope and what's settled — e.g. "reuse existing auth", "security isn't a concern here" — so children inherit the framing)
43
+
44
+ ## Strategy / phases
45
+ - (your high-level shape of how you reach the goal; the ordered phases from here to done, the current one carrying a one-line status. Each phase can become a child whose own roadmap is that phase)
46
+
47
+ ## Active context
48
+ - (the context/ files currently relevant to the work, by path; none yet)
49
+ `;
50
+ writeFileSync(roadmapPath(nodeId), body);
51
+ return body;
52
+ }
@@ -0,0 +1,31 @@
1
+ import { type NodeMeta, type Mode } from '../canvas/index.js';
2
+ export interface BootRootOpts {
3
+ cwd: string;
4
+ kind?: string;
5
+ name?: string;
6
+ /** Optional starter prompt (bare `crtr` requires none). */
7
+ prompt?: string;
8
+ /** 'inline' — exec pi in the current terminal (bare `crtr`).
9
+ * 'session' — create a dedicated tmux session and run pi there (`session new`). */
10
+ placement: 'inline' | 'session';
11
+ }
12
+ /** Create a root node and bring up its pi. Returns the node; for 'inline' this
13
+ * only returns after pi exits (it took over the terminal). */
14
+ export declare function bootRoot(opts: BootRootOpts): NodeMeta;
15
+ export interface SpawnChildOpts {
16
+ kind: string;
17
+ mode?: Mode;
18
+ cwd: string;
19
+ name?: string;
20
+ prompt: string;
21
+ /** Override the parent (defaults to the calling node from env). */
22
+ parent?: string;
23
+ }
24
+ export interface SpawnChildResult {
25
+ node: NodeMeta;
26
+ window: string | null;
27
+ session: string;
28
+ }
29
+ /** Spawn a terminal worker as a background window under the root session.
30
+ * The parent auto-subscribes (active) to it via spawnNode. */
31
+ export declare function spawnChild(opts: SpawnChildOpts): SpawnChildResult;