@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
@@ -0,0 +1,143 @@
1
+ // `crtr canvas chord` — the tmux prefix-menu dispatcher.
2
+ //
3
+ // The alt+c display-menu can't see the cursor node or read config at popup time
4
+ // — it only knows the active pane. So every config-driven prefix chord routes
5
+ // through this one leaf: tmux passes `--pane '#{pane_id}'` + `--key <k>`, and
6
+ // the dispatcher resolves the node in that pane, reads
7
+ // `canvasNav.prefixBinds[key]`, interpolates the template vars, and execs
8
+ // `crtr <argv>`. This keeps the menu static while the behaviour stays fully
9
+ // config-driven (no per-node menu rebuild).
10
+ //
11
+ // Two special cases bypass the bind table:
12
+ // • a digit key 1..9 → focus report N (the Nth live report of the pane node)
13
+ // • a bind whose `run` is the sentinel `__graph__` → send-keys `/graph` into
14
+ // the pane (toggles the in-pi GRAPH modal); the menu emits this directly so
15
+ // the dispatcher only handles it defensively.
16
+ //
17
+ // execFile (never `sh -c`) runs the interpolated argv, so a node name with
18
+ // shell metacharacters can never inject — each argv element is literal.
19
+ import { promisify } from 'node:util';
20
+ import { execFile } from 'node:child_process';
21
+ import { defineLeaf } from '../core/command.js';
22
+ import { InputError } from '../core/io.js';
23
+ import { readConfig } from '../core/config.js';
24
+ import { sendKeysEnter } from '../core/runtime/tmux.js';
25
+ import { nodeInPane } from './node.js';
26
+ import { getNode, subscribersOf, subscriptionsOf, view, fullName, } from '../core/canvas/index.js';
27
+ const pexec = promisify(execFile);
28
+ /** Template vars available to a `run` string. Single-valued vars interpolate
29
+ * in place (preserving spaces, e.g. a node name); `{subtree}` is multi-valued
30
+ * and a bare `{subtree}` token expands to several argv elements. */
31
+ function buildVars(selfId) {
32
+ const node = getNode(selfId);
33
+ const manager = subscribersOf(selfId)[0]?.node_id ?? '';
34
+ return {
35
+ id: selfId,
36
+ self: selfId,
37
+ lane: selfId,
38
+ name: node !== null ? fullName(node) : selfId,
39
+ manager,
40
+ subtree: view(selfId).join(' '),
41
+ };
42
+ }
43
+ /** Split a `run` string argv-style and interpolate the template vars. A bare
44
+ * `{subtree}` token expands to several argv elements; every other placeholder
45
+ * is substituted in place (kept as one element so a multi-word name survives
46
+ * as a single argument under execFile). */
47
+ function interpolateArgv(run, vars) {
48
+ const out = [];
49
+ for (const tok of run.split(/\s+/).filter((t) => t !== '')) {
50
+ if (tok === '{subtree}') {
51
+ for (const part of (vars['subtree'] ?? '').split(/\s+/).filter((p) => p !== ''))
52
+ out.push(part);
53
+ continue;
54
+ }
55
+ out.push(tok.replace(/\{(\w+)\}/g, (_, name) => vars[name] ?? ''));
56
+ }
57
+ return out;
58
+ }
59
+ export const chordLeaf = defineLeaf({
60
+ name: 'chord',
61
+ description: 'tmux prefix-menu dispatcher (bound by alt+c; not run by hand)',
62
+ whenToUse: 'never directly — the alt+c menu routes config-driven chords through it',
63
+ help: {
64
+ name: 'canvas chord',
65
+ summary: 'tmux prefix-menu dispatcher — resolve the node in --pane, look up canvasNav.prefixBinds[--key], interpolate, and exec `crtr <argv>`. Bound by the alt+c menu; not meant to be run by hand.',
66
+ params: [
67
+ {
68
+ kind: 'flag',
69
+ name: 'pane',
70
+ type: 'string',
71
+ required: false,
72
+ constraint: 'tmux pane id whose node the chord acts on. Defaults to $TMUX_PANE / your current pane.',
73
+ },
74
+ {
75
+ kind: 'flag',
76
+ name: 'key',
77
+ type: 'string',
78
+ required: true,
79
+ constraint: 'The chord key pressed after alt+c (e.g. m, e, or a digit 1-9 for focus report N).',
80
+ },
81
+ ],
82
+ output: [
83
+ { name: 'ran', type: 'boolean', required: true, constraint: 'True when an action was dispatched.' },
84
+ { name: 'key', type: 'string', required: true, constraint: 'Echo of the chord key.' },
85
+ { name: 'node_id', type: 'string', required: false, constraint: 'The node the chord resolved against.' },
86
+ { name: 'action', type: 'string', required: false, constraint: 'What ran: a crtr argv string, "graph-toggle", or "noop".' },
87
+ ],
88
+ outputKind: 'object',
89
+ effects: ['Runs a `crtr` subcommand (focus/close/tmux-spread/…) or sends `/graph` into the pane, per the matched bind.'],
90
+ },
91
+ run: async (input) => {
92
+ const pane = input['pane'] ?? process.env['TMUX_PANE'];
93
+ const key = input['key'].trim();
94
+ const selfId = nodeInPane(pane);
95
+ if (selfId === undefined) {
96
+ throw new InputError({
97
+ error: 'no_node',
98
+ message: 'no node found in this pane',
99
+ next: 'Run from inside an agent\'s pane, or pass --pane <pane-id>.',
100
+ });
101
+ }
102
+ // Digit keys 1..9 → focus the Nth live report (generated, not a bind entry).
103
+ if (/^[1-9]$/.test(key)) {
104
+ const n = parseInt(key, 10);
105
+ const reports = subscriptionsOf(selfId)
106
+ .map((r) => r.node_id)
107
+ .filter((id) => {
108
+ const s = getNode(id)?.status;
109
+ return s === 'active' || s === 'idle';
110
+ });
111
+ const target = reports[n - 1];
112
+ if (target === undefined)
113
+ return { ran: false, key, node_id: selfId, action: 'noop' };
114
+ try {
115
+ await pexec('crtr', ['node', 'focus', target], { timeout: 15_000 });
116
+ }
117
+ catch {
118
+ /* best-effort */
119
+ }
120
+ return { ran: true, key, node_id: selfId, action: `node focus ${target}` };
121
+ }
122
+ const bind = readConfig('user').canvasNav.prefixBinds[key];
123
+ if (bind === undefined)
124
+ return { ran: false, key, node_id: selfId, action: 'noop' };
125
+ // The GRAPH-toggle sentinel: type /graph into the pane (the menu normally
126
+ // emits this directly; handle it here too so a manual chord still works).
127
+ if (bind.run === '__graph__') {
128
+ if (pane !== undefined && pane !== '')
129
+ sendKeysEnter(pane, '/graph');
130
+ return { ran: true, key, node_id: selfId, action: 'graph-toggle' };
131
+ }
132
+ const argv = interpolateArgv(bind.run, buildVars(selfId));
133
+ if (argv.length === 0)
134
+ return { ran: false, key, node_id: selfId, action: 'noop' };
135
+ try {
136
+ await pexec('crtr', argv, { timeout: 15_000 });
137
+ }
138
+ catch {
139
+ /* best-effort: the keystroke just acts; errors are surfaced by the inner cmd */
140
+ }
141
+ return { ran: true, key, node_id: selfId, action: `crtr ${argv.join(' ')}` };
142
+ },
143
+ });
@@ -14,6 +14,8 @@ import { readPidfile, isPidAlive } from '../daemon/crtrd.js';
14
14
  // ---------------------------------------------------------------------------
