@crouton-kit/crouter 0.3.11 → 0.3.12

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 (182) hide show
  1. package/bin/crtrd +2 -0
  2. package/dist/builtin-personas/design/base.md +9 -0
  3. package/dist/builtin-personas/design/orchestrator.md +10 -0
  4. package/dist/builtin-personas/developer/base.md +9 -0
  5. package/dist/builtin-personas/developer/orchestrator.md +12 -0
  6. package/dist/builtin-personas/explore/base.md +9 -0
  7. package/dist/builtin-personas/explore/orchestrator.md +9 -0
  8. package/dist/builtin-personas/general/base.md +5 -0
  9. package/dist/builtin-personas/general/orchestrator.md +7 -0
  10. package/dist/builtin-personas/orchestration-kernel.md +71 -0
  11. package/dist/builtin-personas/plan/base.md +7 -0
  12. package/dist/builtin-personas/plan/orchestrator.md +12 -0
  13. package/dist/builtin-personas/review/base.md +7 -0
  14. package/dist/builtin-personas/review/orchestrator.md +9 -0
  15. package/dist/builtin-personas/runtime-base.md +39 -0
  16. package/dist/builtin-personas/spec/base.md +7 -0
  17. package/dist/builtin-personas/spec/orchestrator.md +10 -0
  18. package/dist/builtin-skills/skills/design/SKILL.md +51 -0
  19. package/dist/builtin-skills/skills/development/SKILL.md +109 -0
  20. package/dist/builtin-skills/skills/planning/SKILL.md +59 -0
  21. package/dist/builtin-skills/skills/spec/SKILL.md +83 -0
  22. package/dist/cli.js +14 -6
  23. package/dist/commands/{mode.d.ts → attention.d.ts} +1 -1
  24. package/dist/commands/attention.js +152 -0
  25. package/dist/commands/canvas.d.ts +2 -0
  26. package/dist/commands/canvas.js +35 -0
  27. package/dist/commands/daemon.d.ts +2 -0
  28. package/dist/commands/daemon.js +111 -0
  29. package/dist/commands/dashboard.d.ts +2 -0
  30. package/dist/commands/dashboard.js +65 -0
  31. package/dist/commands/human/prompts.d.ts +5 -0
  32. package/dist/commands/human/prompts.js +269 -0
  33. package/dist/commands/human/queue.d.ts +3 -0
  34. package/dist/commands/human/queue.js +133 -0
  35. package/dist/commands/human/shared.d.ts +43 -0
  36. package/dist/commands/human/shared.js +107 -0
  37. package/dist/commands/human.js +10 -454
  38. package/dist/commands/node.d.ts +2 -0
  39. package/dist/commands/node.js +354 -0
  40. package/dist/commands/pkg/market-inspect.d.ts +1 -0
  41. package/dist/commands/pkg/market-inspect.js +157 -0
  42. package/dist/commands/pkg/market-manage.d.ts +1 -0
  43. package/dist/commands/pkg/market-manage.js +316 -0
  44. package/dist/commands/pkg/market.d.ts +1 -0
  45. package/dist/commands/pkg/market.js +16 -0
  46. package/dist/commands/pkg/plugin-inspect.d.ts +1 -0
  47. package/dist/commands/pkg/plugin-inspect.js +142 -0
  48. package/dist/commands/pkg/plugin-manage.d.ts +1 -0
  49. package/dist/commands/pkg/plugin-manage.js +294 -0
  50. package/dist/commands/pkg/plugin.d.ts +1 -0
  51. package/dist/commands/pkg/plugin.js +16 -0
  52. package/dist/commands/pkg/shared.d.ts +5 -0
  53. package/dist/commands/pkg/shared.js +61 -0
  54. package/dist/commands/pkg.js +3 -1004
  55. package/dist/commands/push.d.ts +3 -0
  56. package/dist/commands/push.js +159 -0
  57. package/dist/commands/revive.d.ts +2 -0
  58. package/dist/commands/revive.js +64 -0
  59. package/dist/commands/skill/author.d.ts +3 -0
  60. package/dist/commands/skill/author.js +147 -0
  61. package/dist/commands/skill/find.d.ts +4 -0
  62. package/dist/commands/skill/find.js +254 -0
  63. package/dist/commands/skill/read.d.ts +1 -0
  64. package/dist/commands/skill/read.js +89 -0
  65. package/dist/commands/skill/shared.d.ts +19 -0
  66. package/dist/commands/skill/shared.js +207 -0
  67. package/dist/commands/skill/state.d.ts +3 -0
  68. package/dist/commands/skill/state.js +69 -0
  69. package/dist/commands/skill.js +6 -691
  70. package/dist/commands/sys/config.d.ts +1 -0
  71. package/dist/commands/sys/config.js +186 -0
  72. package/dist/commands/sys/doctor.d.ts +1 -0
  73. package/dist/commands/sys/doctor.js +369 -0
  74. package/dist/commands/sys/shared.d.ts +3 -0
  75. package/dist/commands/sys/shared.js +24 -0
  76. package/dist/commands/sys/update.d.ts +2 -0
  77. package/dist/commands/sys/update.js +114 -0
  78. package/dist/commands/sys.js +4 -694
  79. package/dist/core/__tests__/argv-parser.test.js +19 -1
  80. package/dist/core/__tests__/canvas-inbox-watcher.test.js +100 -0
  81. package/dist/core/__tests__/canvas.test.js +154 -0
  82. package/dist/core/__tests__/reset.test.js +105 -0
  83. package/dist/core/canvas/attention.d.ts +24 -0
  84. package/dist/core/canvas/attention.js +94 -0
  85. package/dist/core/canvas/canvas.d.ts +40 -0
  86. package/dist/core/canvas/canvas.js +210 -0
  87. package/dist/core/canvas/db.d.ts +7 -0
  88. package/dist/core/canvas/db.js +61 -0
  89. package/dist/core/canvas/index.d.ts +4 -0
  90. package/dist/core/canvas/index.js +6 -0
  91. package/dist/core/canvas/paths.d.ts +16 -0
  92. package/dist/core/canvas/paths.js +62 -0
  93. package/dist/core/canvas/render.d.ts +30 -0
  94. package/dist/core/canvas/render.js +186 -0
  95. package/dist/core/canvas/types.d.ts +87 -0
  96. package/dist/core/canvas/types.js +8 -0
  97. package/dist/core/command.d.ts +5 -0
  98. package/dist/core/command.js +35 -10
  99. package/dist/core/feed/feed.d.ts +43 -0
  100. package/dist/core/feed/feed.js +116 -0
  101. package/dist/core/feed/inbox.d.ts +50 -0
  102. package/dist/core/feed/inbox.js +124 -0
  103. package/dist/core/help.js +5 -3
  104. package/dist/core/io.d.ts +15 -1
  105. package/dist/core/io.js +56 -6
  106. package/dist/core/personas/index.d.ts +12 -0
  107. package/dist/core/personas/index.js +10 -0
  108. package/dist/core/personas/loader.d.ts +44 -0
  109. package/dist/core/personas/loader.js +157 -0
  110. package/dist/core/personas/resolve.d.ts +36 -0
  111. package/dist/core/personas/resolve.js +110 -0
  112. package/dist/core/render.d.ts +11 -0
  113. package/dist/core/render.js +126 -0
  114. package/dist/core/resolver.d.ts +10 -0
  115. package/dist/core/resolver.js +109 -1
  116. package/dist/core/runtime/front-door.d.ts +10 -0
  117. package/dist/core/runtime/front-door.js +97 -0
  118. package/dist/core/runtime/kickoff.d.ts +23 -0
  119. package/dist/core/runtime/kickoff.js +134 -0
  120. package/dist/core/runtime/launch.d.ts +34 -0
  121. package/dist/core/runtime/launch.js +85 -0
  122. package/dist/core/runtime/nodes.d.ts +38 -0
  123. package/dist/core/runtime/nodes.js +95 -0
  124. package/dist/core/runtime/presence.d.ts +38 -0
  125. package/dist/core/runtime/presence.js +152 -0
  126. package/dist/core/runtime/promote.d.ts +30 -0
  127. package/dist/core/runtime/promote.js +105 -0
  128. package/dist/core/runtime/reset.d.ts +13 -0
  129. package/dist/core/runtime/reset.js +97 -0
  130. package/dist/core/runtime/revive.d.ts +26 -0
  131. package/dist/core/runtime/revive.js +89 -0
  132. package/dist/core/runtime/roadmap.d.ts +12 -0
  133. package/dist/core/runtime/roadmap.js +52 -0
  134. package/dist/core/runtime/spawn.d.ts +33 -0
  135. package/dist/core/runtime/spawn.js +118 -0
  136. package/dist/core/runtime/stop-guard.d.ts +18 -0
  137. package/dist/core/runtime/stop-guard.js +33 -0
  138. package/dist/core/runtime/tmux.d.ts +88 -0
  139. package/dist/core/runtime/tmux.js +198 -0
  140. package/dist/core/spawn.d.ts +17 -197
  141. package/dist/core/spawn.js +16 -539
  142. package/dist/daemon/crtrd-cli.js +4 -0
  143. package/dist/daemon/crtrd.d.ts +20 -0
  144. package/dist/daemon/crtrd.js +200 -0
  145. package/dist/daemon/manage.d.ts +17 -0
  146. package/dist/daemon/manage.js +57 -0
  147. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +16 -0
  148. package/dist/pi-extensions/canvas-inbox-watcher.js +229 -0
  149. package/dist/pi-extensions/canvas-nav.d.ts +32 -0
  150. package/dist/pi-extensions/canvas-nav.js +536 -0
  151. package/dist/pi-extensions/canvas-stophook.d.ts +17 -0
  152. package/dist/pi-extensions/canvas-stophook.js +373 -0
  153. package/package.json +6 -5
  154. package/dist/commands/agent.d.ts +0 -6
  155. package/dist/commands/agent.js +0 -585
  156. package/dist/commands/debug.d.ts +0 -3
  157. package/dist/commands/debug.js +0 -192
  158. package/dist/commands/job.d.ts +0 -11
  159. package/dist/commands/job.js +0 -384
  160. package/dist/commands/mode.js +0 -231
  161. package/dist/commands/plan.d.ts +0 -4
  162. package/dist/commands/plan.js +0 -322
  163. package/dist/commands/spec.d.ts +0 -3
  164. package/dist/commands/spec.js +0 -299
  165. package/dist/core/__tests__/flow-leaves.test.js +0 -248
  166. package/dist/core/__tests__/job.test.js +0 -310
  167. package/dist/core/__tests__/jobs.test.js +0 -98
  168. package/dist/core/__tests__/spawn.test.js +0 -138
  169. package/dist/core/__tests__/subagents.test.d.ts +0 -1
  170. package/dist/core/__tests__/subagents.test.js +0 -75
  171. package/dist/core/jobs.d.ts +0 -107
  172. package/dist/core/jobs.js +0 -565
  173. package/dist/core/subagents.d.ts +0 -18
  174. package/dist/core/subagents.js +0 -163
  175. package/dist/prompts/agent.d.ts +0 -27
  176. package/dist/prompts/agent.js +0 -184
  177. package/dist/prompts/debug.d.ts +0 -8
  178. package/dist/prompts/debug.js +0 -44
  179. /package/dist/core/__tests__/{flow-leaves.test.d.ts → canvas-inbox-watcher.test.d.ts} +0 -0
  180. /package/dist/core/__tests__/{job.test.d.ts → canvas.test.d.ts} +0 -0
  181. /package/dist/core/__tests__/{jobs.test.d.ts → reset.test.d.ts} +0 -0
  182. /package/dist/{core/__tests__/spawn.test.d.ts → daemon/crtrd-cli.d.ts} +0 -0
