@crouton-kit/crouter 0.3.13 → 0.3.14

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 (41) hide show
  1. package/dist/commands/__tests__/human.test.js +73 -2
  2. package/dist/commands/human/queue.d.ts +1 -0
  3. package/dist/commands/human/queue.js +89 -2
  4. package/dist/commands/human/shared.d.ts +5 -0
  5. package/dist/commands/human/shared.js +15 -0
  6. package/dist/commands/human.js +4 -2
  7. package/dist/commands/node.js +195 -24
  8. package/dist/core/__tests__/passive-subscription.test.d.ts +1 -0
  9. package/dist/core/__tests__/passive-subscription.test.js +141 -0
  10. package/dist/core/__tests__/subcommand-tier.test.d.ts +1 -0
  11. package/dist/core/__tests__/subcommand-tier.test.js +97 -0
  12. package/dist/core/canvas/paths.d.ts +4 -0
  13. package/dist/core/canvas/paths.js +6 -0
  14. package/dist/core/command.js +40 -7
  15. package/dist/core/feed/feed.js +11 -9
  16. package/dist/core/feed/passive.d.ts +17 -0
  17. package/dist/core/feed/passive.js +79 -0
  18. package/dist/core/help.d.ts +45 -12
  19. package/dist/core/help.js +42 -4
  20. package/dist/core/runtime/demote.d.ts +14 -0
  21. package/dist/core/runtime/demote.js +103 -0
  22. package/dist/core/runtime/kickoff.d.ts +9 -0
  23. package/dist/core/runtime/kickoff.js +19 -1
  24. package/dist/core/runtime/launch.d.ts +12 -1
  25. package/dist/core/runtime/launch.js +18 -2
  26. package/dist/core/runtime/presence.d.ts +1 -18
  27. package/dist/core/runtime/presence.js +7 -51
  28. package/dist/core/runtime/promote.d.ts +4 -0
  29. package/dist/core/runtime/promote.js +21 -6
  30. package/dist/core/runtime/roadmap.d.ts +5 -4
  31. package/dist/core/runtime/roadmap.js +9 -16
  32. package/dist/core/runtime/spawn.js +7 -2
  33. package/dist/core/runtime/tmux.d.ts +11 -12
  34. package/dist/core/runtime/tmux.js +57 -26
  35. package/dist/pi-extensions/canvas-commands.d.ts +34 -0
  36. package/dist/pi-extensions/canvas-commands.js +100 -0
  37. package/dist/pi-extensions/canvas-goal-capture.d.ts +18 -0
  38. package/dist/pi-extensions/canvas-goal-capture.js +53 -0
  39. package/dist/pi-extensions/canvas-passive-context.d.ts +32 -0
  40. package/dist/pi-extensions/canvas-passive-context.js +114 -0
  41. package/package.json +1 -1
