@crouton-kit/crouter 0.3.14 → 0.3.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/dist/build-root.d.ts +8 -0
  2. package/dist/build-root.js +30 -0
  3. package/dist/builtin-personas/design/base.md +3 -7
  4. package/dist/builtin-personas/design/orchestrator.md +4 -3
  5. package/dist/builtin-personas/developer/base.md +3 -7
  6. package/dist/builtin-personas/developer/orchestrator.md +5 -4
  7. package/dist/builtin-personas/explore/base.md +3 -7
  8. package/dist/builtin-personas/explore/orchestrator.md +1 -5
  9. package/dist/builtin-personas/general/base.md +2 -4
  10. package/dist/builtin-personas/general/orchestrator.md +2 -4
  11. package/dist/builtin-personas/lifecycle/resident.md +2 -0
  12. package/dist/builtin-personas/lifecycle/terminal.md +6 -0
  13. package/dist/builtin-personas/orchestration-kernel.md +42 -3
  14. package/dist/builtin-personas/plan/base.md +3 -5
  15. package/dist/builtin-personas/plan/orchestrator.md +5 -4
  16. package/dist/builtin-personas/plan/reviewers/architecture-fit/base.md +9 -0
  17. package/dist/builtin-personas/plan/reviewers/code-smells/base.md +9 -0
  18. package/dist/builtin-personas/plan/reviewers/pattern-consistency/base.md +9 -0
  19. package/dist/builtin-personas/plan/reviewers/requirements-coverage/base.md +9 -0
  20. package/dist/builtin-personas/plan/reviewers/security/base.md +9 -0
  21. package/dist/builtin-personas/review/base.md +3 -5
  22. package/dist/builtin-personas/review/orchestrator.md +2 -6
  23. package/dist/builtin-personas/runtime-base.md +3 -19
  24. package/dist/builtin-personas/spec/base.md +3 -5
  25. package/dist/builtin-personas/spec/orchestrator.md +4 -3
  26. package/dist/builtin-personas/spine/has-manager.md +10 -0
  27. package/dist/builtin-personas/spine/no-manager.md +2 -0
  28. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +96 -0
  29. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +49 -0
  30. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +49 -0
  31. package/dist/builtin-skills/skills/planning/SKILL.md +1 -1
  32. package/dist/builtin-skills/skills/spec/SKILL.md +2 -2
  33. package/dist/cli.js +6 -29
  34. package/dist/commands/attention.js +76 -7
  35. package/dist/commands/canvas-prune.d.ts +2 -0
  36. package/dist/commands/canvas-prune.js +66 -0
  37. package/dist/commands/canvas.js +5 -8
  38. package/dist/commands/chord.d.ts +2 -0
  39. package/dist/commands/chord.js +143 -0
  40. package/dist/commands/daemon.js +8 -5
  41. package/dist/commands/dashboard.js +2 -0
  42. package/dist/commands/human/prompts.js +28 -27
  43. package/dist/commands/human/queue.js +30 -14
  44. package/dist/commands/human/shared.d.ts +26 -21
  45. package/dist/commands/human/shared.js +45 -67
  46. package/dist/commands/human.js +4 -14
  47. package/dist/commands/node.d.ts +11 -0
  48. package/dist/commands/node.js +221 -99
  49. package/dist/commands/pkg/market-inspect.js +6 -4
  50. package/dist/commands/pkg/market-manage.js +10 -6
  51. package/dist/commands/pkg/market.js +2 -4
  52. package/dist/commands/pkg/plugin-inspect.js +6 -4
  53. package/dist/commands/pkg/plugin-manage.js +12 -7
  54. package/dist/commands/pkg/plugin.js +2 -4
  55. package/dist/commands/pkg.js +0 -4
  56. package/dist/commands/push.js +178 -15
  57. package/dist/commands/revive.js +5 -3
  58. package/dist/commands/skill/author.js +6 -4
  59. package/dist/commands/skill/find.js +8 -5
  60. package/dist/commands/skill/read.js +2 -0
  61. package/dist/commands/skill/state.js +6 -4
  62. package/dist/commands/skill.js +0 -6
  63. package/dist/commands/sys/config.js +21 -7
  64. package/dist/commands/sys/doctor.js +2 -0
  65. package/dist/commands/sys/update.js +4 -0
  66. package/dist/commands/sys.js +0 -6
  67. package/dist/commands/tmux-spread.d.ts +2 -0
  68. package/dist/commands/tmux-spread.js +129 -0
  69. package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
  70. package/dist/core/__tests__/child-followup.test.d.ts +1 -0
  71. package/dist/core/__tests__/child-followup.test.js +83 -0
  72. package/dist/core/__tests__/close.test.d.ts +1 -0
  73. package/dist/core/__tests__/close.test.js +148 -0
  74. package/dist/core/__tests__/context-intro.test.d.ts +1 -0
  75. package/dist/core/__tests__/context-intro.test.js +196 -0
  76. package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
  77. package/dist/core/__tests__/daemon-boot.test.js +93 -0
  78. package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
  79. package/dist/core/__tests__/daemon-liveness.test.js +223 -0
  80. package/dist/core/__tests__/focuses.test.d.ts +1 -0
  81. package/dist/core/__tests__/focuses.test.js +196 -0
  82. package/dist/core/__tests__/fork.test.d.ts +1 -0
  83. package/dist/core/__tests__/fork.test.js +91 -0
  84. package/dist/core/__tests__/home-session.test.d.ts +1 -0
  85. package/dist/core/__tests__/home-session.test.js +153 -0
  86. package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
  87. package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
  88. package/dist/core/__tests__/keystone.test.d.ts +1 -0
  89. package/dist/core/__tests__/keystone.test.js +185 -0
  90. package/dist/core/__tests__/kickoff.test.d.ts +1 -0
  91. package/dist/core/__tests__/kickoff.test.js +89 -0
  92. package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
  93. package/dist/core/__tests__/lifecycle.test.js +178 -0
  94. package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
  95. package/dist/core/__tests__/listing-completeness.test.js +31 -0
  96. package/dist/core/__tests__/memory.test.d.ts +1 -0
  97. package/dist/core/__tests__/memory.test.js +152 -0
  98. package/dist/core/__tests__/migration.test.d.ts +1 -0
  99. package/dist/core/__tests__/migration.test.js +238 -0
  100. package/dist/core/__tests__/pane-column.test.d.ts +1 -0
  101. package/dist/core/__tests__/pane-column.test.js +153 -0
  102. package/dist/core/__tests__/passive-subscription.test.js +24 -1
  103. package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
  104. package/dist/core/__tests__/persona-compose.test.js +53 -0
  105. package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
  106. package/dist/core/__tests__/persona-subkind.test.js +62 -0
  107. package/dist/core/__tests__/persona.test.d.ts +1 -0
  108. package/dist/core/__tests__/persona.test.js +107 -0
  109. package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
  110. package/dist/core/__tests__/placement-focus.test.js +266 -0
  111. package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
  112. package/dist/core/__tests__/placement-reconcile.test.js +212 -0
  113. package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
  114. package/dist/core/__tests__/placement-revive.test.js +238 -0
  115. package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
  116. package/dist/core/__tests__/placement-teardown.test.js +178 -0
  117. package/dist/core/__tests__/prune.test.d.ts +1 -0
  118. package/dist/core/__tests__/prune.test.js +116 -0
  119. package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
  120. package/dist/core/__tests__/push-final-guard.test.js +71 -0
  121. package/dist/core/__tests__/relaunch.test.d.ts +1 -0
  122. package/dist/core/__tests__/relaunch.test.js +334 -0
  123. package/dist/core/__tests__/reset.test.js +26 -7
  124. package/dist/core/__tests__/revive.test.d.ts +1 -0
  125. package/dist/core/__tests__/revive.test.js +217 -0
  126. package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
  127. package/dist/core/__tests__/spawn-root.test.js +73 -0
  128. package/dist/core/__tests__/steer-note.test.d.ts +1 -0
  129. package/dist/core/__tests__/steer-note.test.js +39 -0
  130. package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
  131. package/dist/core/__tests__/stop-guard.test.js +82 -0
  132. package/dist/core/__tests__/subcommand-tier.test.js +35 -33
  133. package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
  134. package/dist/core/__tests__/tmux-surface.test.js +105 -0
  135. package/dist/core/__tests__/unknown-path.test.js +8 -2
  136. package/dist/core/canvas/attention.d.ts +10 -0
  137. package/dist/core/canvas/attention.js +40 -0
  138. package/dist/core/canvas/canvas.d.ts +66 -7
  139. package/dist/core/canvas/canvas.js +209 -21
  140. package/dist/core/canvas/db.d.ts +8 -0
  141. package/dist/core/canvas/db.js +205 -4
  142. package/dist/core/canvas/focuses.d.ts +22 -0
  143. package/dist/core/canvas/focuses.js +81 -0
  144. package/dist/core/canvas/index.d.ts +3 -0
  145. package/dist/core/canvas/index.js +3 -0
  146. package/dist/core/canvas/labels.d.ts +27 -0
  147. package/dist/core/canvas/labels.js +36 -0
  148. package/dist/core/canvas/render.js +25 -10
  149. package/dist/core/canvas/telemetry.d.ts +14 -0
  150. package/dist/core/canvas/telemetry.js +35 -0
  151. package/dist/core/canvas/types.d.ts +115 -12
  152. package/dist/core/command.d.ts +25 -1
  153. package/dist/core/command.js +23 -15
  154. package/dist/core/config.js +36 -2
  155. package/dist/core/feed/feed.js +3 -3
  156. package/dist/core/feed/inbox.d.ts +3 -1
  157. package/dist/core/feed/inbox.js +45 -5
  158. package/dist/core/feed/passive.js +24 -11
  159. package/dist/core/help.d.ts +26 -13
  160. package/dist/core/help.js +44 -37
  161. package/dist/core/personas/index.d.ts +1 -1
  162. package/dist/core/personas/index.js +1 -1
  163. package/dist/core/personas/loader.d.ts +40 -1
  164. package/dist/core/personas/loader.js +63 -1
  165. package/dist/core/personas/resolve.d.ts +13 -6
  166. package/dist/core/personas/resolve.js +46 -34
  167. package/dist/core/runtime/bearings.d.ts +20 -0
  168. package/dist/core/runtime/bearings.js +92 -0
  169. package/dist/core/runtime/close.d.ts +14 -0
  170. package/dist/core/runtime/close.js +151 -0
  171. package/dist/core/runtime/demote.js +24 -12
  172. package/dist/core/runtime/front-door.js +1 -1
  173. package/dist/core/runtime/kickoff.d.ts +23 -6
  174. package/dist/core/runtime/kickoff.js +92 -36
  175. package/dist/core/runtime/launch.d.ts +26 -12
  176. package/dist/core/runtime/launch.js +78 -19
  177. package/dist/core/runtime/lifecycle.d.ts +13 -0
  178. package/dist/core/runtime/lifecycle.js +86 -0
  179. package/dist/core/runtime/memory.d.ts +43 -0
  180. package/dist/core/runtime/memory.js +165 -0
  181. package/dist/core/runtime/naming.d.ts +22 -0
  182. package/dist/core/runtime/naming.js +166 -0
  183. package/dist/core/runtime/nodes.d.ts +39 -1
  184. package/dist/core/runtime/nodes.js +69 -10
  185. package/dist/core/runtime/persona.d.ts +25 -0
  186. package/dist/core/runtime/persona.js +139 -0
  187. package/dist/core/runtime/placement.d.ts +299 -0
  188. package/dist/core/runtime/placement.js +688 -0
  189. package/dist/core/runtime/promote.d.ts +14 -7
  190. package/dist/core/runtime/promote.js +57 -67
  191. package/dist/core/runtime/reset.d.ts +47 -4
  192. package/dist/core/runtime/reset.js +223 -52
  193. package/dist/core/runtime/revive.d.ts +26 -2
  194. package/dist/core/runtime/revive.js +166 -39
  195. package/dist/core/runtime/spawn.d.ts +20 -5
  196. package/dist/core/runtime/spawn.js +163 -43
  197. package/dist/core/runtime/stop-guard.d.ts +1 -1
  198. package/dist/core/runtime/stop-guard.js +18 -8
  199. package/dist/core/runtime/tmux-chrome.d.ts +1 -0
  200. package/dist/core/runtime/tmux-chrome.js +4 -0
  201. package/dist/core/runtime/tmux.d.ts +113 -20
  202. package/dist/core/runtime/tmux.js +221 -39
  203. package/dist/core/spawn.js +15 -0
  204. package/dist/daemon/crtrd.d.ts +12 -1
  205. package/dist/daemon/crtrd.js +152 -34
  206. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
  207. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
  208. package/dist/pi-extensions/canvas-commands.js +16 -13
  209. package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
  210. package/dist/pi-extensions/canvas-context-intro.js +164 -0
  211. package/dist/pi-extensions/canvas-goal-capture.d.ts +3 -0
  212. package/dist/pi-extensions/canvas-goal-capture.js +15 -1
  213. package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
  214. package/dist/pi-extensions/canvas-nav.d.ts +12 -4
  215. package/dist/pi-extensions/canvas-nav.js +594 -262
  216. package/dist/pi-extensions/canvas-resume.d.ts +22 -0
  217. package/dist/pi-extensions/canvas-resume.js +173 -0
  218. package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
  219. package/dist/pi-extensions/canvas-stophook.js +340 -228
  220. package/dist/types.d.ts +28 -0
  221. package/dist/types.js +16 -0
  222. package/package.json +2 -2
  223. package/dist/core/runtime/presence.d.ts +0 -38
  224. package/dist/core/runtime/presence.js +0 -154
