@crouton-kit/crouter 0.3.8 → 0.3.12

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 (184) 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 +25 -27
  23. package/dist/commands/{job.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/{agent.d.ts → daemon.d.ts} +1 -1
  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 +15 -427
  38. package/dist/commands/node.d.ts +2 -0
  39. package/dist/commands/node.js +354 -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 +8 -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 +12 -681
  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 +9 -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/__tests__/resolver.test.js +69 -1
  84. package/dist/core/__tests__/unknown-path.test.d.ts +1 -0
  85. package/dist/core/__tests__/unknown-path.test.js +52 -0
  86. package/dist/core/bootstrap.d.ts +2 -0
  87. package/dist/core/bootstrap.js +66 -0
  88. package/dist/core/canvas/attention.d.ts +24 -0
  89. package/dist/core/canvas/attention.js +94 -0
  90. package/dist/core/canvas/canvas.d.ts +40 -0
  91. package/dist/core/canvas/canvas.js +210 -0
  92. package/dist/core/canvas/db.d.ts +7 -0
  93. package/dist/core/canvas/db.js +61 -0
  94. package/dist/core/canvas/index.d.ts +4 -0
  95. package/dist/core/canvas/index.js +6 -0
  96. package/dist/core/canvas/paths.d.ts +16 -0
  97. package/dist/core/canvas/paths.js +62 -0
  98. package/dist/core/canvas/render.d.ts +30 -0
  99. package/dist/core/canvas/render.js +186 -0
  100. package/dist/core/canvas/types.d.ts +87 -0
  101. package/dist/core/canvas/types.js +8 -0
  102. package/dist/core/command.d.ts +63 -2
  103. package/dist/core/command.js +97 -24
  104. package/dist/core/feed/feed.d.ts +43 -0
  105. package/dist/core/feed/feed.js +116 -0
  106. package/dist/core/feed/inbox.d.ts +50 -0
  107. package/dist/core/feed/inbox.js +124 -0
  108. package/dist/core/frontmatter.d.ts +10 -0
  109. package/dist/core/frontmatter.js +24 -9
  110. package/dist/core/help.d.ts +39 -8
  111. package/dist/core/help.js +69 -35
  112. package/dist/core/io.d.ts +15 -1
  113. package/dist/core/io.js +56 -6
  114. package/dist/core/personas/index.d.ts +12 -0
  115. package/dist/core/personas/index.js +10 -0
  116. package/dist/core/personas/loader.d.ts +44 -0
  117. package/dist/core/personas/loader.js +157 -0
  118. package/dist/core/personas/resolve.d.ts +36 -0
  119. package/dist/core/personas/resolve.js +110 -0
  120. package/dist/core/render.d.ts +11 -0
  121. package/dist/core/render.js +126 -0
  122. package/dist/core/resolver.d.ts +10 -0
  123. package/dist/core/resolver.js +160 -2
  124. package/dist/core/runtime/front-door.d.ts +10 -0
  125. package/dist/core/runtime/front-door.js +97 -0
  126. package/dist/core/runtime/kickoff.d.ts +23 -0
  127. package/dist/core/runtime/kickoff.js +134 -0
  128. package/dist/core/runtime/launch.d.ts +34 -0
  129. package/dist/core/runtime/launch.js +85 -0
  130. package/dist/core/runtime/nodes.d.ts +38 -0
  131. package/dist/core/runtime/nodes.js +95 -0
  132. package/dist/core/runtime/presence.d.ts +38 -0
  133. package/dist/core/runtime/presence.js +152 -0
  134. package/dist/core/runtime/promote.d.ts +30 -0
  135. package/dist/core/runtime/promote.js +105 -0
  136. package/dist/core/runtime/reset.d.ts +13 -0
  137. package/dist/core/runtime/reset.js +97 -0
  138. package/dist/core/runtime/revive.d.ts +26 -0
  139. package/dist/core/runtime/revive.js +89 -0
  140. package/dist/core/runtime/roadmap.d.ts +12 -0
  141. package/dist/core/runtime/roadmap.js +52 -0
  142. package/dist/core/runtime/spawn.d.ts +33 -0
  143. package/dist/core/runtime/spawn.js +118 -0
  144. package/dist/core/runtime/stop-guard.d.ts +18 -0
  145. package/dist/core/runtime/stop-guard.js +33 -0
  146. package/dist/core/runtime/tmux.d.ts +88 -0
  147. package/dist/core/runtime/tmux.js +198 -0
  148. package/dist/core/spawn.d.ts +17 -80
  149. package/dist/core/spawn.js +15 -219
  150. package/dist/daemon/crtrd-cli.d.ts +1 -0
  151. package/dist/daemon/crtrd-cli.js +4 -0
  152. package/dist/daemon/crtrd.d.ts +20 -0
  153. package/dist/daemon/crtrd.js +200 -0
  154. package/dist/daemon/manage.d.ts +17 -0
  155. package/dist/daemon/manage.js +57 -0
  156. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +16 -0
  157. package/dist/pi-extensions/canvas-inbox-watcher.js +229 -0
  158. package/dist/pi-extensions/canvas-nav.d.ts +32 -0
  159. package/dist/pi-extensions/canvas-nav.js +536 -0
  160. package/dist/pi-extensions/canvas-stophook.d.ts +17 -0
  161. package/dist/pi-extensions/canvas-stophook.js +373 -0
  162. package/dist/types.d.ts +21 -0
  163. package/dist/types.js +3 -0
  164. package/package.json +6 -5
  165. package/dist/commands/agent.js +0 -384
  166. package/dist/commands/debug.d.ts +0 -3
  167. package/dist/commands/debug.js +0 -179
  168. package/dist/commands/job.js +0 -344
  169. package/dist/commands/plan.d.ts +0 -4
  170. package/dist/commands/plan.js +0 -309
  171. package/dist/commands/spec.d.ts +0 -3
  172. package/dist/commands/spec.js +0 -286
  173. package/dist/core/__tests__/flow-leaves.test.js +0 -248
  174. package/dist/core/__tests__/job.test.js +0 -310
  175. package/dist/core/__tests__/jobs.test.js +0 -66
  176. package/dist/core/jobs.d.ts +0 -101
  177. package/dist/core/jobs.js +0 -462
  178. package/dist/prompts/agent.d.ts +0 -18
  179. package/dist/prompts/agent.js +0 -153
  180. package/dist/prompts/debug.d.ts +0 -8
  181. package/dist/prompts/debug.js +0 -44
  182. /package/dist/core/__tests__/{flow-leaves.test.d.ts → canvas-inbox-watcher.test.d.ts} +0 -0
  183. /package/dist/core/__tests__/{job.test.d.ts → canvas.test.d.ts} +0 -0
  184. /package/dist/core/__tests__/{jobs.test.d.ts → reset.test.d.ts} +0 -0
@@ -0,0 +1,95 @@
1
+ // Runtime node operations — the behavior layer above the canvas store.
2
+ //
3
+ // canvas/ is the pure data-access layer (nodes + edges). This is where the
4
+ // design's *rules* live: how a node comes into being, the env contract its pi
5
+ // process inherits, and the spawn-time wiring of the subscription spine.
6
+ //
7
+ // Two ways a node is born:
8
+ // • root — a user-opened entry point (bare `crtr`).
9
+ // No parent; resident by default (it's a conversation you live in).
10
+ // • child — spawned by another node. Terminal until it must persist. On
11
+ // spawn the PARENT auto-subscribes (active) to the child, so it
12
+ // learns when the work finishes — this seeds the subscription
13
+ // graph to mirror the spawn structure. A `spawned_by` audit edge
14
+ // is also recorded.
15
+ import { randomBytes } from 'node:crypto';
16
+ import { createNode, getNode, subscribe, recordSpawn, } from '../canvas/index.js';
17
+ /** Generate a node id in the same shape as job ids (time-sortable + random). */
18
+ export function newNodeId() {
19
+ return `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
20
+ }
21
+ /** Read the current node's identity from the environment. A spawned pi process
22
+ * runs with CRTR_NODE_ID set; its own `crtr` invocations spawn children under
23
+ * it by reading CRTR_NODE_ID as the parent. */
24
+ export function currentNodeContext() {
25
+ const env = process.env;
26
+ return {
27
+ nodeId: env['CRTR_NODE_ID'] ?? null,
28
+ parentNodeId: env['CRTR_NODE_ID'] ?? null, // a child's parent is the live node
29
+ kind: env['CRTR_KIND'] ?? null,
30
+ mode: env['CRTR_MODE'] ?? null,
31
+ };
32
+ }
33
+ /** The env injected into a node's pi process. Self-gating extensions read
34
+ * CRTR_KIND/CRTR_MODE to flip behavior on polymorph without a respawn; the
35
+ * feed/inbox machinery reads CRTR_NODE_ID. */
36
+ export function nodeEnv(meta) {
37
+ const env = {
38
+ CRTR_NODE_ID: meta.node_id,
39
+ CRTR_KIND: meta.kind,
40
+ CRTR_MODE: meta.mode,
41
+ CRTR_LIFECYCLE: meta.lifecycle,
42
+ CRTR_NODE_CWD: meta.cwd,
43
+ };
44
+ if (meta.parent)
45
+ env['CRTR_PARENT_NODE_ID'] = meta.parent;
46
+ // Propagate an explicit canvas home so children share the same canvas.
47
+ const home = process.env['CRTR_HOME'];
48
+ if (home !== undefined && home !== '')
49
+ env['CRTR_HOME'] = home;
50
+ // Propagate the root's tmux session so every descendant spawns its windows
51
+ // into the same root session.
52
+ const rootSession = process.env['CRTR_ROOT_SESSION'];
53
+ if (rootSession !== undefined && rootSession !== '')
54
+ env['CRTR_ROOT_SESSION'] = rootSession;
55
+ // Merge any launch-spec env last (it may override / extend).
56
+ return { ...env, ...(meta.launch?.env ?? {}) };
57
+ }
58
+ /** Create a node on the canvas and wire its spawn-time edges.
59
+ *
60
+ * For a child (parent given): the parent auto-subscribes ACTIVE to the child
61
+ * (so it's woken when the child finishes), and a spawned_by audit edge is
62
+ * recorded. For a root (no parent): no edges, resident by default. */
63
+ export function spawnNode(opts) {
64
+ const parent = opts.parent ?? null;
65
+ const isRoot = parent === null;
66
+ const meta = {
67
+ node_id: opts.nodeId ?? newNodeId(),
68
+ name: opts.name ?? opts.kind,
69
+ created: new Date().toISOString(),
70
+ cwd: opts.cwd,
71
+ 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'),
76
+ status: 'active',
77
+ parent,
78
+ passive_default: opts.passiveDefault ?? false,
79
+ intent: null,
80
+ pi_session_id: null,
81
+ launch: opts.launch,
82
+ };
83
+ createNode(meta);
84
+ if (parent !== null) {
85
+ if (getNode(parent) === null) {
86
+ throw new Error(`cannot spawn under unknown parent node: ${parent}`);
87
+ }
88
+ // The load-bearing seed: parent subscribes (active) to child so it learns
89
+ // when the work finishes. This mirrors spawn structure into the spine.
90
+ subscribe(parent, meta.node_id, true);
91
+ // Audit-only provenance.
92
+ recordSpawn(meta.node_id, parent);
93
+ }
94
+ return meta;
95
+ }
@@ -0,0 +1,38 @@
1
+ import type { NodeMeta } from '../canvas/index.js';
2
+ /** Persist `nodeId` as the currently focused node. Best-effort; never throws. */
3
+ export declare function setFocus(nodeId: string): void;
4
+ /** Read the currently focused node id, or null if the pointer is absent or
5
+ * empty (no active focus). Best-effort; never throws. */
6
+ export declare function getFocus(): string | null;
7
+ /** True when the node's tmux window is alive. A falsy tmux_session/window
8
+ * always returns false so callers don't need to null-guard. */
9
+ export declare function nodeLive(meta: NodeMeta): boolean;
10
+ /** Bring a node's tmux window to the foreground and record it as focused.
11
+ *
12
+ * Strategy:
13
+ * - If the node has no live window (`nodeLive` is false), still write the
14
+ * focus pointer — the caller (e.g. revive logic) uses `focused:false` to
15
+ * know it needs to open a window first.
16
+ * - Otherwise call `switchClient` (lands us in the right session) then
17
+ * `selectWindow` (picks the right window within it). Both calls are
18
+ * best-effort; the focus pointer is always written regardless.
19
+ *
20
+ * Returns:
21
+ * focused — whether the tmux focus actually succeeded.
22
+ * session — the tmux session name if one was attempted, null otherwise. */
23
+ export declare function focusNode(nodeId: string): {
24
+ focused: boolean;
25
+ session: string | null;
26
+ };
27
+ /** Focus a node IN PLACE: bring its pane into the caller's current pane slot
28
+ * (swap-pane) instead of navigating the client to the node's own window. This
29
+ * is the default for `crtr node focus` and the nav-chrome spine jump — the
30
+ * agent appears where you are.
31
+ *
32
+ * Falls back to window focus when there is no caller pane (not inside tmux) or
33
+ * the target pane can't be resolved. `inPlace` reports which path ran. */
34
+ export declare function focusNodeInPlace(nodeId: string, callerPane?: string): {
35
+ focused: boolean;
36
+ session: string | null;
37
+ inPlace: boolean;
38
+ };
@@ -0,0 +1,152 @@
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 } 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
+ }
@@ -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,89 @@
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, } from './tmux.js';
15
+ import { rootSessionName } from './spawn.js';
16
+ // ---------------------------------------------------------------------------
17
+ // reviveNode
18
+ // ---------------------------------------------------------------------------
19
+ /** Open a fresh background tmux window for `nodeId` and update canvas meta.
20
+ *
21
+ * Throws if the node does not exist. All other failures (e.g. tmux not
22
+ * available) propagate as-is — callers (daemon, command) decide how to handle.
23
+ */
24
+ export function reviveNode(nodeId, opts) {
25
+ const meta = getNode(nodeId);
26
+ if (meta === null) {
27
+ throw new Error(`reviveNode: unknown node ${nodeId}`);
28
+ }
29
+ // The node lives in its root's tmux session. Prefer the stored session name;
30
+ // fall back to deriving it from the parent (or the node itself for roots).
31
+ const session = meta.tmux_session ??
32
+ rootSessionName((meta.parent ?? meta.node_id));
33
+ ensureSession(session, meta.cwd);
34
+ // Decide whether to wake the saved pi conversation or start fresh.
35
+ const resumeId = opts.resume && meta.pi_session_id != null
36
+ ? meta.pi_session_id
37
+ : undefined;
38
+ // A fresh revive (no resume) gets a kickoff prompt so it re-reads its roadmap
39
+ // and continues; resuming a saved conversation needs none.
40
+ const inv = resumeId !== undefined
41
+ ? buildPiArgv(meta, { resumeSessionId: resumeId })
42
+ : buildPiArgv(meta, { prompt: buildReviveKickoff(meta) });
43
+ const env = { ...inv.env, CRTR_ROOT_SESSION: session };
44
+ const window = openNodeWindow({
45
+ session,
46
+ name: meta.name,
47
+ cwd: meta.cwd,
48
+ env,
49
+ command: piCommand(inv.argv),
50
+ });
51
+ updateNode(nodeId, {
52
+ status: 'active',
53
+ intent: null,
54
+ window,
55
+ tmux_session: session,
56
+ });
57
+ return { window, session, resumed: resumeId !== undefined };
58
+ }
59
+ // ---------------------------------------------------------------------------
60
+ // reviveInPlace — refresh-yield without churning the window
61
+ // ---------------------------------------------------------------------------
62
+ /** Re-exec a node's pi FRESH in its EXISTING tmux pane (the refresh-yield
63
+ * path). Unlike `reviveNode`, this opens no new window: the pane's current pi
64
+ * is replaced in place via `respawn-pane -k`, so a foreground/interactive
65
+ * session keeps its terminal and a background node keeps its window. Always
66
+ * fresh (no resume) — the node re-reads its roadmap/context dir.
67
+ *
68
+ * `pane` is the target pane id (the yielding node reads it from $TMUX_PANE).
69
+ * Throws on unknown node or when the respawn could not be dispatched, so the
70
+ * caller can fall back to a plain shutdown (daemon revives in a new window). */
71
+ export function reviveInPlace(nodeId, pane) {
72
+ const meta = getNode(nodeId);
73
+ if (meta === null) {
74
+ throw new Error(`reviveInPlace: unknown node ${nodeId}`);
75
+ }
76
+ const session = meta.tmux_session ??
77
+ rootSessionName((meta.parent ?? meta.node_id));
78
+ // Fresh re-exec: same recipe as a no-resume reviveNode, with the kickoff so
79
+ // the node rebuilds its bearings from disk.
80
+ const inv = buildPiArgv(meta, { prompt: buildReviveKickoff(meta) });
81
+ const env = { ...inv.env, CRTR_ROOT_SESSION: session };
82
+ const ok = respawnPane({ pane, cwd: meta.cwd, env, command: piCommand(inv.argv) });
83
+ if (!ok) {
84
+ throw new Error(`reviveInPlace: respawn-pane dispatch failed for ${nodeId}`);
85
+ }
86
+ updateNode(nodeId, { status: 'active', intent: null, tmux_session: session });
87
+ // Window is unchanged (we re-execed in place); report the existing one.
88
+ return { window: meta.window ?? null, session, resumed: false };
89
+ }
@@ -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;