@@ -0,0 +1,103 @@
1
+ // demote.ts — the "graduate this agent" action behind `crtr node demote`.
2
+ //
3
+ // Demote finishes the agent occupying a tmux pane and recycles that pane for
4
+ // fresh work, in three steps:
5
+ //
6
+ // 1. Finalize — push the agent's last surfaced message as a `final` report so
7
+ // every subscriber/manager waiting on it is unblocked, and mark it done.
8
+ // 2. Close — kill the agent's pi (respawn-pane -k tears it down in place).
9
+ // 3. Recycle — boot a fresh resident root in that same pane (a new `crtr`).
10
+ //
11
+ // The agent's real conversation lives inside pi (not on disk), so the final
12
+ // body is its newest report (which, on a natural stop, IS its last assistant
13
+ // message) — falling back to a short note when it never reported.
14
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { getNode, updateNode } from '../canvas/index.js';
17
+ import { reportsDir } from '../canvas/paths.js';
18
+ import { pushFinal } from '../feed/feed.js';
19
+ import { spawnNode } from './nodes.js';
20
+ import { buildLaunchSpec, buildPiArgv } from './launch.js';
21
+ import { respawnPane, piCommand, paneLocation, nodeSession } from './tmux.js';
22
+ import { FRONT_DOOR_ENV } from './front-door.js';
23
+ import { getFocus, setFocus } from './presence.js';
24
+ import { ensureDaemon } from '../../daemon/manage.js';
25
+ /** The agent's most recent surfaced message: the newest reports/*.md body with
26
+ * its YAML frontmatter stripped. Empty string when the node never reported. */
27
+ function lastReportBody(nodeId) {
28
+ try {
29
+ const dir = reportsDir(nodeId);
30
+ const files = readdirSync(dir).filter((f) => f.endsWith('.md'));
31
+ if (files.length === 0)
32
+ return '';
33
+ let newest = '';
34
+ let newestMs = -1;
35
+ for (const f of files) {
36
+ const ms = statSync(join(dir, f)).mtimeMs;
37
+ if (ms > newestMs) {
38
+ newestMs = ms;
39
+ newest = f;
40
+ }
41
+ }
42
+ const raw = readFileSync(join(dir, newest), 'utf8');
43
+ // Strip leading YAML frontmatter: ---\n …\n---\n<body>
44
+ const m = /^---\n[\s\S]*?\n---\n/.exec(raw);
45
+ return (m !== null ? raw.slice(m[0].length) : raw).trim();
46
+ }
47
+ catch {
48
+ return '';
49
+ }
50
+ }
51
+ /** Finish `nodeId` and recycle its pane into a fresh root. `callerPane` is the
52
+ * tmux pane the agent occupies (the Alt+C menu passes it as `#{pane_id}`).
53
+ * Best-effort; `demoted:false` when there is no pane to act on. */
54
+ export async function demoteNode(nodeId, callerPane) {
55
+ const meta = getNode(nodeId);
56
+ if (meta === null)
57
+ return { demoted: false, finalized: false, newRoot: null, delivered: [] };
58
+ const pane = callerPane ?? process.env['TMUX_PANE'];
59
+ if (pane === undefined || pane === '') {
60
+ return { demoted: false, finalized: false, newRoot: null, delivered: [] };
61
+ }
62
+ // 1. Finalize — fan the agent's last message out as a `final`, mark it done.
63
+ const body = lastReportBody(nodeId) ||
64
+ `Closed via demote — no final summary was authored by ${meta.name}.`;
65
+ let delivered = [];
66
+ let finalized = false;
67
+ try {
68
+ const res = await pushFinal(nodeId, body);
69
+ delivered = res.deliveredTo;
70
+ finalized = true;
71
+ }
72
+ catch { /* recycle the pane even if the report failed */ }
73
+ // The demoted node no longer holds a window — the pane is being reclaimed.
74
+ try {
75
+ updateNode(nodeId, { window: null, tmux_session: null });
76
+ }
77
+ catch { /* best-effort */ }
78
+ if (getFocus() === nodeId)
79
+ setFocus('');
80
+ // 2 + 3. Recycle — boot a fresh resident root in the SAME pane.
81
+ try {
82
+ ensureDaemon();
83
+ }
84
+ catch { /* daemon is best-effort */ }
85
+ const loc = paneLocation(pane);
86
+ const { launch } = buildLaunchSpec('general', 'base');
87
+ const root = spawnNode({
88
+ kind: 'general',
89
+ mode: 'base',
90
+ lifecycle: 'resident',
91
+ cwd: meta.cwd,
92
+ name: 'general',
93
+ parent: null,
94
+ launch,
95
+ });
96
+ if (loc !== null)
97
+ updateNode(root.node_id, { tmux_session: loc.session, window: loc.window });
98
+ const fresh = getNode(root.node_id);
99
+ const inv = buildPiArgv(fresh);
100
+ const env = { ...inv.env, CRTR_ROOT_SESSION: nodeSession(), [FRONT_DOOR_ENV]: '1' };
101
+ const ok = respawnPane({ pane, cwd: meta.cwd, env, command: piCommand(inv.argv) });
102
+ return { demoted: ok, finalized, newRoot: root.node_id, delivered };
103
+ }
@@ -6,6 +6,15 @@ export declare function readGoal(nodeId: string): string | null;
6
6
  /** Persist the spawning prompt as the node's goal. No-op for an empty prompt
7
7
  * (e.g. a bare root). Idempotent enough — call once at spawn/boot. */