@@ -89,12 +89,55 @@ export function writeCursor(nodeId, iso) {
89
89
  // ---------------------------------------------------------------------------
90
90
  // Coalesce
91
91
  // ---------------------------------------------------------------------------
92
+ /** Bounds for inlining a ref-less entry's body in the digest. */
93
+ const BODY_MAX_LINES = 12;
94
+ const BODY_MAX_CHARS = 1000;
95
+ /** Clip a body to a bounded preview, reporting whether anything was dropped. */
96
+ function clipBody(body) {
97
+ let text = body;
98
+ let clipped = false;
99
+ const lines = text.split('\n');
100
+ if (lines.length > BODY_MAX_LINES) {
101
+ text = lines.slice(0, BODY_MAX_LINES).join('\n');
102
+ clipped = true;
103
+ }
104
+ if (text.length > BODY_MAX_CHARS) {
105
+ text = text.slice(0, BODY_MAX_CHARS);
106
+ clipped = true;
107
+ }
108
+ return { text: text.trimEnd(), clipped };
109
+ }
110
+ /**
111
+ * Render one entry's digest line(s).
112
+ *
113
+ * A push pointer (has a `ref`) stays a pointer — the body lives in the report
114
+ * file, dereferenced on demand by reading that path. A ref-less entry (a direct
115
+ * `node msg` or a system alert) has NO report to dereference; its full body
116
+ * lives only in `data.body`, and `label` is just the first line truncated. So
117
+ * for those we inline the body (bounded) — rendering only the truncated label
118
+ * would strand the rest with nowhere to recover it.
119
+ */
120
+ function renderEntry(e) {
121
+ if (e.ref !== undefined) {
122
+ return ` [${e.kind}] ${e.label} (ref: ${e.ref})`;
123
+ }
124
+ const body = typeof e.data?.['body'] === 'string' ? e.data['body'].trim() : '';
125
+ if (body === '' || body === e.label) {
126
+ return ` [${e.kind}] ${e.label}`;
127
+ }
128
+ const { text, clipped } = clipBody(body);
129
+ const indented = text.split('\n').map((l) => ` ${l}`).join('\n');
130
+ const more = clipped ? '\n … (body clipped)' : '';
131
+ return ` [${e.kind}]\n${indented}${more}`;
132
+ }
92
133
  /**
93
134
  * Render many unread inbox pointers into one compact digest string.
94
135
  *
95
136
  * Format (per sender group):
96
137
  * From <sender> — <N> update(s):
97
- * [<kind>] <label> (ref: <path>)
138
+ * [<kind>] <label> (ref: <path>) ← push: pointer, dereference the ref
139
+ * [<kind>] ← ref-less msg: full body inlined
140
+ * <body line>
98
141
  * …
99
142
  *
100
143
  * A header line announces the total count and instructs the receiver to
@@ -114,10 +157,7 @@ export function coalesce(entries) {
114
157
  }
115
158
  const sections = [];
116
159
  for (const [sender, items] of groups) {
117
- const lines = items.map((e) => {
118
- const refPart = e.ref !== undefined ? ` (ref: ${e.ref})` : '';
119
- return ` [${e.kind}] ${e.label}${refPart}`;
120
- });
160
+ const lines = items.map(renderEntry);
121
161
  sections.push(`From ${sender} — ${items.length} update${items.length === 1 ? '' : 's'}:\n${lines.join('\n')}`);
122
162
  }
123
163
  return header + sections.join('\n\n');
@@ -29,15 +29,32 @@ export function appendPassive(nodeId, entry) {
29
29
  appendFileSync(passivePath(nodeId), line, { encoding: 'utf8', flag: 'a' });
30
30
  return full;
31
31
  }
32
+ /**
33
+ * Parse jsonl text into entries, tolerating corruption: each line is parsed on
34
+ * its own and a single malformed line is SKIPPED, never the whole feed. The
35
+ * drain path renames + deletes its snapshot, so a non-tolerant parse here would
36
+ * silently discard every accumulated entry the moment one bad line appears.
37
+ */
38
+ function parseEntries(text) {
39
+ const entries = [];
40
+ for (const line of text.split('\n')) {
41
+ if (line.trim() === '')
42
+ continue;
43
+ try {
44
+ entries.push(JSON.parse(line));
45
+ }
46
+ catch {
47
+ // Skip only the corrupt line — the rest of the feed survives.
48
+ }
49
+ }
50
+ return entries;
51
+ }
32
52
  /** Return every accumulated passive entry (oldest first) without clearing. */
33
53
  export function readPassive(nodeId) {
34
54
  const p = passivePath(nodeId);
35
55
  if (!existsSync(p))
36
56
  return [];
37
- return readFileSync(p, 'utf8')
38
- .split('\n')
39
- .filter((l) => l.trim() !== '')
40
- .map((l) => JSON.parse(l));
57
+ return parseEntries(readFileSync(p, 'utf8'));
41
58
  }
42
59
  /**
43
60
  * Read AND clear the accumulator in one shot — the drain-on-message primitive.
@@ -59,15 +76,11 @@ export function drainPassive(nodeId) {
59
76
  // Lost the race (file vanished) — nothing to drain.
60
77
  return [];
61
78
  }
79
+ // Parse with the tolerant per-line parser BEFORE removing the snapshot, so a
80
+ // single corrupt line can never discard the whole accumulated feed.
62
81
  let entries = [];
63
82
  try {
64
- entries = readFileSync(snapshot, 'utf8')
65
- .split('\n')
66
- .filter((l) => l.trim() !== '')
67
- .map((l) => JSON.parse(l));
68
- }
69
- catch {
70
- entries = [];
83
+ entries = parseEntries(readFileSync(snapshot, 'utf8'));
71
84
  }
72
85
  finally {
73
86
  try {
@@ -56,18 +56,25 @@ export type InputParam = PositionalParam | FlagParam | StdinParam | ContextFileP
56
56
  * - common — ALSO promoted into the parent's -h, as a bare qualified name.
57
57
  * - important — ALSO promoted into the parent's -h, name + shortform desc. */
58
58
  export type SubTier = 'hidden' | 'normal' | 'common' | 'important';
59
- /** One child entry in a branch's -h listing. `desc`/`useWhen` are the shortform
60
- * copy shown there; `tier` governs promotion into ancestor listings. */
61
- export interface BranchChild {
59
+ /** A child's assembled parent-level listing entry computed by defineBranch
60
+ * from each child def's own self-description (`description`/`whenToUse`/`tier`).
61
+ * renderBranch consumes this; it is never authored by hand and there is no
62
+ * parent-side copy of a child's description (principle 16: each node owns its
63
+ * representation one level up). */
64
+ export interface ListingChild {
62
65
  name: string;
63
- desc: string;
64
- useWhen: string;
65
- /** Visibility tier in ancestor listings (see SubTier). Default 'normal'. */
66
- tier?: SubTier;
67
- /** Computed at define time (defineBranch): how many non-hidden subcommands
68
- * this child itself owns. Drives the "[+N subcommands]" affordance shown when
69
- * a branch child is listed without expanding its own subcommands. Absent for
70
- * leaves and childless branches. Do not author by hand. */
66
+ /** Short description for this child's <subcommand> row. */
67
+ description: string;
68
+ /** Selection rubric plainly states when to reach for this command. Expansive
69
+ * with a variety of examples for judgment-heavy commands; concise for
70
+ * genuinely single-purpose ones. Rendered verbatim (no prefix). */
71
+ whenToUse: string;
72
+ /** Visibility tier in ancestor listings (see SubTier). 'hidden' children are
73
+ * dropped from every listing. */
74
+ tier: SubTier;
75
+ /** How many non-hidden subcommands this child itself owns — drives the
76
+ * `subcommands="N"` attribute when a branch child is listed without
77
+ * expansion. Absent for leaves and childless branches. */
71
78
  subCount?: number;
72
79
  }
73
80
  /** A subtree's self-description at the parent (root) level. Each subtree owns
@@ -126,14 +133,20 @@ export interface RootCommand {
126
133
  }
127
134
  export interface BranchHelp {
128
135
  name: string;
136
+ /** The command's own description — rendered as the `description` attribute of
137
+ * its <command> card at its own -h. */
129
138
  summary: string;
130
- /** Local lifecycle/model line that extends the parent definition. */
139
+ /** Local model prose orienting the agent to what the subtree contains and how
140
+ * the children differ as a group — never a per-child restatement (each
141
+ * child's purpose lives in its own listing row). */
131
142
  model?: string;
132
143
  /** Bounded runtime aggregate as a complete self-named state element (build
133
144
  * it with stateBlock), e.g. `<skills count="42">…</skills>`. Renderer
134
145
  * soft-fails to omission if this returns null or throws. */
135
146
  dynamicState?: () => string | null;
136
- children: BranchChild[];
147
+ /** Parent-level listing assembled by defineBranch from the actual child defs.
148
+ * renderBranch reads this; never author it by hand. */
149
+ listing?: ListingChild[];
137
150
  }
138
151
  export interface LeafHelp {
139
152
  name: string;
package/dist/core/help.js CHANGED
@@ -50,10 +50,13 @@ const IO_CONTRACT = 'I/O contract: flags and positional args on input; stdout is
50
50
  'Exit 0 on success, non-zero on failure. Schemas appear at leaf -h.';
51
51
  // Behavioral instruction (not a schema) — engrained in the appended system
52
52
  // prompt so the model treats unfamiliar capabilities as a cue to discover the
53
- // contract, never to guess. Lives in the root guide, outside any leaf -h.
54
- const CAPABILITY_DISCOVERY = "If the user mentions or implies a crtr capability you don't fully understand, " +
55
- 'do not guess or assume it is unsupported run `-h` on the relevant command ' +
56
- '(append it anywhere along the path) to read the contract before acting.';
53
+ // contract, never to guess, AND reads a command's contract before invoking it.
54
+ // Lives in the root guide, outside any leaf -h.
55
+ const CAPABILITY_DISCOVERY = 'Before running a crtr command whose exact contract (args, flags, effects) ' +
56
+ "you haven't verified this session, run `-h` on it and read the schema first " +
57
+ '— a reliable read beats a guess that wastes a turn or triggers an unintended ' +
58
+ "effect. Same when the user names a capability you don't fully recognize: " +
59
+ '`-h` it before acting.';
57
60
  /** Lines for a command's subcommand affordance at root: any promoted
58
61
  * (common/important) subcommands, then a remainder line naming how many other
59
62
  * subcommands exist behind `crtr <name> -h`. Returns [] when the command has
@@ -109,13 +112,18 @@ export function renderRoot(h) {
109
112
  lines.push('</command>');
110
113
  lines.push('');
111
114
  }
112
- // Globals block (footer)
113
- lines.push('Globals');
114
- const gNameW = maxLen(h.globals.map((g) => g.name));
115
- for (const g of h.globals) {
116
- lines.push(` ${pad(g.name, gNameW)} ${g.desc}`);
115
+ // Globals block (footer) — rendered only when globals exist, so an empty
116
+ // list never leaves a bare "Globals" header. -h itself is not a global: the
117
+ // capability-discovery rule below teaches -h usage with its reasoning, so no
118
+ // per-command CTA or standalone "-h: print help" stub is needed.
119
+ if (h.globals.length > 0) {
120
+ lines.push('Globals');
121
+ const gNameW = maxLen(h.globals.map((g) => g.name));
122
+ for (const g of h.globals) {
123
+ lines.push(` ${pad(g.name, gNameW)} ${g.desc}`);
124
+ }
125
+ lines.push('');
117
126
  }
118
- lines.push('');
119
127
  lines.push(IO_CONTRACT);
120
128
  lines.push('');
121
129
  lines.push(CAPABILITY_DISCOVERY);
@@ -124,38 +132,37 @@ export function renderRoot(h) {
124
132
  // ---------------------------------------------------------------------------
125
133
  // renderBranch
126
134
  // ---------------------------------------------------------------------------
135
+ /** Escape a value for a rendered XML attribute. Output is light XML around
136
+ * markdown read as prose by a model, not parsed — so we only guard the
137
+ * double-quote that would visually break the attribute, swapping it for a
138
+ * single quote rather than emitting noisy entities. */
139
+ function attr(s) {
140
+ return s.replace(/"/g, "'");
141
+ }
127
142
  export function renderBranch(h) {
128
143
  const lines = [];
129
- lines.push(`${h.name}: ${h.summary}.`);
130
- // Dynamic content leads the live aggregate (e.g. the <skills> catalog)
131
- // renders right after the name, before the hardcoded model prose, so current
132
- // state is read first. The subtree authors the whole element, so the same
133
- // self-named block appears identically at root and at `skill -h`.
144
+ // The branch renders as one <command> card: its own description in the
145
+ // opening attribute, then orientation prose / live state, then one
146
+ // self-closing <subcommand> per child. Each child's description + whenToUse
147
+ // are assembled by defineBranch from the child's own self-description, so the
148
+ // parent never restates what a child is the child owns its representation.
149
+ lines.push(`<command name="${h.name}" description="${attr(h.summary)}">`);
134
150
  const branchState = evalDynamic(h.dynamicState);
135
- if (branchState !== null) {
136
- // dynamicState returns a complete self-named element — emit as-is.
137
- lines.push('');
151
+ if (branchState !== null)
138
152
  lines.push(branchState);
139
- }
140
- if (h.model !== undefined) {
141
- lines.push('');
153
+ if (h.model !== undefined)
142
154
  lines.push(h.model);
143
- }
144
- lines.push('');
145
- lines.push('Branches');
146
- // 'hidden' children never appear in any listing drop them here.
147
- const visible = h.children.filter((c) => (c.tier ?? 'normal') !== 'hidden');
148
- const nameW = maxLen(visible.map((c) => c.name));
149
- const descW = maxLen(visible.map((c) => c.desc));
150
- for (const c of visible) {
151
- let line = ` ${pad(c.name, nameW)} ${pad(c.desc, descW)} | use when ${c.useWhen}`;
152
- // A branch child is listed without its own subcommands expanded — flag how
153
- // many it has so the agent knows there is more depth behind `<child> -h`.
154
- if (c.subCount !== undefined && c.subCount > 0) {
155
- line += ` [+${c.subCount} subcommand${c.subCount === 1 ? '' : 's'}]`;
156
- }
157
- lines.push(line);
158
- }
155
+ for (const c of h.listing ?? []) {
156
+ if (c.tier === 'hidden')
157
+ continue;
158
+ const subs = c.subCount !== undefined && c.subCount > 0 ? ` subcommands="${c.subCount}"` : '';
159
+ // whenToUse plainly states when to reach for this child, rendered verbatim —
160
+ // expansive with examples for judgment-heavy commands, concise for
161
+ // single-purpose ones. It does not restate "read my -h"; the
162
+ // capability-discovery rule in the root footer already teaches that.
163
+ lines.push(`<subcommand name="${c.name}" description="${attr(c.description)}" whenToUse="${attr(c.whenToUse)}"${subs}/>`);
164
+ }
165
+ lines.push('</command>');
159
166
  return lines.join('\n');
160
167
  }
161
168
  // ---------------------------------------------------------------------------
@@ -6,7 +6,7 @@
6
6
  * - resolve (high-level composer)
7
7
  * - ResolvedPersona (return type of resolve)
8
8
  */
9
- export { loadPersona, loadKernel, availableKinds } from './loader.js';
9
+ export { loadPersona, loadKernel, availableKinds, loadLifecycleFragment, loadSpineFragment } from './loader.js';
10
10
  export type { LoadedPersona } from './loader.js';
11
11
  export { resolve } from './resolve.js';
12
12
  export type { ResolvedPersona } from './resolve.js';
@@ -6,5 +6,5 @@
6
6
  * - resolve (high-level composer)
7
7
  * - ResolvedPersona (return type of resolve)
8
8
  */
9
- export { loadPersona, loadKernel, availableKinds } from './loader.js';
9
+ export { loadPersona, loadKernel, availableKinds, loadLifecycleFragment, loadSpineFragment } from './loader.js';
10
10
  export { resolve } from './resolve.js';
@@ -33,12 +33,51 @@ export declare function loadPersona(kind: string, mode: 'base' | 'orchestrator')
33
33
  export declare function loadKernel(): string;
34
34
  /**
35
35
  * Load the base runtime prompt — the node operating protocol prepended to
36
- * EVERY persona (push/finish/delegate/feed/ask). Returns '' if not found.
36
+ * EVERY persona (delegate/ask/promote). Returns '' if not found. The
37
+ * lifecycle/spine-specific sections (finish vs. dormant, report-up vs. silent)
38
+ * live in their own fragments, loaded below.
37
39
  */
38
40
  export declare function loadRuntimeBase(): string;
41
+ /**
42
+ * Load the lifecycle fragment — the "how you end" contract, keyed on the node's
43
+ * lifecycle axis: `terminal` (drive to done + `push final`) or `resident`
44
+ * (dormant/wake, never forced to submit). Single source for both the baked-in
45
+ * system prompt (resolve) and the transition guidance (runtime/persona.ts).
46
+ * Returns '' if the fragment file cannot be found.
47
+ */
48
+ export declare function loadLifecycleFragment(lifecycle: 'terminal' | 'resident'): string;
49
+ /**
50
+ * Load the spine fragment — the "who you report to" contract, keyed on whether
51
+ * the node has a manager (anyone it reports up to). `has-manager` teaches the
52
+ * `push update`/`push urgent`/escalate verbs; `no-manager` (a top-of-spine root)
53
+ * omits the push family entirely — it answers to the human directly.
54
+ * Returns '' if the fragment file cannot be found.
55
+ */
56
+ export declare function loadSpineFragment(hasManager: boolean): string;
39
57
  /**
40
58
  * Enumerate the kinds with at least one persona file (base.md or
41
59
  * orchestrator.md) across all scope roots (project/user/builtin). Used to
42
60
  * validate a requested `--kind` and to list the valid choices.
43
61
  */
44
62
  export declare function availableKinds(): string[];
63
+ export interface SubKind {
64
+ /** Full kind string to spawn, e.g. 'plan/reviewers/security'. */
65
+ kind: string;
66
+ /** Leaf name, e.g. 'security'. */
67
+ name: string;
68
+ /** One-line "what it reviews", from the sub-kind base.md `summary` frontmatter (or ''). */
69
+ summary: string;
70
+ }
71
+ /**
72
+ * Enumerate the reviewer sub-kinds owned by `parentKind` — the specialist
73
+ * personas at `<root>/<parentKind>/reviewers/<name>/base.md`, scanned across all
74
+ * scope roots (project > user > builtin; highest precedence wins per name).
75
+ *
76
+ * Sub-kinds are intentionally NOT global kinds: `availableKinds()` scans only the
77
+ * immediate children of each persona root, so `<parentKind>/reviewers/*` never
78
+ * leaks into the global list. A sub-kind is reachable only by its full kind
79
+ * string and is surfaced only in its parent kind's composed prompt (resolve.ts).
80
+ * Kind-parametric: any kind owns a roster simply by adding
81
+ * `<kind>/reviewers/<name>/base.md` — no code change.
82
+ */
83
+ export declare function subKindsFor(parentKind: string): SubKind[];
@@ -124,7 +124,9 @@ export function loadKernel() {
124
124
  }
125
125
  /**
126
126
  * Load the base runtime prompt — the node operating protocol prepended to
127
- * EVERY persona (push/finish/delegate/feed/ask). Returns '' if not found.
127
+ * EVERY persona (delegate/ask/promote). Returns '' if not found. The
128
+ * lifecycle/spine-specific sections (finish vs. dormant, report-up vs. silent)
129
+ * live in their own fragments, loaded below.
128
130
  */
129
131
  export function loadRuntimeBase() {
130
132
  const filePath = resolveFile('runtime-base.md');
@@ -134,6 +136,34 @@ export function loadRuntimeBase() {
134
136
  const { body } = parseFrontmatterGeneric(src);
135
137
  return body.trim();
136
138
  }
139
+ /**
140
+ * Load the lifecycle fragment — the "how you end" contract, keyed on the node's
141
+ * lifecycle axis: `terminal` (drive to done + `push final`) or `resident`
142
+ * (dormant/wake, never forced to submit). Single source for both the baked-in
143
+ * system prompt (resolve) and the transition guidance (runtime/persona.ts).
144
+ * Returns '' if the fragment file cannot be found.
145
+ */
146
+ export function loadLifecycleFragment(lifecycle) {
147
+ const filePath = resolveFile(`lifecycle/${lifecycle}.md`);
148
+ if (!filePath)
149
+ return '';
150
+ const { body } = parseFrontmatterGeneric(readFileSync(filePath, 'utf8'));
151
+ return body.trim();
152
+ }
153
+ /**
154
+ * Load the spine fragment — the "who you report to" contract, keyed on whether
155
+ * the node has a manager (anyone it reports up to). `has-manager` teaches the
156
+ * `push update`/`push urgent`/escalate verbs; `no-manager` (a top-of-spine root)
157
+ * omits the push family entirely — it answers to the human directly.
158
+ * Returns '' if the fragment file cannot be found.
159
+ */
160
+ export function loadSpineFragment(hasManager) {
161
+ const filePath = resolveFile(`spine/${hasManager ? 'has-manager' : 'no-manager'}.md`);
162
+ if (!filePath)
163
+ return '';
164
+ const { body } = parseFrontmatterGeneric(readFileSync(filePath, 'utf8'));
165
+ return body.trim();
166
+ }
137
167
  /**
138
168
  * Enumerate the kinds with at least one persona file (base.md or
139
169
  * orchestrator.md) across all scope roots (project/user/builtin). Used to
@@ -155,3 +185,35 @@ export function availableKinds() {
155
185
  }
156
186
  return [...kinds].sort();
157
187
  }
188
+ const REVIEWERS_SUBDIR = 'reviewers';
189
+ /**
190
+ * Enumerate the reviewer sub-kinds owned by `parentKind` — the specialist
191
+ * personas at `<root>/<parentKind>/reviewers/<name>/base.md`, scanned across all
192
+ * scope roots (project > user > builtin; highest precedence wins per name).
193
+ *
194
+ * Sub-kinds are intentionally NOT global kinds: `availableKinds()` scans only the
195
+ * immediate children of each persona root, so `<parentKind>/reviewers/*` never
196
+ * leaks into the global list. A sub-kind is reachable only by its full kind
197
+ * string and is surfaced only in its parent kind's composed prompt (resolve.ts).
198
+ * Kind-parametric: any kind owns a roster simply by adding
199
+ * `<kind>/reviewers/<name>/base.md` — no code change.
200
+ */
201
+ export function subKindsFor(parentKind) {
202
+ const byName = new Map();
203
+ for (const root of personaSearchRoots()) {
204
+ const dir = join(root, parentKind, REVIEWERS_SUBDIR);
205
+ if (!existsSync(dir))
206
+ continue;
207
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
208
+ if (!entry.isDirectory() || byName.has(entry.name))
209
+ continue; // higher root already won
210
+ const baseFile = join(dir, entry.name, 'base.md');
211
+ if (!existsSync(baseFile))
212
+ continue;
213
+ const { data } = parseFrontmatterGeneric(readFileSync(baseFile, 'utf8'));
214
+ const summary = data && typeof data['summary'] === 'string' ? data['summary'] : '';
215
+ byName.set(entry.name, { kind: `${parentKind}/${REVIEWERS_SUBDIR}/${entry.name}`, name: entry.name, summary });
216
+ }
217
+ }
218
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
219
+ }
@@ -14,11 +14,10 @@
14
14
  * If even the base is missing, fall back to general defaults + kernel.
15
15
  *
16
16
  * Frontmatter from whichever file is the primary source (orchestrator.md >
17
- * base.md) supplies model/lifecycle/skills/extensions/tools.
18
- *
19
- * Lifecycle defaults:
20
- * base → 'terminal'
21
- * orchestrator → 'resident'
17
+ * base.md) supplies model/skills/extensions/tools. Lifecycle and spine position
18
+ * are INPUTS (the caller decides them — root/child, terminal/resident), not
19
+ * derived here; they select the lifecycle/spine protocol fragments spliced
20
+ * ahead of the persona body.
22
21
  */
23
22
  export interface ResolvedPersona {
24
23
  systemPrompt: string;
@@ -33,4 +32,12 @@ export interface ResolvedPersona {
33
32
  *
34
33
  * Never throws for missing files — missing personas produce sensible defaults.
35
34
  */
36
- export declare function resolve(kind: string, mode: 'base' | 'orchestrator'): ResolvedPersona;
35
+ export interface ResolveOpts {
36
+ /** The node's lifecycle axis — selects the "how you end" fragment. */
37
+ lifecycle: 'terminal' | 'resident';
38
+ /** Whether the node reports up to a manager (parent !== null) — selects the
39
+ * spine fragment (`has-manager` teaches the push family; `no-manager` omits
40
+ * it entirely). */
41
+ hasManager: boolean;
42
+ }
43
+ export declare function resolve(kind: string, mode: 'base' | 'orchestrator', opts: ResolveOpts): ResolvedPersona;
@@ -14,13 +14,12 @@
14
14
  * If even the base is missing, fall back to general defaults + kernel.
15
15
  *
16
16
  * Frontmatter from whichever file is the primary source (orchestrator.md >
17
- * base.md) supplies model/lifecycle/skills/extensions/tools.
18
- *
19
- * Lifecycle defaults:
20
- * base → 'terminal'
21
- * orchestrator → 'resident'
17
+ * base.md) supplies model/skills/extensions/tools. Lifecycle and spine position
18
+ * are INPUTS (the caller decides them — root/child, terminal/resident), not
19
+ * derived here; they select the lifecycle/spine protocol fragments spliced
20
+ * ahead of the persona body.
22
21
  */
23
- import { loadPersona, loadKernel, loadRuntimeBase } from './loader.js';
22
+ import { loadPersona, loadKernel, loadRuntimeBase, loadSpineFragment, loadLifecycleFragment, subKindsFor } from './loader.js';
24
23
  // ---------------------------------------------------------------------------
25
24
  // Helpers
26
25
  // ---------------------------------------------------------------------------
@@ -32,48 +31,62 @@ function toStringArray(v) {
32
31
  function toOptionalString(v) {
33
32
  return typeof v === 'string' ? v : undefined;
34
33
  }
35
- function toLifecycle(v, defaultValue) {
36
- if (v === 'terminal' || v === 'resident')
37
- return v;
38
- return defaultValue;
39
- }
40
34
  /** The bare-minimum system prompt used when no persona file is found at all. */
41
35
  function fallbackBasePrompt(kind) {
42
36
  return `You are a ${kind} agent. Complete the task you have been given.`;
43
37
  }
44
- /** Prepend the base runtime protocol (push/finish/delegate/feed/ask) to a
45
- * persona's prompt — every node, every kind, every mode gets it first. */
46
- function withBase(personaPrompt) {
47
- const base = loadRuntimeBase();
48
- return base ? `${base}\n\n---\n\n${personaPrompt}` : personaPrompt;
38
+ /** Compose the runtime protocol that precedes every persona body: the
39
+ * lifecycle-neutral base (identity/delegate/ask/promote), then the spine
40
+ * fragment (report-up vs. silent, keyed on whether the node has a manager),
41
+ * then the lifecycle fragment (finish-with-`push final` vs. dormant/wake). The
42
+ * kind×mode persona body follows after a rule. Empty fragments drop out. */
43
+ /** Render the "sub-kinds you may spawn" menu for a kind that owns a roster.
44
+ * Returns '' when the kind owns none. Data-driven: one line per sub-kind, its
45
+ * spawn string + its `summary`. Adding a roster file makes it appear here. */
46
+ function renderSubKindMenu(kind) {
47
+ const subs = subKindsFor(kind);
48
+ if (subs.length === 0)
49
+ return '';
50
+ const lines = subs.map((s) => `- \`${s.kind}\` — ${s.summary}`);
51
+ return [
52
+ '## Reviewer sub-kinds you may spawn',
53
+ '',
54
+ `These specialist reviewers exist only in the ${kind} kind's world — no other kind sees them. Spawn one with \`crtr node new --kind <sub-kind> "<scope>"\`, giving it only its scope, never your suspicions: a reviewer handed a hint anchors on it instead of finding problems independently.`,
55
+ '',
56
+ ...lines,
57
+ ].join('\n');
49
58
  }
50
- // ---------------------------------------------------------------------------
51
- // Public API
52
- // ---------------------------------------------------------------------------
53
- /**
54
- * Resolve a fully composed persona for the given `kind` and `mode`.
55
- *
56
- * Never throws for missing files — missing personas produce sensible defaults.
57
- */
58
- export function resolve(kind, mode) {
59
+ function composeProtocol(personaPrompt, kind, lifecycle, hasManager) {
60
+ const menu = renderSubKindMenu(kind);
61
+ const body = menu ? `${personaPrompt}\n\n${menu}` : personaPrompt;
62
+ const protocol = [
63
+ loadRuntimeBase(),
64
+ loadSpineFragment(hasManager),
65
+ loadLifecycleFragment(lifecycle),
66
+ ]
67
+ .filter((s) => s.length > 0)
68
+ .join('\n\n');
69
+ return protocol ? `${protocol}\n\n---\n\n${body}` : body;
70
+ }
71
+ export function resolve(kind, mode, opts) {
59
72
  if (mode === 'base') {
60
73
  const persona = loadPersona(kind, 'base');
61
74
  if (!persona) {
62
75
  // No persona file for this kind — use minimal defaults.
63
76
  return {
64
- systemPrompt: withBase(fallbackBasePrompt(kind)),
77
+ systemPrompt: composeProtocol(fallbackBasePrompt(kind), kind, opts.lifecycle, opts.hasManager),
65
78
  extensions: [],
66
79
  skills: [],
67
- lifecycle: 'terminal',
80
+ lifecycle: opts.lifecycle,
68
81
  };
69
82
  }
70
83
  const fm = persona.frontmatter ?? {};
71
84
  return {
72
- systemPrompt: withBase(persona.body || fallbackBasePrompt(kind)),
85
+ systemPrompt: composeProtocol(persona.body || fallbackBasePrompt(kind), kind, opts.lifecycle, opts.hasManager),
73
86
  extensions: toStringArray(fm['extensions']),
74
87
  skills: toStringArray(fm['skills']),
75
88
  model: toOptionalString(fm['model']),
76
- lifecycle: toLifecycle(fm['lifecycle'], 'terminal'),
89
+ lifecycle: opts.lifecycle,
77
90
  tools: fm['tools'] !== undefined ? toStringArray(fm['tools']) : undefined,
78
91
  };
79
92
  }
@@ -83,11 +96,11 @@ export function resolve(kind, mode) {
83
96
  // Orchestrator file exists; @include was already inlined by the loader.
84
97
  const fm = orchestratorPersona.frontmatter ?? {};
85
98
  return {
86
- systemPrompt: withBase(orchestratorPersona.body || fallbackBasePrompt(kind)),
99
+ systemPrompt: composeProtocol(orchestratorPersona.body || fallbackBasePrompt(kind), kind, opts.lifecycle, opts.hasManager),
87
100
  extensions: toStringArray(fm['extensions']),
88
101
  skills: toStringArray(fm['skills']),
89
102
  model: toOptionalString(fm['model']),
90
- lifecycle: toLifecycle(fm['lifecycle'], 'resident'),
103
+ lifecycle: opts.lifecycle,
91
104
  tools: fm['tools'] !== undefined ? toStringArray(fm['tools']) : undefined,
92
105
  };
93
106
  }
@@ -99,12 +112,11 @@ export function resolve(kind, mode) {
99
112
  // Append the kernel to the base body (with separator if kernel is non-empty).
100
113
  const systemPrompt = kernel ? `${baseBody}\n\n${kernel}` : baseBody;
101
114
  return {
102
- systemPrompt: withBase(systemPrompt),
115
+ systemPrompt: composeProtocol(systemPrompt, kind, opts.lifecycle, opts.hasManager),
103
116
  extensions: toStringArray(fm['extensions']),
104
117
  skills: toStringArray(fm['skills']),
105
118
  model: toOptionalString(fm['model']),
106
- // Override lifecycle to 'resident' — this node is being used as an orchestrator.
107
- lifecycle: 'resident',
119
+ lifecycle: opts.lifecycle,
108
120
  tools: fm['tools'] !== undefined ? toStringArray(fm['tools']) : undefined,
109
121
  };
110
122
  }
@@ -0,0 +1,20 @@
1
+ /** Base framing — present for every node. No path baked in: the caller carries
2
+ * the dir in the <crtr-context dir="…"> attribute. */
3
+ export declare const BASE_CONTEXT_NOTE: string;
4
+ /** Orchestrator-only framing: a resident orchestrator survives refresh cycles,
5
+ * so its context dir is also where a future cycle of itself resumes the work.
6
+ * Used inside the bearings block AND in the promotion guidance dump, so a
7
+ * promoted node gets the same note a born-orchestrator gets. */
8
+ export declare function orchestratorContextNote(nodeId: string): string;
9
+ /** The <memory> block (orchestrators only): the scoped stores merged, each a
10
+ * `label · dir` header over its live index pointer lines. A memory's `type`
11
+ * decides which store it lands in — the mapping + the how-to live once in the
12
+ * orchestration kernel ("Your long-term memory"); here we carry only the live
13
+ * data + a one-line pointer back to it. user-global rides in when the node has
14
+ * a user store, project when it has a project store, node-local always (the
15
+ * orchestrator gate). */
16
+ export declare function buildMemoryBlock(nodeId: string, cwd: string): string;
17
+ /** The full <crtr-context> bearings block: base framing always, plus the
18
+ * orchestrator addendum + the merged three-store <memory> block when the node
19
+ * has a node-local memory store (the orchestrator gate). */
20
+ export declare function buildContextBearings(nodeId: string): string;