15
15
  const daemonStart = defineLeaf({
16
16
  name: 'start',
17
+ description: 'start the daemon in the background',
18
+ whenToUse: 'bringing the crtrd supervisor up for the first time in a session so node window-exits get handled (no-op if it is already running)',
17
19
  help: {
18
20
  name: 'canvas daemon start',
19
21
  summary: 'start the crtrd supervisor daemon in the background (no-op if already running)',
@@ -39,6 +41,8 @@ const daemonStart = defineLeaf({
39
41
  // ---------------------------------------------------------------------------
40
42
  const daemonStatus = defineLeaf({
41
43
  name: 'status',
44
+ description: 'check whether the daemon is running',
45
+ whenToUse: 'checking whether the crtrd supervisor is running — e.g. confirming supervision is up before relying on auto-revive',
42
46
  help: {
43
47
  name: 'canvas daemon status',
44
48
  summary: 'check whether the crtrd supervisor daemon is currently running',
@@ -63,6 +67,8 @@ const daemonStatus = defineLeaf({
63
67
  // ---------------------------------------------------------------------------
64
68
  const daemonStop = defineLeaf({
65
69
  name: 'stop',
70
+ description: 'stop the running daemon',
71
+ whenToUse: 'shutting the crtrd supervisor down, ending auto-revival of nodes on window exit',
66
72
  help: {
67
73
  name: 'canvas daemon stop',
68
74
  summary: 'send SIGTERM to the crtrd supervisor daemon',
@@ -97,15 +103,12 @@ const daemonStop = defineLeaf({
97
103
  // ---------------------------------------------------------------------------
98
104
  export const daemonBranch = defineBranch({
99
105
  name: 'daemon',
106
+ description: 'manage the crtrd supervisor process',
107
+ whenToUse: 'managing the crtrd supervisor that auto-revives nodes on window exit — start it, check its status, or stop it',
100
108
  help: {
101
109
  name: 'canvas daemon',
102
110
  summary: 'manage the crtrd canvas supervisor daemon',
103
111
  model: 'crtrd is a thin background daemon that polls active+idle nodes and acts on window exit: crashed windows become dead; refresh-yield windows get a fresh respawn. It holds no orchestration logic — just process supervision.',
104
- children: [
105
- { name: 'start', desc: 'start the daemon in the background', useWhen: 'bringing up the supervisor for the first time in a session' },
106
- { name: 'status', desc: 'check whether the daemon is running', useWhen: 'verifying the daemon is up before spawning canvas nodes' },
107
- { name: 'stop', desc: 'stop the running daemon', useWhen: 'shutting down supervision' },
108
- ],
109
112
  },
110
113
  children: [daemonStart, daemonStatus, daemonStop],
111
114
  });
@@ -14,6 +14,8 @@ import { renderTree, renderForest, dashboardRows, dashboardRowsAll } from '../co
14
14
  // ---------------------------------------------------------------------------
15
15
  export const dashboardLeaf = defineLeaf({
16
16
  name: 'dashboard',
17
+ description: 'render the canvas as a subscription tree',
18
+ whenToUse: 'you want the whole graph at a glance as a rendered tree — the subscription forest drawn in ASCII so you can read its SHAPE: who reports to whom, how deep each branch runs, plus each node\'s status and context size. Scope to one root or show the full forest. Use `node inspect list` instead for a flat roster without the tree, `node inspect show` to drill into one node\'s neighbors, and `canvas attention` to find which nodes are blocked on a human',
17
19
  help: {
18
20
  name: 'canvas dashboard',
19
21
  summary: 'render the canvas as an ASCII subscription tree — scoped to a root or the full forest',
@@ -7,7 +7,7 @@ import { mkdirSync, existsSync } from 'node:fs';
7
7
  import { join, resolve } from 'node:path';
8
8
  import { randomBytes } from 'node:crypto';
9
9
  import { validateDeck, approveDeck, notifyDeck, atomicWriteJson, deckPath, display, } from '@crouton-kit/humanloop';
10
- import { DECK_SCHEMA_HINT, waitForFinalReport, spawnHumanJob, pickPlacement, runCmd, resolveMaxPanes, } from './shared.js';
10
+ import { DECK_SCHEMA_HINT, followUpReview, spawnHumanJob, pickPlacement, runCmd, resolveMaxPanes, } from './shared.js';
11
11
  /** The asking node's id, or null when run from a bare shell (no parent to route to). */
12
12
  function askingNode() {
13
13
  return process.env['CRTR_NODE_ID'] ?? null;
@@ -17,6 +17,8 @@ function askingNode() {
17
17
  // ---------------------------------------------------------------------------
18
18
  export const humanAsk = defineLeaf({
19
19
  name: 'ask',
20
+ description: 'put a structured choice or open question to a person',
21
+ whenToUse: 'you would otherwise lay a decision, a set of options, or a question out for the user as prose — reach for this instead, for anything from a quick yes/no to a judgment-heavy call: reviewing all the requirements before building, choosing among implementation patterns, walking a list of risks and deciding what to do about each, settling a naming or scope question, picking which of several findings to act on. Works for open-ended asks too (set `allowFreetext`, offer a few `options` as starting points). The kickoff never blocks, so the human answering on their own time is never a reason to skip the ask and guess instead',
20
22
  help: {
21
23
  name: 'human ask',
22
24
  summary: 'put a humanloop decision deck in front of a person; returns a job handle immediately. This is the default, expected channel for posing ANY question or decision to the user — reach for it instead of writing the question as prose in your reply.',
@@ -65,6 +67,8 @@ export const humanAsk = defineLeaf({
65
67
  // ---------------------------------------------------------------------------
66
68
  export const humanApprove = defineLeaf({
67
69
  name: 'approve',
70
+ description: 'a Yes/No sign-off gate',
71
+ whenToUse: 'a step needs an explicit human yes before it proceeds and a plain answer (not anchored comments) is enough: before a handoff, a merge or deploy, a destructive or irreversible operation, spending real budget, or acting on a risky plan. Reach for `ask` instead when you need them to choose among options or answer something open-ended; reach for `review` when the feedback belongs inline on a document',
68
72
  help: {
69
73
  name: 'human approve',
70
74
  summary: 'a Yes/No approval gate; returns a job handle immediately. The standard way to gate a handoff on human sign-off. Kickoff never blocks — peek at the result later rather than busy-waiting; the human answering on their own time is not a reason to skip the gate.',
@@ -109,27 +113,27 @@ export const humanApprove = defineLeaf({
109
113
  // ---------------------------------------------------------------------------
110
114
  export const humanReview = defineLeaf({
111
115
  name: 'review',
116
+ description: 'collect anchored comments on a .md (plan or spec)',
117
+ whenToUse: 'a human should comment line-by-line on a document rather than give one overall answer: reviewing a plan or spec before you build it, marking up a draft, flagging specific sections to change. The comments come back anchored to the lines they touch. Use `approve` instead for a single yes/no on the whole thing, or `ask` to pose a discrete choice',
112
118
  help: {
113
119
  name: 'human review',
114
- summary: 'open a .md in a read-only review editor for anchored comments; BLOCKS until the human submits the review. Humans respond on human time (often >10 min) if you want to keep working, background this call (your harness will notify you when it finishes).',
115
- guide: 'Unlike ask/approve, this call does not return a job handle and walk away it blocks until the human finishes reviewing and submits (or closes the pane). Run it in the background when you have other work to do; the harness surfaces the result on completion. The returned `result` is the humanloop FeedbackResult (anchored comments). The .md you point at is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see `termrender doc -h` for the directive set before authoring one.',
120
+ summary: "put a .md live on the human's screen for anchored, line-by-line comments; returns a job handle instantly (a non-blocking kickoff, like ask/approve). The pane tracks the file and re-renders on every save, so edit in place rather than re-presenting.",
121
+ guide: "A kickoff, not a blocking call: it returns at once and the human reviews on their own time. When they submit, the FeedbackResult (anchored comments, plus any line edits they made) is pushed to your inbox waking you and autosaved to `output`; you never poll, verify it opened, or background it. The pane is a LIVE view of the file: keep editing the .md in place and it updates, so do not cancel and re-present to show a change. The .md is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see `termrender doc -h` for the directive set before authoring one.",
116
122
  params: [
117
123
  { kind: 'positional', name: 'file', type: 'path', required: true, constraint: 'Absolute path to an existing .md file.' },
118
124
  { kind: 'flag', name: 'output', type: 'path', required: false, constraint: 'Where the FeedbackResult JSON is written. Default: <dir>/feedback.json.' },
119
125
  ],
120
126
  output: [
121
- { name: 'job_id', type: 'string', required: true, constraint: 'Node id of the kind:"human" node backing this review.' },
122
- { name: 'output', type: 'string', required: true, constraint: 'Path the FeedbackResult JSON is autosaved to.' },
123
- { name: 'status', type: 'string', required: true, constraint: 'Terminal state once the call unblocks: done (submitted), failed, canceled, or closed (pane went away before submit).' },
124
- { name: 'result', type: 'object', required: false, constraint: 'The humanloop FeedbackResult (anchored comments). Present when status is done.' },
125
- { name: 'reason', type: 'string', required: false, constraint: 'Short explanation when status is failed or closed.' },
126
- { name: 'follow_up', type: 'string', required: false, constraint: 'Present only when off-tmux: a human must drain the review via `crtr human inbox`, then read the result.' },
127
+ { name: 'job_id', type: 'string', required: true, constraint: 'Node id of the kind:"human" node backing this review. Its FeedbackResult is pushed to your inbox when the human submits.' },
128
+ { name: 'dir', type: 'string', required: true, constraint: 'Interaction directory holding run.json and the autosaved feedback.json.' },
129
+ { name: 'output', type: 'string', required: true, constraint: 'Path the FeedbackResult JSON is autosaved to when the human submits.' },
130
+ { name: 'follow_up', type: 'string', required: true, constraint: 'A non-blocking road sign. The human may take minutes to hours; do not block, poll, or verify the pane — just end your turn and you are woken when they submit.' },
127
131
  ],
128
132
  outputKind: 'object',
129
133
  effects: [
130
134
  'Creates a kind:"human" node under you and writes run.json to the interaction dir.',
131
- 'Spawns a read-only nvim/vim review session in a detached tmux pane (when in tmux).',
132
- 'Blocks the calling process until the human submits, the pane closes, or the job is canceled.',
135
+ 'Spawns a live, read-only review session (in a detached tmux pane when in tmux) that tracks the file and re-renders on save.',
136
+ 'Returns instantly; the anchored comments fan into your inbox when the human submits (off-tmux, drain via `crtr human inbox`).',
133
137
  ],
134
138
  },
135
139
  run: async (input) => {
@@ -159,22 +163,15 @@ export const humanReview = defineLeaf({
159
163
  const output = outputArg !== undefined ? outputArg : join(idir, 'feedback.json');
160
164
  const rc = { mode: 'review', job_id: jobId, file: abs, output };
161
165
  atomicWriteJson(join(idir, 'run.json'), rc);
162
- const { spawned, follow_up, paneId } = spawnHumanJob(jobId, idir, cwd);
163
- // Off-tmux: no pane to block on fall back to the non-blocking handle the
164
- // way ask/approve do, so the review can still be drained from the inbox.
165
- if (!spawned) {
166
- return { job_id: jobId, output, status: 'live', follow_up };
167
- }
168
- // In tmux: block until the human submits or the pane dies before submitting.
169
- // No timeout (the human owns the clock); the pane-alive poll inside
170
- // waitForFinalReport resolves 'closed' if the pane goes away first.
171
- const r = await waitForFinalReport(jobId, paneId);
172
- const out = { job_id: jobId, output, status: r.status };
173
- if (r.result !== undefined)
174
- out['result'] = r.result;
175
- if (r.reason !== undefined)
176
- out['reason'] = r.reason;
177
- return out;
166
+ // review is a non-blocking kickoff, exactly like ask/approve: the detached
167
+ // `_run` worker pushes the FeedbackResult as this node's final report when
168
+ // the human submits, which fans into our inbox and wakes us (and is also
169
+ // autosaved to `output`). There is nothing to block on — the doc is live on
170
+ // the human's screen and tracks the file, so the caller keeps editing in
171
+ // place or simply ends its turn.
172
+ const { spawned, follow_up: drainFollowUp } = spawnHumanJob(jobId, idir, cwd);
173
+ const follow_up = spawned ? followUpReview(jobId) : drainFollowUp;
174
+ return { job_id: jobId, dir: idir, output, follow_up };
178
175
  },
179
176
  });
180
177
  // ---------------------------------------------------------------------------
@@ -182,6 +179,8 @@ export const humanReview = defineLeaf({
182
179
  // ---------------------------------------------------------------------------
183
180
  export const humanNotify = defineLeaf({
184
181
  name: 'notify',
182
+ description: 'fire-and-forget acknowledgement, no reply expected',
183
+ whenToUse: 'informing a person without blocking or expecting an answer',
185
184
  help: {
186
185
  name: 'human notify',
187
186
  summary: 'show a fire-and-forget acknowledgement; creates no job',
@@ -229,6 +228,8 @@ export const humanNotify = defineLeaf({
229
228
  // ---------------------------------------------------------------------------
230
229
  export const humanShow = defineLeaf({
231
230
  name: 'show',
231
+ description: "put a file live on the human's screen",
232
+ whenToUse: 'displaying a doc on screen while a human comments',
232
233
  help: {
233
234
  name: 'human show',
234
235
  summary: 'put a file live on screen in a tmux pane via humanloop display',
@@ -1,3 +1,4 @@
1
1
  export declare const humanInbox: import("../../core/command.js").LeafDef;
2
2
  export declare const humanList: import("../../core/command.js").LeafDef;
3
+ export declare const humanCancel: import("../../core/command.js").LeafDef;
3
4
  export declare const humanRun: import("../../core/command.js").LeafDef;
@@ -1,14 +1,22 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
+ import { InputError } from '../../core/io.js';
2
3
  import { pushFinal } from '../../core/feed/feed.js';
3
- import { interactionsRoot } from '../../core/artifact.js';
4
+ import { interactionsRoot, interactionDir } from '../../core/artifact.js';
4
5
  import { paginate } from '../../core/pagination.js';
6
+ import { getNode, subscribersOf } from '../../core/canvas/index.js';
7
+ import { transition } from '../../core/runtime/lifecycle.js';
8
+ import { appendInbox } from '../../core/feed/inbox.js';
9
+ import { existsSync } from 'node:fs';
5
10
  import { join } from 'node:path';
6
- import { inbox, scanInbox, parseDeck, deckPath, ask, launchReview, readJson, } from '@crouton-kit/humanloop';
11
+ import { inbox, scanInbox, parseDeck, deckPath, responsePath, isResolved, atomicWriteJson, ask, launchReview, readJson, } from '@crouton-kit/humanloop';
12
+ import { killPane } from './shared.js';
7
13
  // ---------------------------------------------------------------------------
8
14
  // inbox (human-invoked, blocking)
9
15
  // ---------------------------------------------------------------------------
10
16
  export const humanInbox = defineLeaf({
11
17
  name: 'inbox',
18
+ description: 'interactively drain pending interactions',
19
+ whenToUse: 'a human is clearing the queue at their terminal',
12
20
  help: {
13
21
  name: 'human inbox',
14
22
  summary: 'interactively drain pending interactions at your own terminal',
@@ -28,6 +36,8 @@ export const humanInbox = defineLeaf({
28
36
  // ---------------------------------------------------------------------------
29
37
  export const humanList = defineLeaf({
30
38
  name: 'list',
39
+ description: 'enumerate pending interactions',
40
+ whenToUse: 'discovering what is blocked on a human',
31
41
  help: {
32
42
  name: 'human list',
33
43
  summary: 'paginated list of pending, unclaimed interactions, oldest first',
@@ -71,10 +81,103 @@ export const humanList = defineLeaf({
71
81
  },
72
82
  });
73
83
  // ---------------------------------------------------------------------------
84
+ // cancel — retract a pending ask/approve/review
85
+ // ---------------------------------------------------------------------------
86
+ export const humanCancel = defineLeaf({
87
+ name: 'cancel',
88
+ description: 'retract a pending ask/approve/review',
89
+ whenToUse: 'a question went stale before the human answered',
90
+ help: {
91
+ name: 'human cancel',
92
+ summary: 'retract a pending ask/approve/review you posed — kills its TUI pane, drops it from the human queue, and retires the node. Reach for this the moment a question goes stale (you answered it yourself, the situation changed) so a human is not left resolving a prompt whose answer no longer matters',
93
+ guide: 'Pass the job_id returned by `human ask`/`approve`/`review`. Best-effort and idempotent: if the human already answered, or it was already canceled, it reports canceled:false with reason "already_resolved" and changes nothing. The agent that posed the deck is almost always the one canceling it, so the caller is never messaged — only OTHER subscribers (e.g. the asking node when a human dismisses the prompt) get a quiet deferred note that no answer is coming. Canceling a review kills its live on-screen pane and delivers no comments — the same quiet deferred note covers it.',
94
+ params: [
95
+ { kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Node id of the interaction to cancel — the job_id returned by ask/approve/review.' },
96
+ { kind: 'flag', name: 'reason', type: 'string', required: false, constraint: 'Optional short note delivered to subscribers explaining why it was retracted.' },
97
+ ],
98
+ output: [
99
+ { name: 'canceled', type: 'boolean', required: true, constraint: 'True when the interaction was retracted; false when there was nothing live to cancel (already answered/canceled).' },
100
+ { name: 'job_id', type: 'string', required: true, constraint: 'The interaction node id.' },
101
+ { name: 'reason', type: 'string', required: false, constraint: 'Why nothing was canceled (e.g. "already_resolved"), present when canceled is false.' },
102
+ ],
103
+ outputKind: 'object',
104
+ effects: [
105
+ "Kills the detached TUI pane (if any) so the prompt leaves the human's screen.",
106
+ 'Writes a canceled response.json so the interaction drops out of `human list`/`inbox`.',
107
+ 'Marks the node done and, only for subscribers other than the caller, drops a deferred note that no answer is coming.',
108
+ ],
109
+ },
110
+ run: async (input) => {
111
+ const jobId = input['job_id'];
112
+ const reason = input['reason'];
113
+ const node = getNode(jobId);
114
+ if (node === null) {
115
+ throw new InputError({
116
+ error: 'not_found',
117
+ message: `no interaction node: ${jobId}`,
118
+ field: 'job_id',
119
+ next: 'Pass the job_id from human ask/approve/review, or list pending with `crtr human list`.',
120
+ });
121
+ }
122
+ // Resolve the interaction dir from the node's RECORDED cwd: interaction dirs
123
+ // are keyed by the asking process's cwd, which may differ from the caller's.
124
+ const idir = interactionDir(jobId, node.cwd);
125
+ // Nothing live to cancel: the human already answered, or it was retired.
126
+ // 'canceled' is in the guard too so transition('finalize') below — legal only
127
+ // from active|idle — can never throw on an already-canceled (but unresolved)
128
+ // interaction node.
129
+ if (node.status === 'done' || node.status === 'dead' || node.status === 'canceled' || isResolved(idir)) {
130
+ return { canceled: false, job_id: jobId, reason: 'already_resolved' };
131
+ }
132
+ // (1) Kill the detached TUI pane so the prompt (or a review's live doc) leaves
133
+ // the human's screen. Pass `idir` so killPane only fires when the target
134
+ // pane is provably the worker we spawned for THIS job (its launch command
135
+ // carries CRTR_HUMAN_DIR=idir) — never the agent's own pane or a shell.
136
+ const rc = readJson(join(idir, 'run.json'));
137
+ if (rc?.pane_id !== undefined && rc.pane_id !== '')
138
+ killPane(rc.pane_id, idir);
139
+ // (2) Drop it from the human queue: a response.json marks the dir resolved,
140
+ // so scanInbox (human list/inbox) skips it.
141
+ if (existsSync(idir)) {
142
+ atomicWriteJson(responsePath(idir), {
143
+ canceled: true,
144
+ canceledAt: new Date().toISOString(),
145
+ ...(reason !== undefined && reason !== '' ? { reason } : {}),
146
+ });
147
+ }
148
+ // (3) Retire the node. We do NOT push a -final.md report: a cancel must not
149
+ // masquerade as a human-submitted result. Subscribers get the quiet
150
+ // deferred 'no answer is coming' note below instead.
151
+ transition(jobId, 'finalize');
152
+ // Almost always the asking agent cancels its OWN deck — it already knows, so
153
+ // never message the caller. Only a third-party cancel (a human, an
154
+ // orchestrator) leaves a genuinely-waiting asker uninformed; give them a
155
+ // quiet deferred note (informational, never nudges) so nobody waits forever.
156
+ const caller = process.env['CRTR_NODE_ID'] ?? 'human';
157
+ const note = reason !== undefined && reason !== '' ? ` — ${reason}` : '';
158
+ for (const sub of subscribersOf(jobId)) {
159
+ if (sub.node_id === caller)
160
+ continue; // don't ping whoever issued the cancel
161
+ appendInbox(sub.node_id, {
162
+ from: caller,
163
+ tier: 'deferred',
164
+ kind: 'message',
165
+ label: `human interaction ${jobId} canceled — no answer is coming${note}`,
166
+ data: { body: `The human interaction ${jobId} was canceled${note}. No response will arrive.` },
167
+ });
168
+ }
169
+ return { canceled: true, job_id: jobId };
170
+ },
171
+ render: (r) => r['canceled'] === true
172
+ ? `<canceled job_id="${r['job_id']}"/>`
173
+ : `<cancel-noop job_id="${r['job_id']}">${r['reason'] ?? 'nothing to cancel'}</cancel-noop>`,
174
+ });
175
+ // ---------------------------------------------------------------------------
74
176
  // _run (hidden worker; not listed in branch help)
75
177
  // ---------------------------------------------------------------------------
76
178
  export const humanRun = defineLeaf({
77
179
  name: '_run',
180
+ tier: 'hidden',
78
181
  help: {
79
182
  name: 'human _run',
80
183
  summary: 'internal: the detached worker that runs the blocking humanloop call at the pane TTY',
@@ -5,39 +5,49 @@ export interface RunRecord {
5
5
  approve_iid?: string;
6
6
  file?: string;
7
7
  output?: string;
8
+ /** tmux pane id of the detached TUI, recorded so `human cancel` can kill it. */
9
+ pane_id?: string;
8
10
  }
9
11
  export declare function resolveMaxPanes(): number;
10
12
  export declare function pickPlacement(): 'split-h' | 'new-window';
11
13
  export declare function runCmd(dir: string): string;
12
14
  export declare function followUpResult(_jobId: string): string;
13
15
  export declare function followUpDrain(_jobId: string): string;
16
+ /**
17
+ * Road sign for a spawned `human review`. It is a non-blocking kickoff, so the
18
+ * text steers the caller to stop rather than wait, verify, or re-present: the
19
+ * pane is already live and tracks the file, and the comments arrive via the
20
+ * inbox/wake when the human submits.
21
+ */
22
+ export declare function followUpReview(_jobId: string): string;
14
23
  /**
15
24
  * Spawn the detached `_run` pane that drives the humanloop TUI for this node.
16
- * Returns whether the pane spawned, the follow_up text, and (when spawned) the
17
- * tmux pane id so a blocking caller (review) can detect the pane dying before
18
- * the human submits. Degrades to the inbox-drain follow_up when not in tmux /
19
- * spawn fails — kickoffs are intentionally non-fatal off-tmux.
25
+ * Returns whether the pane spawned and the follow_up road sign. Degrades to the
26
+ * inbox-drain follow_up when not in tmux / spawn fails kickoffs are
27
+ * intentionally non-fatal off-tmux.
20
28
  *
21
29
  * Completion routing needs no bookkeeping here: the human node was created
22
30
  * under the asking node as its parent (spawnNode auto-subscribes the parent),
23
- * so the `pushFinal` the `_run` worker emits fans the answer straight into the
24
- * asking node's inbox.
31
+ * so the `pushFinal` the `_run` worker emits for ask, approve, AND review —
32
+ * fans the answer straight into the asking node's inbox. The pane id is recorded
33
+ * on run.json (not returned) so `human cancel` can later kill the TUI.
25
34
  */
26
35
  export declare function spawnHumanJob(jobId: string, idir: string, cwd: string): {
27
36
  spawned: boolean;
28
37
  follow_up: string;
29
- paneId?: string;
30
38
  };
31
- export interface HumanResult {
32
- status: string;
33
- result?: unknown;
34
- reason?: string;
35
- }
36
39
  /**
37
- * Block until `nodeId` emits a `final` report (the human submitted) or — when a
38
- * pane id is given that pane dies before submitting (the human closed it).
39
- * Polls once a second: this is a human-time operation, so a coarse poll is fine
40
- * and sidesteps fs.watch directory-existence races. The `_run` worker writes
41
- * the humanloop result as the report body (JSON), which we parse back out.
40
+ * Best-effort kill of a humanloop worker pane. SAFETY-CRITICAL: a malformed or
41
+ * empty `-t` target makes tmux fall back to the CALLER's current pane, so a bad
42
+ * paneId could kill the agent's own pi pane (and, if it is the last pane, the
43
+ * whole session). This refuses to kill anything that is not provably the worker:
44
+ *
45
+ * 1. paneId must be a real tmux pane id (`%<n>`) — never an empty/odd string.
46
+ * 2. The pane's start command must contain `verify` (the interaction dir, which
47
+ * humanloop bakes into the worker's `CRTR_HUMAN_DIR=... crtr human _run`
48
+ * launch). A shell (`zsh -l`) or the agent's pi never matches, so we can
49
+ * only ever kill the exact worker we spawned for this job.
50
+ *
51
+ * Returns true only when a matching pane was found and killed. Never throws.
42
52
  */
43
- export declare function waitForFinalReport(nodeId: string, paneId?: string): Promise<HumanResult>;
53
+ export declare function killPane(paneId: string, verify: string): boolean;