8
8
  export declare function writeGoal(nodeId: string, text: string): void;
9
+ /** Write the goal ONLY if the node has none yet. This is how a bare root (no
10
+ * spawn prompt) acquires its mandate: the first real user message becomes the
11
+ * goal. Returns true when it wrote one, false when a goal already existed or
12
+ * the text was empty. Guarded so a later message never clobbers the mandate. */
13
+ export declare function captureGoalIfAbsent(nodeId: string, text: string): boolean;
14
+ /** Sentinel opening the fresh-revive kickoff message (see buildReviveKickoff).
15
+ * The goal-capture extension skips any input starting with this so a kickoff
16
+ * prompt is never mistaken for a user's first mandate. */
17
+ export declare const REVIVE_KICKOFF_SENTINEL = "You have been revived fresh after a context refresh";
9
18
  /** The yield-message file — a short note `crtr node yield` records for the next
10
19
  * revive ("on wake, do X"). Consumed (deleted) when the revive reads it. */
11
20
  export declare function yieldMessagePath(nodeId: string): string;
@@ -41,6 +41,24 @@ export function writeGoal(nodeId, text) {
41
41
  mkdirSync(contextDir(nodeId), { recursive: true });
42
42
  writeFileSync(goalPath(nodeId), body + '\n', 'utf8');
43
43
  }
44
+ /** Write the goal ONLY if the node has none yet. This is how a bare root (no
45
+ * spawn prompt) acquires its mandate: the first real user message becomes the
46
+ * goal. Returns true when it wrote one, false when a goal already existed or
47
+ * the text was empty. Guarded so a later message never clobbers the mandate. */
48
+ export function captureGoalIfAbsent(nodeId, text) {
49
+ const existing = readGoal(nodeId);
50
+ if (existing !== null && existing.trim() !== '')
51
+ return false;
52
+ const body = text.trim();
53
+ if (body === '')
54
+ return false;
55
+ writeGoal(nodeId, body);
56
+ return true;
57
+ }
58
+ /** Sentinel opening the fresh-revive kickoff message (see buildReviveKickoff).
59
+ * The goal-capture extension skips any input starting with this so a kickoff
60
+ * prompt is never mistaken for a user's first mandate. */
61
+ export const REVIVE_KICKOFF_SENTINEL = 'You have been revived fresh after a context refresh';
44
62
  /** The yield-message file — a short note `crtr node yield` records for the next
45
63
  * revive ("on wake, do X"). Consumed (deleted) when the revive reads it. */
