@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.
- package/bin/crtrd +2 -0
- package/dist/builtin-personas/design/base.md +9 -0
- package/dist/builtin-personas/design/orchestrator.md +10 -0
- package/dist/builtin-personas/developer/base.md +9 -0
- package/dist/builtin-personas/developer/orchestrator.md +12 -0
- package/dist/builtin-personas/explore/base.md +9 -0
- package/dist/builtin-personas/explore/orchestrator.md +9 -0
- package/dist/builtin-personas/general/base.md +5 -0
- package/dist/builtin-personas/general/orchestrator.md +7 -0
- package/dist/builtin-personas/orchestration-kernel.md +71 -0
- package/dist/builtin-personas/plan/base.md +7 -0
- package/dist/builtin-personas/plan/orchestrator.md +12 -0
- package/dist/builtin-personas/review/base.md +7 -0
- package/dist/builtin-personas/review/orchestrator.md +9 -0
- package/dist/builtin-personas/runtime-base.md +39 -0
- package/dist/builtin-personas/spec/base.md +7 -0
- package/dist/builtin-personas/spec/orchestrator.md +10 -0
- package/dist/builtin-skills/skills/design/SKILL.md +51 -0
- package/dist/builtin-skills/skills/development/SKILL.md +109 -0
- package/dist/builtin-skills/skills/planning/SKILL.md +59 -0
- package/dist/builtin-skills/skills/spec/SKILL.md +83 -0
- package/dist/cli.js +14 -6
- package/dist/commands/{mode.d.ts → attention.d.ts} +1 -1
- package/dist/commands/attention.js +152 -0
- package/dist/commands/canvas.d.ts +2 -0
- package/dist/commands/canvas.js +35 -0
- package/dist/commands/daemon.d.ts +2 -0
- package/dist/commands/daemon.js +111 -0
- package/dist/commands/dashboard.d.ts +2 -0
- package/dist/commands/dashboard.js +65 -0
- package/dist/commands/human/prompts.d.ts +5 -0
- package/dist/commands/human/prompts.js +269 -0
- package/dist/commands/human/queue.d.ts +3 -0
- package/dist/commands/human/queue.js +133 -0
- package/dist/commands/human/shared.d.ts +43 -0
- package/dist/commands/human/shared.js +107 -0
- package/dist/commands/human.js +10 -454
- package/dist/commands/node.d.ts +2 -0
- package/dist/commands/node.js +407 -0
- package/dist/commands/pkg/market-inspect.d.ts +1 -0
- package/dist/commands/pkg/market-inspect.js +157 -0
- package/dist/commands/pkg/market-manage.d.ts +1 -0
- package/dist/commands/pkg/market-manage.js +316 -0
- package/dist/commands/pkg/market.d.ts +1 -0
- package/dist/commands/pkg/market.js +16 -0
- package/dist/commands/pkg/plugin-inspect.d.ts +1 -0
- package/dist/commands/pkg/plugin-inspect.js +142 -0
- package/dist/commands/pkg/plugin-manage.d.ts +1 -0
- package/dist/commands/pkg/plugin-manage.js +294 -0
- package/dist/commands/pkg/plugin.d.ts +1 -0
- package/dist/commands/pkg/plugin.js +16 -0
- package/dist/commands/pkg/shared.d.ts +5 -0
- package/dist/commands/pkg/shared.js +61 -0
- package/dist/commands/pkg.js +3 -1004
- package/dist/commands/push.d.ts +3 -0
- package/dist/commands/push.js +159 -0
- package/dist/commands/revive.d.ts +2 -0
- package/dist/commands/revive.js +64 -0
- package/dist/commands/skill/author.d.ts +3 -0
- package/dist/commands/skill/author.js +147 -0
- package/dist/commands/skill/find.d.ts +4 -0
- package/dist/commands/skill/find.js +254 -0
- package/dist/commands/skill/read.d.ts +1 -0
- package/dist/commands/skill/read.js +89 -0
- package/dist/commands/skill/shared.d.ts +19 -0
- package/dist/commands/skill/shared.js +207 -0
- package/dist/commands/skill/state.d.ts +3 -0
- package/dist/commands/skill/state.js +69 -0
- package/dist/commands/skill.js +6 -691
- package/dist/commands/sys/config.d.ts +1 -0
- package/dist/commands/sys/config.js +186 -0
- package/dist/commands/sys/doctor.d.ts +1 -0
- package/dist/commands/sys/doctor.js +369 -0
- package/dist/commands/sys/shared.d.ts +3 -0
- package/dist/commands/sys/shared.js +24 -0
- package/dist/commands/sys/update.d.ts +2 -0
- package/dist/commands/sys/update.js +114 -0
- package/dist/commands/sys.js +4 -694
- package/dist/core/__tests__/argv-parser.test.js +19 -1
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +100 -0
- package/dist/core/__tests__/canvas.test.js +154 -0
- package/dist/core/__tests__/reset.test.js +105 -0
- package/dist/core/canvas/attention.d.ts +24 -0
- package/dist/core/canvas/attention.js +94 -0
- package/dist/core/canvas/canvas.d.ts +40 -0
- package/dist/core/canvas/canvas.js +210 -0
- package/dist/core/canvas/db.d.ts +7 -0
- package/dist/core/canvas/db.js +61 -0
- package/dist/core/canvas/index.d.ts +4 -0
- package/dist/core/canvas/index.js +6 -0
- package/dist/core/canvas/paths.d.ts +16 -0
- package/dist/core/canvas/paths.js +62 -0
- package/dist/core/canvas/render.d.ts +30 -0
- package/dist/core/canvas/render.js +186 -0
- package/dist/core/canvas/types.d.ts +87 -0
- package/dist/core/canvas/types.js +8 -0
- package/dist/core/command.d.ts +5 -0
- package/dist/core/command.js +35 -10
- package/dist/core/feed/feed.d.ts +43 -0
- package/dist/core/feed/feed.js +116 -0
- package/dist/core/feed/inbox.d.ts +50 -0
- package/dist/core/feed/inbox.js +124 -0
- package/dist/core/help.js +5 -3
- package/dist/core/io.d.ts +15 -1
- package/dist/core/io.js +56 -6
- package/dist/core/personas/index.d.ts +12 -0
- package/dist/core/personas/index.js +10 -0
- package/dist/core/personas/loader.d.ts +44 -0
- package/dist/core/personas/loader.js +157 -0
- package/dist/core/personas/resolve.d.ts +36 -0
- package/dist/core/personas/resolve.js +110 -0
- package/dist/core/render.d.ts +11 -0
- package/dist/core/render.js +126 -0
- package/dist/core/resolver.d.ts +10 -0
- package/dist/core/resolver.js +109 -1
- package/dist/core/runtime/front-door.d.ts +10 -0
- package/dist/core/runtime/front-door.js +97 -0
- package/dist/core/runtime/kickoff.d.ts +23 -0
- package/dist/core/runtime/kickoff.js +134 -0
- package/dist/core/runtime/launch.d.ts +34 -0
- package/dist/core/runtime/launch.js +85 -0
- package/dist/core/runtime/nodes.d.ts +38 -0
- package/dist/core/runtime/nodes.js +95 -0
- package/dist/core/runtime/presence.d.ts +55 -0
- package/dist/core/runtime/presence.js +198 -0
- package/dist/core/runtime/promote.d.ts +30 -0
- package/dist/core/runtime/promote.js +105 -0
- package/dist/core/runtime/reset.d.ts +13 -0
- package/dist/core/runtime/reset.js +97 -0
- package/dist/core/runtime/revive.d.ts +26 -0
- package/dist/core/runtime/revive.js +87 -0
- package/dist/core/runtime/roadmap.d.ts +12 -0
- package/dist/core/runtime/roadmap.js +52 -0
- package/dist/core/runtime/spawn.d.ts +31 -0
- package/dist/core/runtime/spawn.js +123 -0
- package/dist/core/runtime/stop-guard.d.ts +18 -0
- package/dist/core/runtime/stop-guard.js +33 -0
- package/dist/core/runtime/tmux.d.ts +107 -0
- package/dist/core/runtime/tmux.js +244 -0
- package/dist/core/spawn.d.ts +17 -197
- package/dist/core/spawn.js +16 -539
- package/dist/daemon/crtrd-cli.js +4 -0
- package/dist/daemon/crtrd.d.ts +20 -0
- package/dist/daemon/crtrd.js +200 -0
- package/dist/daemon/manage.d.ts +17 -0
- package/dist/daemon/manage.js +57 -0
- package/dist/pi-extensions/canvas-inbox-watcher.d.ts +16 -0
- package/dist/pi-extensions/canvas-inbox-watcher.js +229 -0
- package/dist/pi-extensions/canvas-nav.d.ts +32 -0
- package/dist/pi-extensions/canvas-nav.js +536 -0
- package/dist/pi-extensions/canvas-stophook.d.ts +17 -0
- package/dist/pi-extensions/canvas-stophook.js +396 -0
- package/package.json +6 -5
- package/dist/commands/agent.d.ts +0 -6
- package/dist/commands/agent.js +0 -585
- package/dist/commands/debug.d.ts +0 -3
- package/dist/commands/debug.js +0 -192
- package/dist/commands/job.d.ts +0 -11
- package/dist/commands/job.js +0 -384
- package/dist/commands/mode.js +0 -231
- package/dist/commands/plan.d.ts +0 -4
- package/dist/commands/plan.js +0 -322
- package/dist/commands/spec.d.ts +0 -3
- package/dist/commands/spec.js +0 -299
- package/dist/core/__tests__/flow-leaves.test.js +0 -248
- package/dist/core/__tests__/job.test.js +0 -310
- package/dist/core/__tests__/jobs.test.js +0 -98
- package/dist/core/__tests__/spawn.test.js +0 -138
- package/dist/core/__tests__/subagents.test.d.ts +0 -1
- package/dist/core/__tests__/subagents.test.js +0 -75
- package/dist/core/jobs.d.ts +0 -107
- package/dist/core/jobs.js +0 -565
- package/dist/core/subagents.d.ts +0 -18
- package/dist/core/subagents.js +0 -163
- package/dist/prompts/agent.d.ts +0 -27
- package/dist/prompts/agent.js +0 -184
- package/dist/prompts/debug.d.ts +0 -8
- package/dist/prompts/debug.js +0 -44
- /package/dist/core/__tests__/{flow-leaves.test.d.ts → canvas-inbox-watcher.test.d.ts} +0 -0
- /package/dist/core/__tests__/{job.test.d.ts → canvas.test.d.ts} +0 -0
- /package/dist/core/__tests__/{jobs.test.d.ts → reset.test.d.ts} +0 -0
- /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;
|