@@ -0,0 +1,269 @@
1
+ import { defineLeaf } from '../../core/command.js';
2
+ import { InputError } from '../../core/io.js';
3
+ import { spawnNode } from '../../core/runtime/nodes.js';
4
+ import { interactionDir } from '../../core/artifact.js';
5
+ import { isInTmux, spawnAndDetach } from '../../core/spawn.js';
6
+ import { mkdirSync, existsSync } from 'node:fs';
7
+ import { join, resolve } from 'node:path';
8
+ import { randomBytes } from 'node:crypto';
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';
11
+ /** The asking node's id, or null when run from a bare shell (no parent to route to). */
12
+ function askingNode() {
13
+ return process.env['CRTR_NODE_ID'] ?? null;
14
+ }
15
+ // ---------------------------------------------------------------------------
16
+ // ask
17
+ // ---------------------------------------------------------------------------
18
+ export const humanAsk = defineLeaf({
19
+ name: 'ask',
20
+ help: {
21
+ name: 'human ask',
22
+ 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.',
23
+ guide: 'Use this for quick, open-ended, and nuanced asks alike — not just "formal" multiple-choice. Set `allowFreetext: true` (with `freetextLabel`) when the answer is open-ended; offer a few `options` as starting points even for judgment calls. The kickoff is instant and NEVER blocks — "never block on the result" refers only to not busy-waiting on the job; the human answering on their own time is not a reason to avoid asking or to fall back to inline prose. The deck body is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see `termrender doc -h` for the directive set before authoring one.',
24
+ params: [
25
+ { kind: 'context-file', name: 'deck', required: true, constraint: 'Contains a humanloop deck. Validated before any job is created.', shape: DECK_SCHEMA_HINT },
26
+ { kind: 'flag', name: 'wait', type: 'bool', required: false, constraint: 'Accepted for symmetry with the job contract; the kickoff never blocks.' },
27
+ ],
28
+ output: [
29
+ { name: 'job_id', type: 'string', required: true, constraint: 'Node id of this human interaction. Its answer is pushed to your inbox when the human responds.' },
30
+ { name: 'dir', type: 'string', required: true, constraint: 'Interaction directory holding deck.json/run.json/response.json.' },
31
+ { name: 'follow_up', type: 'string', required: true, constraint: 'A non-blocking status peek. The human may take minutes to hours — never block waiting on this.' },
32
+ ],
33
+ outputKind: 'object',
34
+ effects: [
35
+ 'Creates a kind:"human" node under you and writes deck.json/run.json to the interaction dir.',
36
+ 'Spawns the decision TUI in a detached tmux pane (when in tmux).',
37
+ ],
38
+ },
39
+ run: async (input) => {
40
+ let deck;
41
+ try {
42
+ deck = validateDeck(input['deck']);
43
+ }
44
+ catch (e) {
45
+ throw new InputError({
46
+ error: 'deck_invalid',
47
+ message: String(e),
48
+ field: 'deck',
49
+ next: DECK_SCHEMA_HINT,
50
+ });
51
+ }
52
+ const cwd = process.cwd();
53
+ const jobId = spawnNode({ kind: 'human', parent: askingNode(), cwd, name: 'human-ask', lifecycle: 'terminal' }).node_id;
54
+ const idir = interactionDir(jobId, cwd);
55
+ mkdirSync(idir, { recursive: true });
56
+ atomicWriteJson(deckPath(idir), deck);
57
+ const rc = { mode: 'ask', job_id: jobId };
58
+ atomicWriteJson(join(idir, 'run.json'), rc);
59
+ const { follow_up } = spawnHumanJob(jobId, idir, cwd);
60
+ return { job_id: jobId, dir: idir, follow_up };
61
+ },
62
+ });
63
+ // ---------------------------------------------------------------------------
64
+ // approve
65
+ // ---------------------------------------------------------------------------
66
+ export const humanApprove = defineLeaf({
67
+ name: 'approve',
68
+ help: {
69
+ name: 'human approve',
70
+ 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.',
71
+ guide: 'The body is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see `termrender doc -h` for the directive set before authoring one.',
72
+ params: [
73
+ { kind: 'positional', name: 'title', type: 'string', required: true, constraint: 'The question shown to the human.' },
74
+ { kind: 'flag', name: 'subtitle', type: 'string', required: false, constraint: 'Optional one-line context.' },
75
+ { kind: 'flag', name: 'body', type: 'string', required: false, constraint: 'Optional markdown body.' },
76
+ ],
77
+ output: [
78
+ { name: 'job_id', type: 'string', required: true, constraint: 'Node id of this approval; the {approved, …envelope} result is pushed to your inbox when answered.' },
79
+ { name: 'dir', type: 'string', required: true, constraint: 'Interaction directory.' },
80
+ { name: 'follow_up', type: 'string', required: true, constraint: 'A non-blocking status peek. The human may take minutes to hours — never block waiting on this.' },
81
+ ],
82
+ outputKind: 'object',
83
+ effects: [
84
+ 'Creates a kind:"human" node under you and writes a Yes/No validation deck.',
85
+ 'Spawns the approval TUI in a detached tmux pane (when in tmux).',
86
+ ],
87
+ },
88
+ run: async (input) => {
89
+ const title = input['title'];
90
+ const subtitle = input['subtitle'];
91
+ const body = input['body'];
92
+ const deck = approveDeck(title, {
93
+ ...(subtitle !== undefined ? { subtitle } : {}),
94
+ ...(body !== undefined ? { body } : {}),
95
+ });
96
+ const cwd = process.cwd();
97
+ const jobId = spawnNode({ kind: 'human', parent: askingNode(), cwd, name: 'human-approve', lifecycle: 'terminal' }).node_id;
98
+ const idir = interactionDir(jobId, cwd);
99
+ mkdirSync(idir, { recursive: true });
100
+ atomicWriteJson(deckPath(idir), deck);
101
+ const rc = { mode: 'approve', job_id: jobId, approve_iid: 'approve' };
102
+ atomicWriteJson(join(idir, 'run.json'), rc);
103
+ const { follow_up } = spawnHumanJob(jobId, idir, cwd);
104
+ return { job_id: jobId, dir: idir, follow_up };
105
+ },
106
+ });
107
+ // ---------------------------------------------------------------------------
108
+ // review
109
+ // ---------------------------------------------------------------------------
110
+ export const humanReview = defineLeaf({
111
+ name: 'review',
112
+ help: {
113
+ 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.',
116
+ params: [
117
+ { kind: 'positional', name: 'file', type: 'path', required: true, constraint: 'Absolute path to an existing .md file.' },
118
+ { kind: 'flag', name: 'output', type: 'path', required: false, constraint: 'Where the FeedbackResult JSON is written. Default: <dir>/feedback.json.' },
119
+ ],
120
+ 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
+ ],
128
+ outputKind: 'object',
129
+ effects: [
130
+ '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.',
133
+ ],
134
+ },
135
+ run: async (input) => {
136
+ const fileArg = input['file'];
137
+ const abs = resolve(fileArg);
138
+ if (!existsSync(abs)) {
139
+ throw new InputError({
140
+ error: 'file_not_found',
141
+ message: `file not found: ${abs}`,
142
+ field: 'file',
143
+ next: 'Provide an absolute path to an existing .md file.',
144
+ });
145
+ }
146
+ if (!abs.endsWith('.md')) {
147
+ throw new InputError({
148
+ error: 'invalid_field',
149
+ message: `review requires a .md file: ${abs}`,
150
+ field: 'file',
151
+ next: 'Point `file` at a Markdown (.md) artifact.',
152
+ });
153
+ }
154
+ const cwd = process.cwd();
155
+ const jobId = spawnNode({ kind: 'human', parent: askingNode(), cwd, name: 'human-review', lifecycle: 'terminal' }).node_id;
156
+ const idir = interactionDir(jobId, cwd);
157
+ mkdirSync(idir, { recursive: true });
158
+ const outputArg = input['output'];
159
+ const output = outputArg !== undefined ? outputArg : join(idir, 'feedback.json');
160
+ const rc = { mode: 'review', job_id: jobId, file: abs, output };
161
+ 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;
178
+ },
179
+ });
180
+ // ---------------------------------------------------------------------------
181
+ // notify (no job)
182
+ // ---------------------------------------------------------------------------
183
+ export const humanNotify = defineLeaf({
184
+ name: 'notify',
185
+ help: {
186
+ name: 'human notify',
187
+ summary: 'show a fire-and-forget acknowledgement; creates no job',
188
+ guide: 'The body is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see `termrender doc -h` for the directive set before authoring one.',
189
+ params: [
190
+ { kind: 'positional', name: 'title', type: 'string', required: true, constraint: 'The notification headline.' },
191
+ { kind: 'flag', name: 'body', type: 'string', required: false, constraint: 'Optional markdown body.' },
192
+ ],
193
+ output: [
194
+ { name: 'shown', type: 'boolean', required: true, constraint: 'True if the TUI pane was spawned; false when not in tmux (deck surfaces in `human inbox`).' },
195
+ { name: 'dir', type: 'string', required: true, constraint: 'Interaction directory holding deck.json.' },
196
+ ],
197
+ outputKind: 'object',
198
+ effects: [
199
+ 'Writes a notify deck to the per-project interactions root.',
200
+ 'Spawns the acknowledgement TUI in a detached tmux pane when in tmux. Creates no node.',
201
+ ],
202
+ },
203
+ run: async (input) => {
204
+ const title = input['title'];
205
+ const body = input['body'];
206
+ const deck = notifyDeck(title, body !== undefined ? { body } : {});
207
+ const cwd = process.cwd();
208
+ const id = `nfy-${randomBytes(4).toString('hex')}`;
209
+ const idir = interactionDir(id, cwd);
210
+ mkdirSync(idir, { recursive: true });
211
+ atomicWriteJson(deckPath(idir), deck);
212
+ const rc = { mode: 'notify' };
213
+ atomicWriteJson(join(idir, 'run.json'), rc);
214
+ let shown = false;
215
+ if (isInTmux()) {
216
+ const spawn = spawnAndDetach({
217
+ command: runCmd(idir),
218
+ cwd,
219
+ placement: pickPlacement(),
220
+ killAfterSeconds: 0,
221
+ });
222
+ shown = spawn.status === 'spawned';
223
+ }
224
+ return { shown, dir: idir };
225
+ },
226
+ });
227
+ // ---------------------------------------------------------------------------
228
+ // show (no job, non-blocking passthrough)
229
+ // ---------------------------------------------------------------------------
230
+ export const humanShow = defineLeaf({
231
+ name: 'show',
232
+ help: {
233
+ name: 'human show',
234
+ summary: 'put a file live on screen in a tmux pane via humanloop display',
235
+ guide: 'The pane always watches the file and live-updates on every save — a displayed doc is a live view by definition, so point it at a file something keeps rewriting (a status board, a running summary) and it stays current. The file is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see `termrender doc -h` for the directive set before authoring one.',
236
+ params: [
237
+ { kind: 'positional', name: 'path', type: 'path', required: true, constraint: 'Path to the file to render.' },
238
+ { kind: 'flag', name: 'window', type: 'enum', choices: ['auto', 'split', 'new'], required: false, default: 'auto', constraint: 'Placement. Default auto.' },
239
+ ],
240
+ output: [
241
+ { name: 'pane_id', type: 'string | null', required: true, constraint: 'Tmux pane id, or null when not displayed.' },
242
+ { name: 'reason', type: 'string | null', required: true, constraint: 'Why no pane was created, or null on success.' },
243
+ ],
244
+ outputKind: 'object',
245
+ effects: ['Spawns a live-watch tmux pane when possible. No job. Always exits 0.'],
246
+ },
247
+ run: async (input) => {
248
+ const path = input['path'];
249
+ const windowArg = input['window'];
250
+ const window = windowArg !== undefined ? windowArg : 'auto';
251
+ // `human show` must never fail the caller: any display error degrades to
252
+ // {pane_id:null, reason} with exit 0 (matches humanloop display semantics).
253
+ let paneId;
254
+ try {
255
+ const r = display(path, { window, maxPanes: resolveMaxPanes() });
256
+ paneId = r.paneId;
257
+ }
258
+ catch {
259
+ paneId = undefined;
260
+ }
261
+ if (paneId !== undefined) {
262
+ return { pane_id: paneId, reason: null };
263
+ }
264
+ const reason = isInTmux()
265
+ ? 'renderer unavailable (termrender/uv missing)'
266
+ : 'not in tmux';
267
+ return { pane_id: null, reason };
268
+ },
269
+ });
@@ -0,0 +1,3 @@
1
+ export declare const humanInbox: import("../../core/command.js").LeafDef;
2
+ export declare const humanList: import("../../core/command.js").LeafDef;
3
+ export declare const humanRun: import("../../core/command.js").LeafDef;
@@ -0,0 +1,133 @@
1
+ import { defineLeaf } from '../../core/command.js';
2
+ import { pushFinal } from '../../core/feed/feed.js';
3
+ import { interactionsRoot } from '../../core/artifact.js';
4
+ import { paginate } from '../../core/pagination.js';
5
+ import { join } from 'node:path';
6
+ import { inbox, scanInbox, parseDeck, deckPath, ask, launchReview, readJson, } from '@crouton-kit/humanloop';
7
+ // ---------------------------------------------------------------------------
8
+ // inbox (human-invoked, blocking)
9
+ // ---------------------------------------------------------------------------
10
+ export const humanInbox = defineLeaf({
11
+ name: 'inbox',
12
+ help: {
13
+ name: 'human inbox',
14
+ summary: 'interactively drain pending interactions at your own terminal',
15
+ params: [],
16
+ inputNote: 'No input. Run this at a human terminal — it blocks until the backlog is drained or you quit.',
17
+ output: [{ name: 'drained', type: 'boolean', required: true, constraint: 'True once the loop returns.' }],
18
+ outputKind: 'object',
19
+ effects: ['Resolves pending interactions in the per-project interactions root via the TUI.'],
20
+ },
21
+ run: async () => {
22
+ await inbox([interactionsRoot(process.cwd())]);
23
+ return { drained: true };
24
+ },
25
+ });
26
+ // ---------------------------------------------------------------------------
27
+ // list (read-only, paginated)
28
+ // ---------------------------------------------------------------------------
29
+ export const humanList = defineLeaf({
30
+ name: 'list',
31
+ help: {
32
+ name: 'human list',
33
+ summary: 'paginated list of pending, unclaimed interactions, oldest first',
34
+ params: [
35
+ { kind: 'flag', name: 'limit', type: 'int', required: false, default: 20, constraint: 'Default 20, max 100.' },
36
+ { kind: 'flag', name: 'cursor', type: 'string', required: false, constraint: "Opaque token from a previous response's next_cursor. Omit on first call." },
37
+ ],
38
+ output: [
39
+ { name: 'items', type: 'object[]', required: true, constraint: 'Each: {id, dir, title, kind, blocked_since}. Oldest first.' },
40
+ { name: 'next_cursor', type: 'string | null', required: true, constraint: 'Pass on the next call to continue. null means no more items.' },
41
+ { name: 'total', type: 'integer | null', required: true, constraint: 'Total pending interactions.' },
42
+ ],
43
+ outputKind: 'object',
44
+ effects: ['None. Read-only.'],
45
+ },
46
+ run: async (input) => {
47
+ const limitRaw = input['limit'];
48
+ const limit = Math.min(Math.max(1, limitRaw), 100);
49
+ const cursor = input['cursor'];
50
+ const raw = scanInbox([interactionsRoot(process.cwd())]);
51
+ const items = raw
52
+ .map((i) => ({
53
+ id: i.id,
54
+ dir: i.dir,
55
+ title: i.title !== undefined ? i.title : null,
56
+ kind: i.kind !== undefined ? i.kind : null,
57
+ blocked_since: i.blockedSince,
58
+ }))
59
+ .sort((a, b) => {
60
+ const ka = `${a.blocked_since}|${a.id}`;
61
+ const kb = `${b.blocked_since}|${b.id}`;
62
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
63
+ });
64
+ const page = paginate(items, { limit, cursor }, {
65
+ defaultLimit: 20,
66
+ maxLimit: 100,
67
+ keyOf: (i) => `${i.blocked_since}|${i.id}`,
68
+ total: 'count',
69
+ });
70
+ return { items: page.items, next_cursor: page.next_cursor, total: page.total };
71
+ },
72
+ });
73
+ // ---------------------------------------------------------------------------
74
+ // _run (hidden worker; not listed in branch help)
75
+ // ---------------------------------------------------------------------------
76
+ export const humanRun = defineLeaf({
77
+ name: '_run',
78
+ help: {
79
+ name: 'human _run',
80
+ summary: 'internal: the detached worker that runs the blocking humanloop call at the pane TTY',
81
+ params: [],
82
+ inputNote: 'Internal; invoked by the spawned pane via CRTR_HUMAN_DIR + run.json. Not for manual use.',
83
+ output: [{ name: 'none', type: 'void', required: false, constraint: 'No stdout; writes the job result file directly.' }],
84
+ outputKind: 'object',
85
+ effects: ['Runs the blocking humanloop call; for tracked modes pushes the result as the node\'s final report (fans out to the asking node\'s inbox).'],
86
+ },
87
+ run: async () => {
88
+ const dir = process.env['CRTR_HUMAN_DIR'];
89
+ if (dir === undefined || dir === '') {
90
+ process.exit(1);
91
+ }
92
+ const rc = readJson(join(dir, 'run.json'));
93
+ if (rc === null) {
94
+ process.exit(1);
95
+ }
96
+ try {
97
+ if (rc.mode === 'ask' || rc.mode === 'approve' || rc.mode === 'notify') {
98
+ const deck = parseDeck(deckPath(dir));
99
+ const env = await ask(deck, { dir });
100
+ if (rc.mode === 'ask') {
101
+ await pushFinal(rc.job_id, JSON.stringify(env));
102
+ }
103
+ else if (rc.mode === 'approve') {
104
+ const sel = env.responses.find((r) => r.id === rc.approve_iid)?.selectedOptionId;
105
+ await pushFinal(rc.job_id, JSON.stringify({
106
+ approved: sel === 'yes',
107
+ summary: env.summary,
108
+ responses: env.responses,
109
+ responsePath: env.responsePath,
110
+ completedAt: env.completedAt,
111
+ }));
112
+ }
113
+ // notify: no job — nothing to write
114
+ }
115
+ else if (rc.mode === 'review') {
116
+ // The _run worker is already its own dedicated tmux pane with a TTY, so
117
+ // run nvim directly in it (noTmux) instead of letting launchReview
118
+ // split off a SECOND pane and sit polling. This matches how ask/approve
119
+ // render in-place and avoids the redundant side pane.
120
+ const res = await launchReview(rc.file, {
121
+ output: rc.output,
122
+ noTmux: true,
123
+ });
124
+ await pushFinal(rc.job_id, JSON.stringify(res));
125
+ }
126
+ }
127
+ catch (e) {
128
+ if (rc.job_id !== undefined) {
129
+ await pushFinal(rc.job_id, JSON.stringify({ error: 'human_run_failed', message: String(e) }));
130
+ }
131
+ }
132
+ },
133
+ });
@@ -0,0 +1,43 @@
1
+ export declare const DECK_SCHEMA_HINT: string;
2
+ export interface RunRecord {
3
+ mode: 'ask' | 'approve' | 'notify' | 'review';
4
+ job_id?: string;
5
+ approve_iid?: string;
6
+ file?: string;
7
+ output?: string;
8
+ }
9
+ export declare function resolveMaxPanes(): number;
10
+ export declare function pickPlacement(): 'split-h' | 'new-window';
11
+ export declare function runCmd(dir: string): string;
12
+ export declare function followUpResult(_jobId: string): string;
13
+ export declare function followUpDrain(_jobId: string): string;
14
+ /**
15
+ * 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.
20
+ *
21
+ * Completion routing needs no bookkeeping here: the human node was created
22
+ * 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.
25
+ */
26
+ export declare function spawnHumanJob(jobId: string, idir: string, cwd: string): {
27
+ spawned: boolean;
28
+ follow_up: string;
29
+ paneId?: string;
30
+ };
31
+ export interface HumanResult {
32
+ status: string;
33
+ result?: unknown;
34
+ reason?: string;
35
+ }
36
+ /**
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.
42
+ */
43
+ export declare function waitForFinalReport(nodeId: string, paneId?: string): Promise<HumanResult>;
@@ -0,0 +1,107 @@
1
+ import { readConfig } from '../../core/config.js';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { countPanesInCurrentWindow, spawnAndDetach, shellQuote } from '../../core/spawn.js';
6
+ import { reportsDir } from '../../core/canvas/paths.js';
7
+ export const DECK_SCHEMA_HINT = 'Deck must match the humanloop deck schema: {title?, ' +
8
+ 'source?:{sessionName?,askedBy?,blockedSince?}, ' +
9
+ 'interactions:[{id,title,subtitle?,(body?|bodyPath?),options:[{id,label,' +
10
+ 'description?,shortcut?}],multiSelect?,allowFreetext?,freetextLabel?,' +
11
+ "kind?:'notify'|'validation'|'decision'|'context'|'error'}]}.";
12
+ export function resolveMaxPanes() {
13
+ return readConfig('user').max_panes_per_window;
14
+ }
15
+ export function pickPlacement() {
16
+ return countPanesInCurrentWindow() >= resolveMaxPanes() ? 'new-window' : 'split-h';
17
+ }
18
+ export function runCmd(dir) {
19
+ return `CRTR_HUMAN_DIR=${shellQuote(dir)} crtr human _run`;
20
+ }
21
+ export function followUpResult(_jobId) {
22
+ return "The human's answer is delivered to your inbox when they respond — no need to poll.";
23
+ }
24
+ export function followUpDrain(_jobId) {
25
+ return ('Not in tmux: a human must drain it — run `crtr human inbox` (or re-run ' +
26
+ 'inside tmux). The answer then arrives in your inbox.');
27
+ }
28
+ /**
29
+ * Spawn the detached `_run` pane that drives the humanloop TUI for this node.
30
+ * Returns whether the pane spawned, the follow_up text, and (when spawned) the
31
+ * tmux pane id so a blocking caller (review) can detect the pane dying before
32
+ * the human submits. Degrades to the inbox-drain follow_up when not in tmux /
33
+ * spawn fails — kickoffs are intentionally non-fatal off-tmux.
34
+ *
35
+ * Completion routing needs no bookkeeping here: the human node was created
36
+ * under the asking node as its parent (spawnNode auto-subscribes the parent),
37
+ * so the `pushFinal` the `_run` worker emits fans the answer straight into the
38
+ * asking node's inbox.
39
+ */
40
+ export function spawnHumanJob(jobId, idir, cwd) {
41
+ const spawn = spawnAndDetach({
42
+ command: runCmd(idir),
43
+ cwd,
44
+ jobId,
45
+ placement: pickPlacement(),
46
+ killAfterSeconds: 0,
47
+ });
48
+ if (spawn.status !== 'spawned') {
49
+ return { spawned: false, follow_up: followUpDrain(jobId) };
50
+ }
51
+ return {
52
+ spawned: true,
53
+ follow_up: followUpResult(jobId),
54
+ ...(spawn.paneId !== undefined ? { paneId: spawn.paneId } : {}),
55
+ };
56
+ }
57
+ /** True when a tmux pane is still alive. */
58
+ function paneAlive(paneId) {
59
+ const r = spawnSync('tmux', ['display-message', '-p', '-t', paneId, '#{pane_id}'], {
60
+ encoding: 'utf8',
61
+ });
62
+ return r.status === 0 && r.stdout.trim() !== '';
63
+ }
64
+ /**
65
+ * Block until `nodeId` emits a `final` report (the human submitted) or — when a
66
+ * pane id is given — that pane dies before submitting (the human closed it).
67
+ * Polls once a second: this is a human-time operation, so a coarse poll is fine
68
+ * and sidesteps fs.watch directory-existence races. The `_run` worker writes
69
+ * the humanloop result as the report body (JSON), which we parse back out.
70
+ */
71
+ export function waitForFinalReport(nodeId, paneId) {
72
+ const dir = reportsDir(nodeId);
73
+ const findFinal = () => {
74
+ if (!existsSync(dir))
75
+ return null;
76
+ const files = readdirSync(dir).filter((f) => f.endsWith('-final.md')).sort();
77
+ return files.length > 0 ? join(dir, files[files.length - 1]) : null;
78
+ };
79
+ const parse = (path) => {
80
+ const body = readFileSync(path, 'utf8').replace(/^---[\s\S]*?---\n/, '').trim();
81
+ try {
82
+ return { status: 'done', result: JSON.parse(body) };
83
+ }
84
+ catch {
85
+ return { status: 'done' };
86
+ }
87
+ };
88
+ const immediate = findFinal();
89
+ if (immediate !== null)
90
+ return Promise.resolve(parse(immediate));
91
+ return new Promise((resolve) => {
92
+ const timer = setInterval(() => {
93
+ const p = findFinal();
94
+ if (p !== null) {
95
+ clearInterval(timer);
96
+ resolve(parse(p));
97
+ return;
98
+ }
99
+ if (paneId !== undefined && !paneAlive(paneId)) {
100
+ clearInterval(timer);
101
+ resolve({ status: 'closed', reason: 'review pane closed before submit' });
102
+ }
103
+ }, 1000);
104
+ if (typeof timer.unref === 'function')
105
+ timer.unref();
106
+ });
107
+ }