46
64
  export function yieldMessagePath(nodeId) {
@@ -112,7 +130,7 @@ export function buildReviveKickoff(meta) {
112
130
  // Consume the one-shot yield note first so it never shows in the dir listing.
113
131
  const yieldMsg = consumeYieldMessage(nodeId);
114
132
  const parts = [
115
- 'You have been revived fresh after a context refresh — your previous in-memory ' +
133
+ `${REVIVE_KICKOFF_SENTINEL} — your previous in-memory ` +
116
134
  'context is gone, by design. Everything below was just read from disk; it is your ' +
117
135
  'full bearings. Rebuild from it and continue toward your goal.',
118
136
  ];
@@ -2,9 +2,15 @@ import type { NodeMeta, LaunchSpec, Mode } from '../canvas/index.js';
2
2
  export declare const CANVAS_STOPHOOK_PATH: string;
3
3
  export declare const CANVAS_INBOX_WATCHER_PATH: string;
4
4
  export declare const CANVAS_NAV_PATH: string;
5
+ export declare const CANVAS_GOAL_CAPTURE_PATH: string;
6
+ export declare const CANVAS_PASSIVE_CONTEXT_PATH: string;
7
+ export declare const CANVAS_COMMANDS_PATH: string;
5
8
  /** The canvas extensions every node loads, in order: stophook (routing +
6
9
  * telemetry + session-id capture), inbox-watcher (wake), nav (in-editor
7
- * graph chrome). All self-gate on CRTR_NODE_ID. */
10
+ * graph chrome), goal-capture (persist the first user message as the goal),
11
+ * passive-context (drain passive backlog as pre-text on the next message),
12
+ * commands (the /promote slash-command). All self-gate on CRTR_NODE_ID.
13
+ * goal-capture precedes passive-context so it reads the raw user text. */
8
14
  export declare const CANVAS_EXTENSIONS: string[];
9
15
  /** Bare model aliases resolve to the anthropic provider under pi (avoids the
10
16
  * bedrock default). Anything with a `/` or an unknown name passes through. */
@@ -24,6 +30,11 @@ export interface PiInvocation {
24
30
  /** env to merge into the process. */
25
31
  env: Record<string, string>;
26
32
  }
33
+ /** The pi session display name — the editor label in the top-left. Shows the
34
+ * node's name plus its current mode so base vs orchestrator reads at a glance
35
+ * (e.g. `developer (orchestrator)`). Recomputed from `meta.mode` on every
36
+ * revive, so a base→orchestrator polymorph updates the label. */
37
+ export declare function editorLabel(meta: NodeMeta): string;
27
38
  /** Construct the pi invocation for a node.
28
39
  * - fresh start: pass `prompt` (the node's first user message), no resume.
29
40
  * - revive idle/done: pass `resumeSessionId` to `--resume` (keeps conversation).
@@ -27,13 +27,22 @@ function resolveExtension(name) {
27
27
  export const CANVAS_STOPHOOK_PATH = resolveExtension('canvas-stophook');
28
28
  export const CANVAS_INBOX_WATCHER_PATH = resolveExtension('canvas-inbox-watcher');
29
29
  export const CANVAS_NAV_PATH = resolveExtension('canvas-nav');
30
+ export const CANVAS_GOAL_CAPTURE_PATH = resolveExtension('canvas-goal-capture');
31
+ export const CANVAS_PASSIVE_CONTEXT_PATH = resolveExtension('canvas-passive-context');
32
+ export const CANVAS_COMMANDS_PATH = resolveExtension('canvas-commands');
30
33
  /** The canvas extensions every node loads, in order: stophook (routing +
31
34
  * telemetry + session-id capture), inbox-watcher (wake), nav (in-editor
32
- * graph chrome). All self-gate on CRTR_NODE_ID. */
35
+ * graph chrome), goal-capture (persist the first user message as the goal),
36
+ * passive-context (drain passive backlog as pre-text on the next message),
37
+ * commands (the /promote slash-command). All self-gate on CRTR_NODE_ID.
38
+ * goal-capture precedes passive-context so it reads the raw user text. */
33
39
  export const CANVAS_EXTENSIONS = [
34
40
  CANVAS_STOPHOOK_PATH,
35
41
  CANVAS_INBOX_WATCHER_PATH,
36
42
  CANVAS_NAV_PATH,
43
+ CANVAS_GOAL_CAPTURE_PATH,
44
+ CANVAS_PASSIVE_CONTEXT_PATH,
45
+ CANVAS_COMMANDS_PATH,
37
46
  ];
38
47
  /** Bare model aliases resolve to the anthropic provider under pi (avoids the
39
48
  * bedrock default). Anything with a `/` or an unknown name passes through. */
@@ -59,6 +68,13 @@ export function buildLaunchSpec(kind, mode, opts = {}) {
59
68
  };
60
69
  return { launch, lifecycle: p.lifecycle, skills: p.skills };
61
70
  }
