@crouton-kit/crouter 0.3.13 → 0.3.15

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 (233) 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/__tests__/human.test.js +73 -2
  35. package/dist/commands/attention.js +76 -7
  36. package/dist/commands/canvas-prune.d.ts +2 -0
  37. package/dist/commands/canvas-prune.js +66 -0
  38. package/dist/commands/canvas.js +5 -8
  39. package/dist/commands/chord.d.ts +2 -0
  40. package/dist/commands/chord.js +143 -0
  41. package/dist/commands/daemon.js +8 -5
  42. package/dist/commands/dashboard.js +2 -0
  43. package/dist/commands/human/prompts.js +28 -27
  44. package/dist/commands/human/queue.d.ts +1 -0
  45. package/dist/commands/human/queue.js +105 -2
  46. package/dist/commands/human/shared.d.ts +28 -18
  47. package/dist/commands/human/shared.js +53 -60
  48. package/dist/commands/human.js +6 -14
  49. package/dist/commands/node.d.ts +11 -0
  50. package/dist/commands/node.js +381 -87
  51. package/dist/commands/pkg/market-inspect.js +6 -4
  52. package/dist/commands/pkg/market-manage.js +10 -6
  53. package/dist/commands/pkg/market.js +2 -4
  54. package/dist/commands/pkg/plugin-inspect.js +6 -4
  55. package/dist/commands/pkg/plugin-manage.js +12 -7
  56. package/dist/commands/pkg/plugin.js +2 -4
  57. package/dist/commands/pkg.js +0 -4
  58. package/dist/commands/push.js +178 -15
  59. package/dist/commands/revive.js +5 -3
  60. package/dist/commands/skill/author.js +6 -4
  61. package/dist/commands/skill/find.js +8 -5
  62. package/dist/commands/skill/read.js +2 -0
  63. package/dist/commands/skill/state.js +6 -4
  64. package/dist/commands/skill.js +0 -6
  65. package/dist/commands/sys/config.js +21 -7
  66. package/dist/commands/sys/doctor.js +2 -0
  67. package/dist/commands/sys/update.js +4 -0
  68. package/dist/commands/sys.js +0 -6
  69. package/dist/commands/tmux-spread.d.ts +2 -0
  70. package/dist/commands/tmux-spread.js +130 -0
  71. package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
  72. package/dist/core/__tests__/child-followup.test.d.ts +1 -0
  73. package/dist/core/__tests__/child-followup.test.js +83 -0
  74. package/dist/core/__tests__/close.test.d.ts +1 -0
  75. package/dist/core/__tests__/close.test.js +148 -0
  76. package/dist/core/__tests__/context-intro.test.d.ts +1 -0
  77. package/dist/core/__tests__/context-intro.test.js +196 -0
  78. package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
  79. package/dist/core/__tests__/daemon-boot.test.js +93 -0
  80. package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
  81. package/dist/core/__tests__/daemon-liveness.test.js +223 -0
  82. package/dist/core/__tests__/focuses.test.d.ts +1 -0
  83. package/dist/core/__tests__/focuses.test.js +259 -0
  84. package/dist/core/__tests__/fork.test.d.ts +1 -0
  85. package/dist/core/__tests__/fork.test.js +91 -0
  86. package/dist/core/__tests__/home-session.test.d.ts +1 -0
  87. package/dist/core/__tests__/home-session.test.js +153 -0
  88. package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
  89. package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
  90. package/dist/core/__tests__/keystone.test.d.ts +1 -0
  91. package/dist/core/__tests__/keystone.test.js +185 -0
  92. package/dist/core/__tests__/kickoff.test.d.ts +1 -0
  93. package/dist/core/__tests__/kickoff.test.js +89 -0
  94. package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
  95. package/dist/core/__tests__/lifecycle.test.js +178 -0
  96. package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
  97. package/dist/core/__tests__/listing-completeness.test.js +31 -0
  98. package/dist/core/__tests__/memory.test.d.ts +1 -0
  99. package/dist/core/__tests__/memory.test.js +152 -0
  100. package/dist/core/__tests__/migration.test.d.ts +1 -0
  101. package/dist/core/__tests__/migration.test.js +238 -0
  102. package/dist/core/__tests__/pane-column.test.d.ts +1 -0
  103. package/dist/core/__tests__/pane-column.test.js +153 -0
  104. package/dist/core/__tests__/passive-subscription.test.d.ts +1 -0
  105. package/dist/core/__tests__/passive-subscription.test.js +164 -0
  106. package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
  107. package/dist/core/__tests__/persona-compose.test.js +53 -0
  108. package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
  109. package/dist/core/__tests__/persona-subkind.test.js +62 -0
  110. package/dist/core/__tests__/persona.test.d.ts +1 -0
  111. package/dist/core/__tests__/persona.test.js +107 -0
  112. package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
  113. package/dist/core/__tests__/placement-focus.test.js +244 -0
  114. package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
  115. package/dist/core/__tests__/placement-reconcile.test.js +212 -0
  116. package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
  117. package/dist/core/__tests__/placement-revive.test.js +238 -0
  118. package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
  119. package/dist/core/__tests__/placement-teardown.test.js +183 -0
  120. package/dist/core/__tests__/prune.test.d.ts +1 -0
  121. package/dist/core/__tests__/prune.test.js +116 -0
  122. package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
  123. package/dist/core/__tests__/push-final-guard.test.js +71 -0
  124. package/dist/core/__tests__/relaunch.test.d.ts +1 -0
  125. package/dist/core/__tests__/relaunch.test.js +328 -0
  126. package/dist/core/__tests__/reset.test.js +26 -7
  127. package/dist/core/__tests__/revive.test.d.ts +1 -0
  128. package/dist/core/__tests__/revive.test.js +217 -0
  129. package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
  130. package/dist/core/__tests__/spawn-root.test.js +73 -0
  131. package/dist/core/__tests__/steer-note.test.d.ts +1 -0
  132. package/dist/core/__tests__/steer-note.test.js +39 -0
  133. package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
  134. package/dist/core/__tests__/stop-guard.test.js +82 -0
  135. package/dist/core/__tests__/subcommand-tier.test.d.ts +1 -0
  136. package/dist/core/__tests__/subcommand-tier.test.js +99 -0
  137. package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
  138. package/dist/core/__tests__/tmux-surface.test.js +106 -0
  139. package/dist/core/__tests__/unknown-path.test.js +8 -2
  140. package/dist/core/canvas/attention.d.ts +10 -0
  141. package/dist/core/canvas/attention.js +40 -0
  142. package/dist/core/canvas/canvas.d.ts +66 -7
  143. package/dist/core/canvas/canvas.js +209 -21
  144. package/dist/core/canvas/db.d.ts +8 -0
  145. package/dist/core/canvas/db.js +206 -4
  146. package/dist/core/canvas/focuses.d.ts +22 -0
  147. package/dist/core/canvas/focuses.js +80 -0
  148. package/dist/core/canvas/index.d.ts +3 -0
  149. package/dist/core/canvas/index.js +3 -0
  150. package/dist/core/canvas/labels.d.ts +27 -0
  151. package/dist/core/canvas/labels.js +36 -0
  152. package/dist/core/canvas/paths.d.ts +4 -0
  153. package/dist/core/canvas/paths.js +6 -0
  154. package/dist/core/canvas/render.js +25 -10
  155. package/dist/core/canvas/telemetry.d.ts +14 -0
  156. package/dist/core/canvas/telemetry.js +35 -0
  157. package/dist/core/canvas/types.d.ts +115 -12
  158. package/dist/core/command.d.ts +25 -1
  159. package/dist/core/command.js +48 -7
  160. package/dist/core/config.js +36 -2
  161. package/dist/core/feed/feed.js +14 -12
  162. package/dist/core/feed/inbox.d.ts +3 -1
  163. package/dist/core/feed/inbox.js +45 -5
  164. package/dist/core/feed/passive.d.ts +17 -0
  165. package/dist/core/feed/passive.js +92 -0
  166. package/dist/core/help.d.ts +59 -13
  167. package/dist/core/help.js +73 -28
  168. package/dist/core/personas/index.d.ts +1 -1
  169. package/dist/core/personas/index.js +1 -1
  170. package/dist/core/personas/loader.d.ts +40 -1
  171. package/dist/core/personas/loader.js +63 -1
  172. package/dist/core/personas/resolve.d.ts +13 -6
  173. package/dist/core/personas/resolve.js +46 -34
  174. package/dist/core/runtime/bearings.d.ts +20 -0
  175. package/dist/core/runtime/bearings.js +92 -0
  176. package/dist/core/runtime/close.d.ts +14 -0
  177. package/dist/core/runtime/close.js +151 -0
  178. package/dist/core/runtime/demote.d.ts +14 -0
  179. package/dist/core/runtime/demote.js +120 -0
  180. package/dist/core/runtime/front-door.js +1 -1
  181. package/dist/core/runtime/kickoff.d.ts +32 -6
  182. package/dist/core/runtime/kickoff.js +111 -37
  183. package/dist/core/runtime/launch.d.ts +29 -6
  184. package/dist/core/runtime/launch.js +85 -13
  185. package/dist/core/runtime/lifecycle.d.ts +13 -0
  186. package/dist/core/runtime/lifecycle.js +86 -0
  187. package/dist/core/runtime/memory.d.ts +43 -0
  188. package/dist/core/runtime/memory.js +165 -0
  189. package/dist/core/runtime/naming.d.ts +22 -0
  190. package/dist/core/runtime/naming.js +166 -0
  191. package/dist/core/runtime/nodes.d.ts +32 -1
  192. package/dist/core/runtime/nodes.js +60 -10
  193. package/dist/core/runtime/persona.d.ts +25 -0
  194. package/dist/core/runtime/persona.js +139 -0
  195. package/dist/core/runtime/placement.d.ts +287 -0
  196. package/dist/core/runtime/placement.js +663 -0
  197. package/dist/core/runtime/presence.d.ts +7 -32
  198. package/dist/core/runtime/presence.js +90 -110
  199. package/dist/core/runtime/promote.d.ts +18 -7
  200. package/dist/core/runtime/promote.js +70 -65
  201. package/dist/core/runtime/reset.d.ts +47 -4
  202. package/dist/core/runtime/reset.js +223 -52
  203. package/dist/core/runtime/revive.d.ts +26 -2
  204. package/dist/core/runtime/revive.js +166 -39
  205. package/dist/core/runtime/roadmap.d.ts +5 -4
  206. package/dist/core/runtime/roadmap.js +9 -16
  207. package/dist/core/runtime/spawn.d.ts +20 -5
  208. package/dist/core/runtime/spawn.js +169 -44
  209. package/dist/core/runtime/stop-guard.d.ts +1 -1
  210. package/dist/core/runtime/stop-guard.js +18 -8
  211. package/dist/core/runtime/tmux.d.ts +106 -21
  212. package/dist/core/runtime/tmux.js +249 -45
  213. package/dist/core/spawn.js +15 -0
  214. package/dist/daemon/crtrd.d.ts +12 -1
  215. package/dist/daemon/crtrd.js +152 -34
  216. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
  217. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
  218. package/dist/pi-extensions/canvas-commands.d.ts +34 -0
  219. package/dist/pi-extensions/canvas-commands.js +103 -0
  220. package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
  221. package/dist/pi-extensions/canvas-context-intro.js +164 -0
  222. package/dist/pi-extensions/canvas-goal-capture.d.ts +21 -0
  223. package/dist/pi-extensions/canvas-goal-capture.js +67 -0
  224. package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
  225. package/dist/pi-extensions/canvas-nav.d.ts +12 -4
  226. package/dist/pi-extensions/canvas-nav.js +586 -262
  227. package/dist/pi-extensions/canvas-passive-context.d.ts +32 -0
  228. package/dist/pi-extensions/canvas-passive-context.js +114 -0
  229. package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
  230. package/dist/pi-extensions/canvas-stophook.js +344 -228
  231. package/dist/types.d.ts +28 -0
  232. package/dist/types.js +16 -0
  233. package/package.json +1 -1