71
+ /** The pi session display name — the editor label in the top-left. Shows the
72
+ * node's name plus its current mode so base vs orchestrator reads at a glance
73
+ * (e.g. `developer (orchestrator)`). Recomputed from `meta.mode` on every
74
+ * revive, so a base→orchestrator polymorph updates the label. */
75
+ export function editorLabel(meta) {
76
+ return `${meta.name} (${meta.mode})`;
77
+ }
62
78
  /** Construct the pi invocation for a node.
63
79
  * - fresh start: pass `prompt` (the node's first user message), no resume.
64
80
  * - revive idle/done: pass `resumeSessionId` to `--resume` (keeps conversation).
@@ -69,7 +85,7 @@ export function buildPiArgv(meta, opts = {}) {
69
85
  for (const ext of spec?.extensions ?? CANVAS_EXTENSIONS) {
70
86
  argv.push('-e', ext);
71
87
  }
72
- argv.push('-n', meta.name);
88
+ argv.push('-n', editorLabel(meta));
73
89
  if (opts.resumeSessionId !== undefined)
74
90
  argv.push('--resume', opts.resumeSessionId);
75
91
  if (spec?.model !== undefined)
@@ -31,25 +31,8 @@ export declare function focusNode(nodeId: string): {
31
31
  *
32
32
  * Falls back to window focus when there is no caller pane (not inside tmux) or
33
33
  * the target pane can't be resolved. `inPlace` reports which path ran. */
34
- export declare function focusNodeInPlace(nodeId: string, callerPane?: string): {
34
+ export declare function focusNodeInPlace(nodeId: string, callerPane?: string, callerNodeId?: string): {
35
35
  focused: boolean;
36
36
  session: string | null;
37
37
  inPlace: boolean;
38
38
  };
39
- /** Send a node's running pi OUT of the caller's pane and into a window in the
40
- * shared global session, leaving a fresh shell where it was — the pane
41
- * "becomes a terminal" and the agent keeps running, detached, in the
42
- * background. The inverse of `focusNodeInPlace`; reversible via `node focus`.
43
- *
44
- * Mechanism: open a shell window in the global session, then swap that shell
45
- * pane INTO the caller's pane — tmux exchanges the two panes, so the node's pi
46
- * pane lands in the shell's window (global session) and the shell lands in the
47
- * caller's pane. The node's meta is re-pointed to the new window so the daemon
48
- * keeps supervising it.
49
- *
50
- * Best-effort; `demoted:false` when not in tmux or any tmux step fails. */
51
- export declare function demoteNode(nodeId: string, callerPane?: string): {
52
- demoted: boolean;
53
- session: string | null;
54
- window: string | null;
55
- };
@@ -19,7 +19,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
19
  import { dirname } from 'node:path';
20
20
  import { join } from 'node:path';
21
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';
22
+ import { selectWindow, switchClient, windowAlive, currentTmux, paneOfWindow, swapPaneInPlace, windowOfPane } from './tmux.js';
23
23
  // ---------------------------------------------------------------------------
24
24
  // Focus pointer
25
25
  // ---------------------------------------------------------------------------
@@ -99,7 +99,7 @@ export function focusNode(nodeId) {
99
99
  *
100
100
  * Falls back to window focus when there is no caller pane (not inside tmux) or
101
101
  * the target pane can't be resolved. `inPlace` reports which path ran. */
102
- export function focusNodeInPlace(nodeId, callerPane) {
102
+ export function focusNodeInPlace(nodeId, callerPane, callerNodeId) {
103
103
  const meta = getNode(nodeId);
104
104
  // Always write the pointer so the dashboard reflects intent even on failure.
105
105
  setFocus(nodeId);
@@ -140,59 +140,15 @@ export function focusNodeInPlace(nodeId, callerPane) {
140
140
  catch { /* best-effort */ }
141
141
  // The caller is the node running this focus (its pi process owns callerPane).
142
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) {
143
+ // Prefer an explicit id (the `node cycle` tmux binding runs outside any pi,
144
+ // so CRTR_NODE_ID is unset there) and fall back to the env for `node focus`.
145
+ const cnid = callerNodeId ?? process.env['CRTR_NODE_ID'];
146
+ if (cnid !== undefined && cnid.trim() !== '' && cnid !== nodeId) {
145
147
  try {
146
- updateNode(callerNodeId, { window });
148
+ updateNode(cnid, { window });
147
149
  }
148
150
  catch { /* best-effort */ }
149
151
  }
150
152
  }
151
153
  return { focused: ok, session, inPlace: true };
152
154
  }
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
- }
@@ -4,6 +4,10 @@ export interface PromoteResult {
4
4
  /** Orchestration guidance to surface into the node's current context now. */
5
5
  guidance: string;
6
6
  roadmapWritten: boolean;
7
+ /** Absolute path to the node's roadmap doc (context/roadmap.md). */
8
+ roadmapPath: string;
9
+ /** Absolute path to the node's goal doc (context/initial-prompt.md). */
10
+ goalPath: string;
7
11
  }
8
12
  /** Promote a node to resident orchestrator, optionally specializing its kind
9
13
  * (e.g. a `general` worker becoming a `developer.orchestrator`). Idempotent:
@@ -19,7 +19,8 @@ import { loadKernel, loadPersona } from '../personas/index.js';
19
19
  import { resolveSkill } from '../resolver.js';
20
20
  import { readText } from '../fs-utils.js';
21
21
  import { parseFrontmatter } from '../frontmatter.js';
22
- import { hasRoadmap, seedRoadmap, readRoadmap } from './roadmap.js';
22
+ import { hasRoadmap, seedRoadmap, readRoadmap, roadmapPath } from './roadmap.js';
23
+ import { readGoal, goalPath } from './kickoff.js';
23
24
  /** Load a skill's body text by name, or null if it can't be resolved. Used to
24
25
  * inline a kind's roadmap-shaping skill into the promotion guidance dump. */
25
26
  function loadSkillBody(name) {
@@ -43,16 +44,21 @@ function orchestrationGuidance(nodeId, kind) {
43
44
  : undefined;
44
45
  const skillBody = roadmapSkill ? loadSkillBody(roadmapSkill) : null;
45
46
  const roadmap = readRoadmap(nodeId) ?? '(no roadmap yet)';
47
+ const rmPath = roadmapPath(nodeId);
48
+ const goal = readGoal(nodeId);
46
49
  const parts = [
47
50
  `You are now a RESIDENT ${kind.toUpperCase()} ORCHESTRATOR. Your scarce resource is your own context window.`,
48
51
  'Your job is to manage context and delegate — not to do the goal yourself.',
49
52
  '',
50
53
  kernel,
51
54
  ];
55
+ if (goal !== null && goal.trim() !== '') {
56
+ parts.push('', `--- Your goal (${goalPath(nodeId)}) ---`, '', goal.trim());
57
+ }
52
58
  if (skillBody) {
53
59
  parts.push('', `--- How to shape a ${kind} roadmap (skill: ${roadmapSkill}) ---`, '', skillBody);
54
60
  }
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.');
61
+ parts.push('', `Your roadmap scaffold (\`${rmPath}\`) — author it now: state the goal, exit criteria, and the phase skeleton, using the approach above. Current contents:`, '', roadmap, '', 'Then delegate each phase with `crtr node new --kind <kind>`. When your context fills, run `crtr node yield` to refresh against this roadmap.');
56
62
  return parts.join('\n');
57
63
  }
58
64
  /** Promote a node to resident orchestrator, optionally specializing its kind
@@ -71,15 +77,24 @@ export function promote(nodeId, opts = {}) {
71
77
  // nodeEnv reads meta.{kind,mode}, so CRTR_KIND/CRTR_MODE flip immediately for
72
78
  // the live process's children too.
73
79
  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.
80
+ // Seed a barebones roadmap scaffold if absent so the file exists for a
81
+ // refresh. Pre-fill its Goal from the node's goal doc when present (set at
82
+ // spawn, or captured from the first user message); the node fleshes out the
83
+ // body next, guided by the kind skill dumped below.
76
84
  let roadmapWritten = false;
77
85
  if (!hasRoadmap(nodeId)) {
78
- seedRoadmap(nodeId);
86
+ const goal = readGoal(nodeId);
87
+ seedRoadmap(nodeId, goal !== null && goal.trim() !== '' ? { goal: goal.trim() } : {});
79
88
  roadmapWritten = true;
80
89
  }
81
90
  const meta = updateNode(nodeId, { kind: targetKind, lifecycle: 'resident', mode: 'orchestrator', launch });
82
- return { meta, guidance: orchestrationGuidance(nodeId, targetKind), roadmapWritten };
91
+ return {
92
+ meta,
93
+ guidance: orchestrationGuidance(nodeId, targetKind),
94
+ roadmapWritten,
95
+ roadmapPath: roadmapPath(nodeId),
96
+ goalPath: goalPath(nodeId),
97
+ };
83
98
  }
84
99
  /** Request a refresh-yield: discard in-memory context and revive fresh against
85
100
  * the roadmap. A *terminal* node that yields is choosing to persist — it
@@ -1,10 +1,11 @@
1
1
  export declare function roadmapPath(nodeId: string): string;
2
2
  export declare function hasRoadmap(nodeId: string): boolean;
3
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 —
4
+ /** Seed a fresh, EXTREMELY BAREBONES roadmap scaffoldjust the section
5
+ * skeleton with one-line prompts. Promotion lays this down so the file exists
6
+ * for a refresh; the owner fleshes it out as its next act (guided by its
7
+ * kind's roadmap skill). `goal`/`exitCriteria` pre-fill those sections when
8
+ * known (e.g. from the node's goal doc). Idempotent only if you intend it —
8
9
  * call sites guard on hasRoadmap to avoid clobbering an evolved map. */
9
10
  export declare function seedRoadmap(nodeId: string, opts?: {
10
11
  goal?: string;
@@ -20,32 +20,25 @@ export function readRoadmap(nodeId) {
20
20
  const p = roadmapPath(nodeId);
21
21
  return existsSync(p) ? readFileSync(p, 'utf8') : null;
22
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 —
23
+ /** Seed a fresh, EXTREMELY BAREBONES roadmap scaffoldjust the section
24
+ * skeleton with one-line prompts. Promotion lays this down so the file exists
25
+ * for a refresh; the owner fleshes it out as its next act (guided by its
26
+ * kind's roadmap skill). `goal`/`exitCriteria` pre-fill those sections when
27
+ * known (e.g. from the node's goal doc). Idempotent only if you intend it —
27
28
  * call sites guard on hasRoadmap to avoid clobbering an evolved map. */
28
29
  export function seedRoadmap(nodeId, opts = {}) {
29
30
  const dir = contextDir(nodeId);
30
31
  mkdirSync(dir, { recursive: true });
31
32
  const body = `# Roadmap
32
33
 
33
- <!-- frozen core: set once, rarely changes -->
34
34
  ## Goal
35
- ${opts.goal?.trim() ?? '- (state the high-level goal you now own — write this as your first act)'}
35
+ ${opts.goal?.trim() ?? '(the goal you now own)'}
36
36
 
37
37
  ## Exit criteria
38
- ${opts.exitCriteria?.trim() ?? '- (define what "done" looks like)'}
38
+ ${opts.exitCriteria?.trim() ?? '(what "done" looks like)'}
39
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)
40
+ ## Phases
41
+ (ordered phases from here to done; the current one carries a one-line status)
49
42
  `;
50
43
  writeFileSync(roadmapPath(nodeId), body);
51
44
  return body;
@@ -11,7 +11,7 @@ import { FRONT_DOOR_ENV } from './front-door.js';
11
11
  import { spawnNode, currentNodeContext } from './nodes.js';
12
12
  import { buildLaunchSpec, buildPiArgv } from './launch.js';
13
13
  import { writeGoal } from './kickoff.js';
14
- import { ensureSession, openNodeWindow, piCommand, currentTmux, inTmux, nodeSession, installMenuBinding, } from './tmux.js';
14
+ import { ensureSession, openNodeWindow, piCommand, currentTmux, inTmux, nodeSession, installMenuBinding, installNavBindings, } from './tmux.js';
15
15
  import { updateNode, getNode } from '../canvas/index.js';
16
16
  import { ensureDaemon } from '../../daemon/manage.js';
17
17
  /** Create a root node and bring up its pi. Returns the node; for 'inline' this
@@ -43,12 +43,17 @@ export function bootRoot(opts) {
43
43
  // Every node window — root or child — lives in the one shared session.
44
44
  const session = nodeSession();
45
45
  ensureSession(session, opts.cwd);
46
- // Make the Alt+C action menu live on this server (idempotent, in-tmux only).
46
+ // Make the Alt+C action menu + Alt+] / Alt+[ nav keys live on this server
47
+ // (idempotent, in-tmux only).
47
48
  if (inTmux()) {
48
49
  try {
49
50
  installMenuBinding();
50
51
  }
51
52
  catch { /* best-effort */ }
53
+ try {
54
+ installNavBindings();
55
+ }
56
+ catch { /* best-effort */ }
52
57
  }
53
58
  if (opts.placement === 'session') {
54
59
  updateNode(meta.node_id, { tmux_session: session });
@@ -39,18 +39,6 @@ export interface OpenWindowOpts {
39
39
  * `-a` also keeps node windows off index 0, which is reserved for the optional
40
40
  * dashboard. */
41
41
  export declare function openNodeWindow(opts: OpenWindowOpts): string | null;
42
- /** Open a background window running a plain login shell (no pi) and return its
43
- * window + pane ids. Used by demote: the agent's pi is swapped OUT into this
44
- * window's slot and the shell is swapped INTO the caller's pane. `-a` keeps it
45
- * off index 0 (reserved for a dashboard), `-d` keeps it from stealing focus. */
46
- export declare function openShellWindow(opts: {
47
- session: string;
48
- name: string;
49
- cwd: string;
50
- }): {
51
- window: string;
52
- pane: string;
53
- } | null;
54
42
  /** Bring a node's window forefront. Switches client across roots when needed. */
55
43
  export declare function focusWindow(session: string, window: string): boolean;
56
44
  /** Close a node's window (drop it from the UI). */
@@ -63,6 +51,12 @@ export declare function paneOfWindow(session: string, window: string): string |
63
51
  * are not, so the node→window mapping must be re-derived from the pane. Returns
64
52
  * null if the pane is gone or tmux fails. */
65
53
  export declare function windowOfPane(pane: string): string | null;
54
+ /** The session + window a pane currently lives in. Used by demote to place the
55
+ * recycled root's meta on the pane it respawns into. Null if tmux fails. */
56
+ export declare function paneLocation(pane: string): {
57
+ session: string;
58
+ window: string;
59
+ } | null;
66
60
  /** Swap `targetPane` into `callerPane`'s layout slot, IN PLACE. `-d` keeps the
67
61
  * caller's window active, so the target's pane appears where the caller is
68
62
  * rather than navigating the client off to the target's window. The caller's
@@ -105,3 +99,8 @@ export declare function selectWindow(session: string, window: string): boolean;
105
99
  export declare function switchClient(session: string): boolean;
106
100
  /** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails. */
107
101
  export declare function installMenuBinding(): boolean;
102
+ /** Bind Alt+] (forward) and Alt+[ (back) to the DFS canvas walk. Best-effort;
103
+ * false if either bind fails. NOTE: Alt+[ is only delivered cleanly when the
104
+ * terminal/tmux disambiguate it from a raw CSI introducer (`extended-keys on`).
105
+ */
106
+ export declare function installNavBindings(): boolean;