@@ -9,8 +9,10 @@
9
9
  // file-system friendliness and lexicographic sort alignment.
10
10
  import { writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
- import { reportsDir, subscribersOf, setStatus, updateNode, } from '../canvas/index.js';
12
+ import { reportsDir, subscribersOf, } from '../canvas/index.js';
13
+ import { transition } from '../runtime/lifecycle.js';
13
14
  import { appendInbox } from './inbox.js';
15
+ import { appendPassive } from './passive.js';
14
16
  // ---------------------------------------------------------------------------
15
17
  // Internal helpers
16
18
  // ---------------------------------------------------------------------------
@@ -74,25 +76,25 @@ export async function push(nodeId, opts) {
74
76
  const ts = compactTs(now);
75
77
  // (a) Write the report.
76
78
  const reportPath = writeReport(nodeId, kind, ts, body);
77
- // (b) Fan out inbox pointers to every subscriber (active and passive both
78
- // receive the pointer; the daemon decides whether to wake active ones).
79
+ // (b) Fan out a pointer to every subscriber. Active subscribers get it on
80
+ // inbox.jsonl (the inbox-watcher polls that a wake). Passive subscribers
81
+ // get it on passive.jsonl instead — the watcher never polls that, so they
82
+ // are NOT woken; the pointer accumulates until the node is next messaged,
83
+ // when canvas-passive-context drains it as XML pre-text.
79
84
  const subscribers = subscribersOf(nodeId);
80
85
  const deliveredTo = [];
81
86
  const label = firstLine(body);
82
87
  for (const sub of subscribers) {
83
- appendInbox(sub.node_id, {
84
- from,
85
- tier: tierFor(kind),
86
- kind,
87
- ref: reportPath,
88
- label,
89
- });
88
+ const entry = { from, tier: tierFor(kind), kind, ref: reportPath, label };
89
+ if (sub.active)
90
+ appendInbox(sub.node_id, entry);
91
+ else
92
+ appendPassive(sub.node_id, entry);
90
93
  deliveredTo.push(sub.node_id);
91
94
  }
92
95
  // (c) Finalise node when kind === 'final'.
93
96
  if (kind === 'final') {
94
- setStatus(nodeId, 'done');
95
- updateNode(nodeId, { intent: 'done' });
97
+ transition(nodeId, 'finalize');
96
98
  }
97
99
  return { reportPath, deliveredTo };
98
100
  }
@@ -41,7 +41,9 @@ export declare function writeCursor(nodeId: string, iso: string): void;
41
41
  *
42
42
  * Format (per sender group):
43
43
  * From <sender> — <N> update(s):
44
- * [<kind>] <label> (ref: <path>)
44
+ * [<kind>] <label> (ref: <path>) ← push: pointer, dereference the ref
45
+ * [<kind>] ← ref-less msg: full body inlined
46
+ * <body line>
45
47
  * …
46
48
  *
47
49
  * A header line announces the total count and instructs the receiver to
@@ -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');
@@ -0,0 +1,17 @@
1
+ import type { InboxEntry } from './inbox.js';
2
+ /**
3
+ * Atomically append one entry to `nodes/<nodeId>/passive.jsonl`.
4
+ * Fills `ts` (current ISO time). Returns the completed entry.
5
+ */
6
+ export declare function appendPassive(nodeId: string, entry: Omit<InboxEntry, 'ts'>): InboxEntry;
7
+ /** Return every accumulated passive entry (oldest first) without clearing. */
8
+ export declare function readPassive(nodeId: string): InboxEntry[];
9
+ /**
10
+ * Read AND clear the accumulator in one shot — the drain-on-message primitive.
11
+ *
12
+ * We rename the file aside before reading so a concurrent `appendPassive` (a
13
+ * publisher pushing at the same instant) starts a fresh file and is never lost
14
+ * to the truncate: at worst it lands in the next drain. The renamed snapshot is
15
+ * removed after a successful read. Returns the drained entries (oldest first).
16
+ */
17
+ export declare function drainPassive(nodeId: string): InboxEntry[];
@@ -0,0 +1,92 @@
1
+ // Per-node passive-subscription accumulator for the pi-native canvas runtime.
2
+ //
3
+ // A PASSIVE subscription (the `active=false` flavor of a subscribes_to edge)
4
+ // must never WAKE its subscriber. So when `push` fans out, a passive
5
+ // subscriber's pointer is written here — to nodes/<id>/passive.jsonl — instead
6
+ // of inbox.jsonl. The inbox-watcher polls only inbox.jsonl, so nothing here
7
+ // triggers a turn.
8
+ //
9
+ // The accumulator is drained the moment the node is next MESSAGED: the
10
+ // canvas-passive-context extension reads + clears this file on pi's `input`
11
+ // event and injects every entry as timestamped XML pre-text before the message
12
+ // reaches the LLM. Until then entries simply pile up, oldest first.
13
+ //
14
+ // Same entry shape as the inbox (InboxEntry) so the two stores stay symmetric
15
+ // and a passive edge can be flipped active without reshaping data.
16
+ import { appendFileSync, existsSync, readFileSync, renameSync, rmSync, mkdirSync, } from 'node:fs';
17
+ import { dirname } from 'node:path';
18
+ import { passivePath } from '../canvas/index.js';
19
+ /**
20
+ * Atomically append one entry to `nodes/<nodeId>/passive.jsonl`.
21
+ * Fills `ts` (current ISO time). Returns the completed entry.
22
+ */
23
+ export function appendPassive(nodeId, entry) {
24
+ const full = { ts: new Date().toISOString(), ...entry };
25
+ const line = JSON.stringify(full) + '\n';
26
+ const dir = dirname(passivePath(nodeId));
27
+ if (!existsSync(dir))
28
+ mkdirSync(dir, { recursive: true });
29
+ appendFileSync(passivePath(nodeId), line, { encoding: 'utf8', flag: 'a' });
30
+ return full;
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
+ }
52
+ /** Return every accumulated passive entry (oldest first) without clearing. */
53
+ export function readPassive(nodeId) {
54
+ const p = passivePath(nodeId);
55
+ if (!existsSync(p))
56
+ return [];
57
+ return parseEntries(readFileSync(p, 'utf8'));
58
+ }
59
+ /**
60
+ * Read AND clear the accumulator in one shot — the drain-on-message primitive.
61
+ *
62
+ * We rename the file aside before reading so a concurrent `appendPassive` (a
63
+ * publisher pushing at the same instant) starts a fresh file and is never lost
64
+ * to the truncate: at worst it lands in the next drain. The renamed snapshot is
65
+ * removed after a successful read. Returns the drained entries (oldest first).
66
+ */
67
+ export function drainPassive(nodeId) {
68
+ const p = passivePath(nodeId);
69
+ if (!existsSync(p))
70
+ return [];
71
+ const snapshot = `${p}.draining`;
72
+ try {
73
+ renameSync(p, snapshot);
74
+ }
75
+ catch {
76
+ // Lost the race (file vanished) — nothing to drain.
77
+ return [];
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.
81
+ let entries = [];
82
+ try {
83
+ entries = parseEntries(readFileSync(snapshot, 'utf8'));
84
+ }
85
+ finally {
86
+ try {
87
+ rmSync(snapshot, { force: true });
88
+ }
89
+ catch { /* best-effort cleanup */ }
90
+ }
91
+ return entries;
92
+ }
@@ -47,6 +47,36 @@ export interface ContextFileParam {
47
47
  shape?: string;
48
48
  }
49
49
  export type InputParam = PositionalParam | FlagParam | StdinParam | ContextFileParam;
50
+ /** How prominently a subcommand surfaces in ancestor (parent / root) -h
51
+ * listings. Set per child in the parent branch's `help.children`. Default
52
+ * 'normal'.
53
+ * - hidden — never listed anywhere, not even in this branch's own -h.
54
+ * You must already know it exists to invoke it.
55
+ * - normal — listed in this branch's own -h only (the default).
56
+ * - common — ALSO promoted into the parent's -h, as a bare qualified name.
57
+ * - important — ALSO promoted into the parent's -h, name + shortform desc. */
58
+ export type SubTier = 'hidden' | 'normal' | 'common' | 'important';
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 {
65
+ name: string;
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. */
78
+ subCount?: number;
79
+ }
50
80
  /** A subtree's self-description at the parent (root) level. Each subtree owns
51
81
  * the content that represents it one level up: its vocabulary line, its
52
82
  * selection rubric, and any bounded block it contributes to the parent's -h.
@@ -75,32 +105,48 @@ export interface RootHelp {
75
105
  * root, carrying the subtree's concept, selection rubric, and any nested
76
106
  * runtime-state block. Assembled from subtrees' RootEntry by defineRoot;
77
107
  * root hardcodes none of it. */
78
- commands: {
79
- name: string;
80
- concept: string;
81
- desc: string;
82
- useWhen: string;
83
- dynamicState?: () => string | null;
84
- }[];
108
+ commands: RootCommand[];
85
109
  globals: {
86
110
  name: string;
87
111
  desc: string;
88
112
  }[];
89
113
  }
114
+ /** A single command block at root. Most fields come from the subtree's
115
+ * RootEntry; `subcommands`/`otherSubcommandCount` are computed by defineRoot
116
+ * from the subtree's children tiers. */
117
+ export interface RootCommand {
118
+ name: string;
119
+ concept: string;
120
+ desc: string;
121
+ useWhen: string;
122
+ dynamicState?: () => string | null;
123
+ /** Promoted subcommands surfaced inline under this command at root, in
124
+ * declaration order. `desc` is present only for 'important' tier; 'common'
125
+ * tier carries the bare qualified path. */
126
+ subcommands?: {
127
+ path: string;
128
+ desc?: string;
129
+ }[];
130
+ /** How many of this command's other (non-hidden, not-promoted) direct
131
+ * subcommands are not shown. Drives the "[+N (other) subcommands]" line. */
132
+ otherSubcommandCount?: number;
133
+ }
90
134
  export interface BranchHelp {
91
135
  name: string;
136
+ /** The command's own description — rendered as the `description` attribute of
137
+ * its <command> card at its own -h. */
92
138
  summary: string;
93
- /** 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). */
94
142
  model?: string;
95
143
  /** Bounded runtime aggregate as a complete self-named state element (build
96
144
  * it with stateBlock), e.g. `<skills count="42">…</skills>`. Renderer
97
145
  * soft-fails to omission if this returns null or throws. */
98
146
  dynamicState?: () => string | null;
99
- children: {
100
- name: string;
101
- desc: string;
102
- useWhen: string;
103
- }[];
147
+ /** Parent-level listing assembled by defineBranch from the actual child defs.
148
+ * renderBranch reads this; never author it by hand. */
149
+ listing?: ListingChild[];
104
150
  }
105
151
  export interface LeafHelp {
106
152
  name: string;
package/dist/core/help.js CHANGED
@@ -50,10 +50,38 @@ 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.';
60
+ /** Lines for a command's subcommand affordance at root: any promoted
61
+ * (common/important) subcommands, then a remainder line naming how many other
62
+ * subcommands exist behind `crtr <name> -h`. Returns [] when the command has
63
+ * no listable subcommands at all. */
64
+ function rootSubcommandLines(c) {
65
+ const promoted = c.subcommands ?? [];
66
+ const other = c.otherSubcommandCount ?? 0;
67
+ if (promoted.length === 0 && other === 0)
68
+ return [];
69
+ const out = [];
70
+ if (promoted.length > 0) {
71
+ const labelW = maxLen(promoted.map((s) => s.path));
72
+ for (const s of promoted) {
73
+ // important → padded name + shortform desc; common → bare name.
74
+ out.push(s.desc !== undefined && s.desc !== ''
75
+ ? ` ${pad(s.path, labelW)} ${s.desc}`
76
+ : ` ${s.path}`);
77
+ }
78
+ }
79
+ if (other > 0) {
80
+ const word = promoted.length > 0 ? 'other subcommand' : 'subcommand';
81
+ out.push(` [+${other} ${word}${other === 1 ? '' : 's'} — \`crtr ${c.name} -h\`]`);
82
+ }
83
+ return out;
84
+ }
57
85
  export function renderRoot(h) {
58
86
  const lines = [];
59
87
  lines.push(`${h.tagline}`);
@@ -71,6 +99,11 @@ export function renderRoot(h) {
71
99
  lines.push(`<command name="${c.name}">`);
72
100
  lines.push(c.concept);
73
101
  lines.push(`use when ${c.useWhen}`);
102
+ // The command's subcommand surface: promoted (common/important) children
103
+ // inline, plus a "[+N other subcommands]" pointer to its own -h. Sits
104
+ // between the selection rubric and any live state block.
105
+ for (const l of rootSubcommandLines(c))
106
+ lines.push(l);
74
107
  // dynamicState returns a complete self-named element (e.g.
75
108
  // <skills count="42">…</skills>) — emit it as-is, nested in the command.
76
109
  const state = evalDynamic(c.dynamicState);
@@ -79,13 +112,18 @@ export function renderRoot(h) {
79
112
  lines.push('</command>');
80
113
  lines.push('');
81
114
  }
82
- // Globals block (footer)
83
- lines.push('Globals');
84
- const gNameW = maxLen(h.globals.map((g) => g.name));
85
- for (const g of h.globals) {
86
- 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('');
87
126
  }
88
- lines.push('');
89
127
  lines.push(IO_CONTRACT);
90
128
  lines.push('');
91
129
  lines.push(CAPABILITY_DISCOVERY);
@@ -94,30 +132,37 @@ export function renderRoot(h) {
94
132
  // ---------------------------------------------------------------------------
95
133
  // renderBranch
96
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
+ }
97
142
  export function renderBranch(h) {
98
143
  const lines = [];
99
- lines.push(`${h.name}: ${h.summary}.`);
100
- // Dynamic content leads the live aggregate (e.g. the <skills> catalog)
101
- // renders right after the name, before the hardcoded model prose, so current
102
- // state is read first. The subtree authors the whole element, so the same
103
- // 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)}">`);
104
150
  const branchState = evalDynamic(h.dynamicState);
105
- if (branchState !== null) {
106
- // dynamicState returns a complete self-named element — emit as-is.
107
- lines.push('');
151
+ if (branchState !== null)
108
152
  lines.push(branchState);
109
- }
110
- if (h.model !== undefined) {
111
- lines.push('');
153
+ if (h.model !== undefined)
112
154
  lines.push(h.model);
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}/>`);
113
164
  }
114
- lines.push('');
115
- lines.push('Branches');
116
- const nameW = maxLen(h.children.map((c) => c.name));
117
- const descW = maxLen(h.children.map((c) => c.desc));
118
- for (const c of h.children) {
119
- lines.push(` ${pad(c.name, nameW)} ${pad(c.desc, descW)} | use when ${c.useWhen}`);
120
- }
165
+ lines.push('</command>');
121
166
  return lines.join('\n');
122
167
  }
123
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
+ }