@crouton-kit/humanloop 0.2.1 → 0.3.2

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.
package/dist/cli.js CHANGED
@@ -1,357 +1,195 @@
1
1
  #!/usr/bin/env node
2
- import { Command, Option } from 'commander';
3
- import { writeFileSync, mkdtempSync } from 'fs';
4
- import { tmpdir } from 'os';
5
- import { resolve, join } from 'path';
6
- import { dispatchToTmuxPane } from './tui/tmux.js';
7
- import { findRecentSessionId } from './conversation/reader.js';
8
- import { launchReview, formatFeedbackSummary } from './editor/review.js';
9
- import { parseDeck } from './inbox/deck-schema.js';
2
+ import { Command } from 'commander';
3
+ import { writeFileSync, mkdirSync, mkdtempSync, existsSync, readFileSync, appendFileSync, statSync, } from 'node:fs';
4
+ import { readdirSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { resolve, join, basename } from 'node:path';
7
+ import { execFileSync } from 'node:child_process';
8
+ import { launchReview } from './editor/review.js';
9
+ import { validateDeck } from './inbox/deck-schema.js';
10
10
  import { ask, inbox } from './api.js';
11
11
  import { display } from './surfaces/display.js';
12
- const program = new Command();
13
- program
14
- .name('hl')
15
- .description('Human-in-the-loop decision TUI.\n' +
16
- '\n' +
17
- 'Use this when you (the agent) need the human to make a material decision\n' +
18
- 'before continuing — design tradeoffs, approval gates, picks between real\n' +
19
- 'alternatives. Blocks on an interactive TUI and returns answers as JSON.\n' +
20
- 'Reach for it when you have 2+ structured questions, or one decision with\n' +
21
- 'genuine tradeoffs; for a single freetext question, ask inline instead.\n' +
22
- '\n' +
23
- 'AUDIENCE — the deck you write is read by a busy, technical human. Use\n' +
24
- 'progressive disclosure so the reader can stop at any layer:\n' +
25
- ' • title — noun-phrase topic (≤4 words). Scannable like an inbox.\n' +
26
- ' • subtitle TL;DR. One plain sentence framing the choice or stakes.\n' +
27
- ' • body — ELI12 markdown. Plain language up top, deeper material\n' +
28
- ' under a heading the reader can skip.\n' +
29
- 'The reader can connect dots — do not write engineer-to-engineer jargon.\n' +
30
- 'See `hl ask --help` for content guidance and a worked example.\n' +
31
- '\n' +
32
- 'Workflow:\n' +
33
- ' 1. Write a deck file matching `hl schema` (a JSON object with interactions[]).\n' +
34
- ' 2. Run `hl ask <file>`; it blocks and prints a ResolutionEnvelope JSON to stdout.\n' +
35
- ' 3. Parse the JSON; look up answers by `id` (humans can skip questions).')
36
- .version('0.1.0')
37
- .addHelpText('after', '\nExamples:\n' +
38
- ' hl schema # print the input JSON schema\n' +
39
- ' hl schema response # print the resolution-envelope schema\n' +
40
- ' hl ask deck.json # open TUI, block, print envelope JSON\n' +
41
- ' hl ask deck.json --dir /tmp/ix # store progress/response.json in /tmp/ix\n' +
42
- ' hl ask deck.json --no-tmux # run in current pane even inside tmux\n' +
43
- ' hl inbox /tmp/box # resolve pending interactions under a root\n' +
44
- ' hl display plan.md # live-render a file in a tmux pane\n');
45
- program
46
- .command('ask')
47
- .description('Open the decisions TUI on <file> and block until the human finishes review.\n' +
48
- 'Prints a ResolutionEnvelope JSON to stdout (or to --output / --write-to).')
49
- .argument('<file>', 'Path to deck JSON file (see `hl schema` for format)')
50
- .option('--dir <interaction-dir>', 'Interaction directory holding progress.json/response.json. Default: a managed temp dir under os.tmpdir().')
51
- .option('--session-id <id>', 'Claude session ID; enables per-interaction visual context from conversation history. Defaults to the most recent session in cwd.')
52
- .option('--no-visuals', 'Skip visual context generation (faster, no haiku calls)')
53
- .option('--output <path>', 'Write result JSON to <path> instead of stdout')
54
- .option('--no-tmux', 'Do not auto-dispatch the TUI to a new tmux pane even when $TMUX is set')
55
- .addOption(new Option('--write-to <path>', 'internal: tmux child mode').hideHelp())
56
- .addHelpText('after', '\n' +
57
- 'CONTENT — what to write in each interaction\n' +
58
- ' The deck is read by a busy, technical human. Use progressive\n' +
59
- ' disclosure so the reader can stop at any layer:\n' +
60
- '\n' +
61
- ' title Noun-phrase topic (≤4 words). The *thing* being\n' +
62
- ' decided, not the decision. \'Database\' not \'Use\n' +
63
- ' Postgres\'. The reader scans titles like an inbox.\n' +
64
- ' subtitle TL;DR — one plain-English sentence framing the\n' +
65
- ' choice or stakes. Action-ready if the call is\n' +
66
- ' obvious. No jargon, no library names without\n' +
67
- ' context.\n' +
68
- ' body ELI12 markdown. Audience: a smart engineer joining\n' +
69
- ' the codebase — capable, but does not want a wall of\n' +
70
- ' jargon. Lead with what is at stake in plain language.\n' +
71
- ' Tuck anything denser (technical specifics, alternatives\n' +
72
- ' considered, edge cases, related context) under a\n' +
73
- ' heading like `## Details` or `## Alternatives` so the\n' +
74
- ' reader can skip past it. Every layer below the TL;DR\n' +
75
- ' is optional reading.\n' +
76
- '\n' +
77
- ' AVOID: walls of jargon; raw schema dumps or stack traces in body;\n' +
78
- ' titles that bury the topic; subtitles that restate the title;\n' +
79
- ' options that are not real alternatives; asking the human anything\n' +
80
- ' you could decide yourself from the code.\n' +
81
- '\n' +
82
- 'INPUT FORMAT\n' +
83
- ' JSON file with an `interactions` array. Each interaction has an `id`,\n' +
84
- ' `title`, `options[]`, and optional `subtitle`, `body`, `allowFreetext`.\n' +
85
- ' Run `hl schema` for the full schema. Example with pyramid content:\n' +
86
- ' {\n' +
87
- ' "interactions": [\n' +
88
- ' {\n' +
89
- ' "id": "db",\n' +
90
- ' "title": "Database",\n' +
91
- ' "subtitle": "Postgres or SQLite for the new capture store?",\n' +
92
- ' "body": "Two services will write at the same time, which is the crux.\\n\\nPostgres handles concurrent writes natively. SQLite serializes them — fine at low traffic, but we expect bursts.\\n\\n## Details\\nSQLite WAL still serializes writers; Postgres uses MVCC.",\n' +
93
- ' "options": [\n' +
94
- ' {"id":"pg","label":"Postgres"},\n' +
95
- ' {"id":"sqlite","label":"SQLite"}\n' +
96
- ' ],\n' +
97
- ' "allowFreetext": true\n' +
98
- ' },\n' +
99
- ' {\n' +
100
- ' "id": "retry",\n' +
101
- ' "title": "Retry policy",\n' +
102
- ' "subtitle": "How aggressively should we retry publish failures?",\n' +
103
- ' "body": "Affects the reliability budget. Too aggressive and we hammer downstream during outages; too lax and transient blips become user-visible.",\n' +
104
- ' "options": [],\n' +
105
- ' "allowFreetext": true\n' +
106
- ' }\n' +
107
- ' ]\n' +
108
- ' }\n' +
109
- '\n' +
110
- 'OUTPUT FORMAT (stdout on success, JSON — a ResolutionEnvelope)\n' +
111
- ' {\n' +
112
- ' "summary": "<one line per answered interaction>",\n' +
113
- ' "responsePath": "/abs/path/to/<dir>/response.json",\n' +
114
- ' "schema": "humanloop.response/v2",\n' +
115
- ' "responses": [ ... ],\n' +
116
- ' "completedAt": "2026-04-20T15:23:00.000Z"\n' +
117
- ' }\n' +
118
- '\n' +
119
- ' On-disk <dir>/response.json holds only { responses, completedAt }.\n' +
120
- ' Run `hl schema response` for the envelope schema.\n' +
121
- '\n' +
122
- ' Response shape:\n' +
123
- ' { id: string, selectedOptionId?: string, freetext?: string }\n' +
124
- '\n' +
125
- ' The human can skip interactions. `responses` may have FEWER entries than\n' +
126
- ' input interactions — look up by `id`, do not assume index alignment.\n' +
127
- '\n' +
128
- 'BEHAVIOR\n' +
129
- ' tmux When $TMUX is set, the TUI auto-splits into a new pane to the\n' +
130
- ' right (-d keeps focus on the caller). Disable with --no-tmux.\n' +
131
- ' storage progress.json/response.json live in --dir (default: a managed\n' +
132
- ' temp dir). Responses persist atomically after every change;\n' +
133
- ' a hard kill resumes from progress.json on the next run.\n' +
134
- ' response.json is written when the human finishes.\n' +
135
- ' visuals With --session-id (or auto-detected) haiku generates a short\n' +
136
- ' ANSI context block per interaction from recent conversation turns.\n' +
137
- '\n' +
138
- 'EXIT CODES\n' +
139
- ' 0 success — result JSON emitted\n' +
140
- ' 1 error — message on stderr (file missing, invalid JSON, empty\n' +
141
- ' interactions, no TTY, etc.)\n')
142
- .action(async (file, opts) => {
143
- const sessionId = opts.visuals
144
- ? (opts.sessionId || findRecentSessionId(process.cwd()) || findRecentSessionId() || undefined)
145
- : undefined;
146
- const dir = opts.dir ? resolve(opts.dir) : mkdtempSync(join(tmpdir(), 'hl-ix-'));
147
- const emit = (result) => {
148
- const json = JSON.stringify(result, null, 2) + '\n';
149
- if (opts.writeTo) {
150
- writeFileSync(opts.writeTo, json);
151
- }
152
- else if (opts.output) {
153
- writeFileSync(opts.output, json);
154
- }
155
- else {
156
- process.stdout.write(json);
157
- }
158
- };
12
+ import { scanInbox } from './inbox/scan.js';
13
+ import { deckPath, atomicWriteJson, readJson, responsePath, } from './inbox/convention.js';
14
+ // ── Version ───────────────────────────────────────────────────────────────────
15
+ const HL_VERSION = '0.2.1';
16
+ function emitError(err, exitCode = 1) {
17
+ process.stdout.write(JSON.stringify(err) + '\n');
18
+ process.exit(exitCode);
19
+ }
20
+ function emitStderrError(err, exitCode = 1) {
21
+ process.stderr.write(JSON.stringify(err) + '\n');
22
+ process.exit(exitCode);
23
+ }
24
+ // ── stdin reader ──────────────────────────────────────────────────────────────
25
+ function readStdin() {
26
+ let content = '';
27
+ const stdinResult = { ok: false, value: '' };
159
28
  try {
160
- if (process.env.TMUX && opts.tmux && !opts.writeTo) {
161
- try {
162
- const result = await dispatchToTmuxPane(file, { sessionId, visuals: opts.visuals, dir });
163
- emit(result);
164
- process.exit(0);
165
- }
166
- catch (err) {
167
- process.stderr.write(`tmux dispatch failed, running locally: ${err instanceof Error ? err.message : String(err)}\n`);
168
- }
169
- }
170
- const deck = parseDeck(resolve(file));
171
- const result = await ask(deck, { dir, sessionId });
172
- emit(result);
173
- process.exit(0);
29
+ const _io = {};
30
+ void _io;
31
+ stdinResult.value = readFileSync('/dev/stdin', 'utf8');
32
+ stdinResult.ok = true;
174
33
  }
175
- catch (err) {
176
- const msg = err instanceof Error ? err.message : String(err);
177
- process.stderr.write(`ERROR: ${msg}\n`);
178
- if (msg.includes('ENOENT') || msg.includes('no such file')) {
179
- process.stderr.write('\nFix: pass a path to an existing deck JSON file.\nSee format: hl schema\n');
180
- }
181
- else if (msg.includes('not valid JSON') || msg.includes('JSON')) {
182
- process.stderr.write('\nFix: the deck file must be valid JSON matching `hl schema`.\n');
183
- }
184
- else if (msg.includes('TTY')) {
185
- process.stderr.write('\nFix: hl needs an interactive terminal. If the caller captures stdin,\nrun inside tmux so hl can auto-dispatch the TUI to a new pane, or pipe\nstdin from /dev/tty.\n');
186
- }
187
- else {
188
- process.stderr.write('\nFix: the deck file must match `hl schema`. Run `hl schema` to see the required shape.\n');
189
- }
190
- process.exit(1);
34
+ catch (stdinErr) {
35
+ process.stderr.write(`[hl] stdin read error: ${stdinErr instanceof Error ? stdinErr.message : String(stdinErr)}\n`);
191
36
  }
192
- });
193
- program
194
- .command('inbox')
195
- .description('Resolve pending interactions across one or more root directories.\n' +
196
- 'Each root\'s immediate subdirs are treated as interaction dirs (a dir\n' +
197
- 'with deck.json and no response.json is pending). Lists them, lets the\n' +
198
- 'human pick and resolve one at a time, writing each response.json.')
199
- .argument('<roots...>', 'Root dir(s) whose immediate subdirs are interaction dirs')
200
- .addHelpText('after', '\n' +
201
- 'BEHAVIOR\n' +
202
- ' Runs in the current terminal (requires a TTY). Up/down (or j/k) to\n' +
203
- ' navigate, enter to resolve the selected interaction, q/esc to quit.\n' +
204
- ' After each resolution the list rescans — resolved items drop out.\n' +
205
- '\n' +
206
- 'EXIT CODES\n' +
207
- ' 0 finished (human quit, or nothing pending)\n' +
208
- ' 1 error (no TTY, unreadable root, etc.)\n' +
209
- '\n' +
210
- 'Examples:\n' +
211
- ' hl inbox /tmp/box\n' +
212
- ' hl inbox ~/.sisyphus/asks ~/.crtr/pending\n')
213
- .action(async (roots) => {
37
+ content = stdinResult.value;
38
+ return content;
39
+ }
40
+ function parseStdinJson() {
41
+ const raw = readStdin().trim();
42
+ if (!raw) {
43
+ emitStderrError({
44
+ error: 'bad_stdin_json',
45
+ message: 'No input on stdin. Expected a JSON object.',
46
+ next: "Pipe a JSON object to stdin, e.g.: echo '{\"deck\":{...}}' | hl deck ask",
47
+ });
48
+ }
49
+ let parsed;
50
+ const parseResult = { ok: false };
214
51
  try {
215
- await inbox(roots.map((r) => resolve(r)));
216
- process.exit(0);
52
+ const _p = {};
53
+ void _p;
54
+ parseResult.value = JSON.parse(raw);
55
+ parseResult.ok = true;
217
56
  }
218
- catch (err) {
219
- const msg = err instanceof Error ? err.message : String(err);
220
- process.stderr.write(`ERROR: ${msg}\n`);
221
- if (msg.includes('TTY')) {
222
- process.stderr.write('\nFix: hl inbox needs an interactive terminal.\n');
223
- }
224
- process.exit(1);
57
+ catch (parseErr) {
58
+ emitStderrError({
59
+ error: 'bad_stdin_json',
60
+ message: `stdin is not valid JSON: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`,
61
+ received: raw.slice(0, 200),
62
+ next: 'Pipe a valid JSON object to stdin.',
63
+ });
225
64
  }
226
- });
227
- program
228
- .command('display')
229
- .description('Render <path> live in a tmux pane (via the managed termrender). The pane\n' +
230
- 'live-updates as the file changes. Auto-splits, or opens a new window when\n' +
231
- 'the current window is at its pane budget.')
232
- .argument('<path>', 'Path to the markdown/text file to render')
233
- .option('--no-watch', 'Render once instead of live-watching the file')
234
- .option('--new-window', 'Open in a new tmux window instead of splitting the current one')
235
- .addHelpText('after', '\n' +
236
- 'EXIT CODES\n' +
237
- ' 0 spawned (prints the pane id) — or no-op when not in tmux / renderer\n' +
238
- ' unavailable (message on stderr)\n' +
239
- '\n' +
240
- 'Examples:\n' +
241
- ' hl display plan.md\n' +
242
- ' hl display plan.md --no-watch\n' +
243
- ' hl display plan.md --new-window\n')
244
- .action((path, opts) => {
245
- const res = display(resolve(path), {
246
- watch: opts.watch,
247
- window: opts.newWindow ? 'new' : 'auto',
248
- });
249
- if (res.paneId) {
250
- process.stdout.write(`opened in tmux pane ${res.paneId} (live — edits to the file refresh the view)\n`);
65
+ if (parseResult.ok)
66
+ parsed = parseResult.value;
67
+ return parsed;
68
+ }
69
+ function jobLogPath(dir) {
70
+ return join(dir, 'job.log');
71
+ }
72
+ function appendJobLog(dir, entry) {
73
+ const line = { ts: new Date().toISOString(), ...entry };
74
+ const serialized = JSON.stringify(line) + '\n';
75
+ const logResult = { wrote: false };
76
+ try {
77
+ const _l = {};
78
+ void _l;
79
+ appendFileSync(jobLogPath(dir), serialized);
80
+ logResult.wrote = true;
251
81
  }
252
- else {
253
- process.stderr.write('display: no pane opened (not in tmux, or termrender unavailable)\n');
82
+ catch (writeErr) {
83
+ void Object.assign(logResult, { error: String(writeErr) }); // best-effort; never throws
254
84
  }
255
- process.exit(0);
256
- });
257
- program
258
- .command('propose')
259
- .description('Open a markdown document in a read-only editor review session and block\n' +
260
- 'until the human finishes and quits. Use this when you have produced a\n' +
261
- 'document (plan, design doc, spec, draft) and need targeted human review\n' +
262
- 'before continuing — not a structured decision (use `hl ask` for that).')
263
- .argument('<file>', 'Path to the markdown (.md) file to get feedback on')
264
- .option('--output <path>', 'Path for the answers JSON (live autosave + finalized on exit). Default: <file>.feedback.json')
265
- .option('--editor <bin>', 'Editor binary to use. Default: first of nvim, vim on PATH')
266
- .option('--no-tmux', 'Run the editor in the current terminal instead of a tmux split pane')
267
- .addHelpText('after', '\n' +
268
- 'WHAT THIS IS FOR\n' +
269
- ' Freeform review of a markdown doc you wrote. The human leaves comments\n' +
270
- ' anchored to real source lines or visual selections using native vim\n' +
271
- ' keybindings — you do not predefine questions; the human tells you what\n' +
272
- ' is wrong and where.\n' +
273
- '\n' +
274
- 'BEHAVIOR\n' +
275
- ' • Opens the file READ-ONLY in a CLEAN editor (nvim -u NONE: no\n' +
276
- ' init.lua / LazyVim / plugins / keymaps). Look/feel is ONLY the\n' +
277
- ' user\'s gloam colorscheme + built-in treesitter markdown\n' +
278
- ' highlighting. Review keys are buffer-scoped on <Space>,\n' +
279
- ' plus :HL* commands.\n' +
280
- ' • When $TMUX is set, opens in a split pane (disable with --no-tmux);\n' +
281
- ' otherwise takes over the current terminal.\n' +
282
- ' • Comments autosave to the answers JSON continuously. A killed/closed\n' +
283
- ' session RESUMES from the autosave on next run.\n' +
284
- ' • ANY quit submits. On editor exit the JSON is finalized (submitted:true)\n' +
285
- ' and echoed to stdout, then the command exits 0.\n' +
286
- ' • Quitting with zero comments sets approved:true ("looks good").\n' +
287
- '\n' +
288
- ' In-editor (<Space> maps; or use the :HL* commands):\n' +
289
- ' <Space>c / :HLComment Comment on the visual selection or current line\n' +
290
- ' <Space>l / :HLList Toggle comments list\n' +
291
- ' <Space>u / :HLUndo Undo last comment\n' +
292
- ' <Space>s / :HLSubmit Submit & quit\n' +
293
- '\n' +
294
- 'OUTPUT\n' +
295
- ' stdout is a COMPACT listing (kept small so it does not clog context):\n' +
296
- ' a header with the source path, then per comment:\n' +
297
- '\n' +
298
- ' 1. L46:5-35\n' +
299
- ' text: <the original text in that span>\n' +
300
- ' comment: <the human\'s note>\n' +
301
- '\n' +
302
- ' Range is L<line> (whole line), L<line>:<colStart>-<colEnd> (partial\n' +
303
- ' selection), or L<l1>-<l2> / L<l1>:<c1>-<l2>:<c2> across lines. text\n' +
304
- ' is the exact selected quote, or the whole line(s) when there was no\n' +
305
- ' partial selection. Zero comments => "approved" (looks good, proceed).\n' +
306
- '\n' +
307
- ' The FULL record is written to --output (default <file>.feedback.json);\n' +
308
- ' stdout ends with its path + this schema (read the file only if you\n' +
309
- ' actually need the verbose fields — usually you do not):\n' +
310
- ' {file, submitted, approved,\n' +
311
- ' comments:[{id, line, endLine, quote?, colStart?, colEnd?,\n' +
312
- ' lineText, comment, createdAt}],\n' +
313
- ' submittedAt, savedAt}\n' +
314
- ' cols are 0-based byte offsets, colEnd exclusive. Act on each comment\n' +
315
- ' via the source path + range (quote/cols when present).\n' +
316
- '\n' +
317
- 'EXIT CODES\n' +
318
- ' 0 finished — feedback summary emitted\n' +
319
- ' 1 error (file missing, no nvim/vim found)\n' +
320
- '\n' +
321
- 'Examples:\n' +
322
- ' hl propose plan.md\n' +
323
- ' hl propose plan.md --output /tmp/fb.json\n' +
324
- ' hl propose plan.md --no-tmux\n' +
325
- ' hl propose plan.md --editor vim\n')
326
- .action(async (file, opts) => {
85
+ }
86
+ // ── job dir resolution ────────────────────────────────────────────────────────
87
+ function resolveJobDir(jobId) {
88
+ if (jobId.startsWith('/'))
89
+ return jobId;
90
+ const td = tmpdir();
91
+ const candidate = join(td, jobId);
92
+ if (existsSync(candidate))
93
+ return candidate;
94
+ let entries = [];
95
+ const scanResult = { entries: [] };
327
96
  try {
328
- const output = opts.output ?? `${file}.feedback.json`;
329
- const result = await launchReview(file, { output, editor: opts.editor, noTmux: !opts.tmux });
330
- process.stdout.write(formatFeedbackSummary(result, resolve(output)) + '\n');
331
- process.exit(0);
97
+ const _s = {};
98
+ void _s;
99
+ scanResult.entries = readdirSync(td);
332
100
  }
333
- catch (err) {
334
- const msg = err instanceof Error ? err.message : String(err);
335
- process.stderr.write(`ERROR: ${msg}\n`);
336
- if (msg.startsWith('Markdown file not found')) {
337
- process.stderr.write('\nFix: pass a path to an existing markdown file.\n');
101
+ catch (readdirErr) {
102
+ void Object.assign(scanResult, { error: String(readdirErr) }); // opportunistic scan
103
+ return candidate;
104
+ }
105
+ entries = scanResult.entries;
106
+ for (const e of entries) {
107
+ if (e === jobId || basename(e) === jobId) {
108
+ const full = join(td, e);
109
+ if (existsSync(join(full, 'deck.json')))
110
+ return full;
338
111
  }
339
- else if (msg.startsWith('Editor not found') || msg.startsWith('No editor found')) {
340
- process.stderr.write('\nFix: install Neovim (`brew install neovim`) or pass --editor <path>.\n');
112
+ }
113
+ return candidate;
114
+ }
115
+ function detectJobKind(dir) {
116
+ if (existsSync(join(dir, 'deck.json')))
117
+ return 'deck';
118
+ if (existsSync(join(dir, 'feedback.json')) || existsSync(join(dir, 'review.vim')))
119
+ return 'review';
120
+ return 'inbox';
121
+ }
122
+ function tryParseJson(text) {
123
+ try {
124
+ const _j = {};
125
+ void _j;
126
+ return JSON.parse(text);
127
+ }
128
+ catch (parseErr) {
129
+ void String(parseErr);
130
+ return null;
131
+ }
132
+ }
133
+ function readLogLines(logPath) {
134
+ try {
135
+ const _r = {};
136
+ void _r;
137
+ return readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
138
+ }
139
+ catch (readErr) {
140
+ void String(readErr);
141
+ return [];
142
+ }
143
+ }
144
+ function detectJobState(dir) {
145
+ if (existsSync(join(dir, 'response.json')))
146
+ return 'done';
147
+ if (existsSync(join(dir, 'feedback.json'))) {
148
+ const fb = tryParseJson(readFileSync(join(dir, 'feedback.json'), 'utf8'));
149
+ if (fb && fb.submitted)
150
+ return 'done';
151
+ }
152
+ const logPath = jobLogPath(dir);
153
+ if (existsSync(logPath)) {
154
+ const lines = readLogLines(logPath);
155
+ for (let i = lines.length - 1; i >= 0; i--) {
156
+ const entry = tryParseJson(lines[i]);
157
+ if (!entry)
158
+ continue;
159
+ if (entry.event === 'job_failed')
160
+ return 'failed';
161
+ if (entry.event === 'job_canceled')
162
+ return 'canceled';
163
+ if (entry.event === 'job_finished')
164
+ return 'done';
341
165
  }
342
- process.exit(1);
343
166
  }
344
- });
167
+ return 'live';
168
+ }
169
+ function lastLogEvent(dir) {
170
+ const logPath = jobLogPath(dir);
171
+ if (!existsSync(logPath))
172
+ return null;
173
+ const lines = readLogLines(logPath);
174
+ for (let i = lines.length - 1; i >= 0; i--) {
175
+ const entry = tryParseJson(lines[i]);
176
+ if (entry)
177
+ return entry;
178
+ }
179
+ return null;
180
+ }
181
+ // ── tmux child-mode env var (internal, not a CLI flag) ───────────────────────
182
+ // When the parent dispatches to a tmux pane, it sets HL_WRITE_TO=<path> so the
183
+ // child writes its result there instead of stdout. Implementation detail only.
184
+ const INTERNAL_WRITE_TO = process.env['HL_WRITE_TO'];
185
+ // ── Schemas ───────────────────────────────────────────────────────────────────
345
186
  const REQUEST_SCHEMA = {
346
187
  $schema: 'https://json-schema.org/draft/2020-12/schema',
347
- description: 'Input schema for hl ask (v2)',
188
+ description: 'Input schema for hl deck ask (v2)',
348
189
  type: 'object',
349
190
  required: ['interactions'],
350
191
  properties: {
351
- title: {
352
- type: 'string',
353
- description: 'Optional deck title shown in the TUI header',
354
- },
192
+ title: { type: 'string', description: 'Optional deck title shown in the TUI header' },
355
193
  interactions: {
356
194
  type: 'array',
357
195
  minItems: 1,
@@ -359,14 +197,13 @@ const REQUEST_SCHEMA = {
359
197
  type: 'object',
360
198
  required: ['id', 'title', 'options'],
361
199
  properties: {
362
- id: { type: 'string', description: 'Unique identifier. Used to look up answers in the output — never assume index alignment.' },
363
- title: { type: 'string', description: 'Noun-phrase topic (≤4 words) — the *thing* being decided, not the decision. \'Database\' not \'Use Postgres\'. The reader scans titles like an inbox.' },
364
- subtitle: { type: 'string', description: 'TL;DR — one plain-English sentence framing the choice or stakes. Action-ready if the call is obvious. No jargon, no library names without context.' },
365
- body: { type: 'string', description: 'ELI12 markdown. Audience: a smart engineer joining the codebase — capable, but does not want a wall of jargon. Lead with what is at stake in plain language. Tuck anything denser (technical specifics, alternatives considered, edge cases) under a heading like `## Details` or `## Alternatives` so the reader can skip past. Every layer below the TL;DR is optional reading.' },
366
- bodyPath: { type: 'string', description: 'Path to a markdown file used in place of `body`; inlined before mount, resolved relative to the deck JSON\'s directory and confined to it. Same content guidance as `body`.' },
200
+ id: { type: 'string', description: 'Unique identifier.' },
201
+ title: { type: 'string', description: 'Noun-phrase topic (≤4 words).' },
202
+ subtitle: { type: 'string' },
203
+ body: { type: 'string', description: 'ELI12 markdown body.' },
204
+ bodyPath: { type: 'string', description: 'Path to a markdown file used in place of body.' },
367
205
  options: {
368
206
  type: 'array',
369
- description: 'Selectable choices. Empty for freetext-only interactions.',
370
207
  items: {
371
208
  type: 'object',
372
209
  required: ['id', 'label'],
@@ -374,26 +211,13 @@ const REQUEST_SCHEMA = {
374
211
  id: { type: 'string' },
375
212
  label: { type: 'string' },
376
213
  description: { type: 'string' },
377
- shortcut: {
378
- type: 'string',
379
- description: 'Single char shortcut. Auto-assigned if absent. Avoid: c r n p q j k space',
380
- },
214
+ shortcut: { type: 'string', description: 'Single char. Auto-assigned if absent.' },
381
215
  },
382
216
  },
383
217
  },
384
- allowFreetext: {
385
- type: 'boolean',
386
- description: 'If true, user can add a freetext comment (or respond freely if options is empty)',
387
- },
388
- freetextLabel: {
389
- type: 'string',
390
- description: 'Prompt shown above the freetext input. Default: "Comment" or "Response"',
391
- },
392
- kind: {
393
- type: 'string',
394
- enum: ['notify', 'validation', 'decision', 'context', 'error'],
395
- description: 'Display hint — opaque to humanloop, used by consumers for inbox icons',
396
- },
218
+ allowFreetext: { type: 'boolean' },
219
+ freetextLabel: { type: 'string' },
220
+ kind: { type: 'string', enum: ['notify', 'validation', 'decision', 'context', 'error'] },
397
221
  },
398
222
  },
399
223
  },
@@ -402,54 +226,654 @@ const REQUEST_SCHEMA = {
402
226
  const RESPONSE_SCHEMA = {
403
227
  $schema: 'https://json-schema.org/draft/2020-12/schema',
404
228
  $id: 'humanloop.response/v2',
405
- description: 'Resolution envelope emitted by `hl ask` / returned by ask(). The on-disk <dir>/response.json holds only { responses, completedAt }; responsePath points at it.',
229
+ description: 'Resolution envelope emitted by hl deck ask / returned by ask().',
406
230
  type: 'object',
407
231
  required: ['summary', 'responsePath', 'schema', 'responses', 'completedAt'],
408
232
  properties: {
409
- summary: {
410
- type: 'string',
411
- description: 'Deterministic, no-LLM. One line per answered interaction: "<title>: <option label>[ — <freetext>]".',
412
- },
413
- responsePath: {
414
- type: 'string',
415
- description: 'Absolute path to the on-disk response.json ({ responses, completedAt }).',
416
- },
417
- schema: {
418
- const: 'humanloop.response/v2',
419
- description: 'Identifies this response contract.',
420
- },
233
+ summary: { type: 'string' },
234
+ responsePath: { type: 'string' },
235
+ schema: { const: 'humanloop.response/v2' },
421
236
  responses: {
422
237
  type: 'array',
423
- description: 'Inline answers. May have FEWER entries than input interactions — humans can skip. Look up by id.',
424
238
  items: {
425
239
  type: 'object',
426
240
  required: ['id'],
427
241
  properties: {
428
242
  id: { type: 'string' },
429
243
  selectedOptionId: { type: 'string' },
244
+ selectedOptionIds: { type: 'array', items: { type: 'string' } },
430
245
  freetext: { type: 'string' },
431
246
  },
432
247
  },
433
248
  },
434
- completedAt: {
435
- type: 'string',
436
- description: 'ISO 8601 timestamp when the human finished.',
249
+ completedAt: { type: 'string' },
250
+ },
251
+ };
252
+ const FEEDBACK_SCHEMA = {
253
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
254
+ description: 'FeedbackResult written by hl review open / launchReview().',
255
+ type: 'object',
256
+ required: ['file', 'submitted', 'approved', 'comments', 'savedAt'],
257
+ properties: {
258
+ file: { type: 'string', description: 'Absolute path to the reviewed markdown file.' },
259
+ submitted: { type: 'boolean' },
260
+ approved: { type: 'boolean', description: 'True when submitted with zero comments.' },
261
+ comments: {
262
+ type: 'array',
263
+ items: {
264
+ type: 'object',
265
+ required: ['id', 'line', 'endLine', 'lineText', 'comment', 'createdAt'],
266
+ properties: {
267
+ id: { type: 'string' },
268
+ line: { type: 'integer', description: '1-based source line (start).' },
269
+ endLine: { type: 'integer', description: '1-based source line (end).' },
270
+ quote: { type: 'string' },
271
+ colStart: { type: 'integer' },
272
+ colEnd: { type: 'integer' },
273
+ lineText: { type: 'string' },
274
+ comment: { type: 'string' },
275
+ createdAt: { type: 'string' },
276
+ },
277
+ },
437
278
  },
279
+ submittedAt: { type: 'string' },
280
+ savedAt: { type: 'string' },
438
281
  },
439
282
  };
283
+ // ── Commander tree ────────────────────────────────────────────────────────────
284
+ const program = new Command();
440
285
  program
441
- .command('schema')
442
- .description('Print a JSON Schema. `request` (default) = the `hl ask` deck input; `response` = the resolution envelope.')
443
- .argument('[kind]', 'request | response', 'request')
444
- .addHelpText('after', '\n' +
445
- 'Use `request` to learn the input format for `hl ask`; `response` to learn\n' +
446
- 'the envelope `hl ask` prints (schema id "humanloop.response/v2").\n' +
286
+ .name('hl')
287
+ .description(`hl ${HL_VERSION} human-in-the-loop TUI bridge for agents\n` +
288
+ '\n' +
289
+ 'I/O contract: every leaf reads ONE JSON object from stdin; writes ONE JSON\n' +
290
+ 'object (or JSONL for streams) to stdout; exits 0 on success, non-zero on\n' +
291
+ 'error. Errors are always a JSON object on stdout:\n' +
292
+ ' { error: <code>, message, next } — codes: bad_stdin_json | bad_input |\n' +
293
+ ' deck_invalid | file_not_found | editor_not_found | job_not_found |\n' +
294
+ ' not_ready | not_in_tmux | internal\n' +
295
+ '\n' +
296
+ 'Concepts:\n' +
297
+ ' deck — structured set of interactions (questions) for the human\n' +
298
+ ' review — freeform markdown document review with anchored comments\n' +
299
+ ' view — passive live render of a file in a tmux pane\n' +
300
+ ' inbox — list/resolve all pending interactions across root dirs\n' +
301
+ ' job — a running or completed kickoff (deck ask / review / inbox)\n' +
302
+ ' schema — JSON Schema for deck, resolution, or feedback payloads\n' +
303
+ '\n' +
304
+ 'Subtrees:\n' +
305
+ ' hl deck — write questions, get answers | use when: material decisions\n' +
306
+ ' hl review — markdown doc review | use when: doc feedback needed\n' +
307
+ ' hl view — live render in pane | use when: displaying a file\n' +
308
+ ' hl inbox — browse pending interactions | use when: clearing a backlog\n' +
309
+ ' hl job — inspect/wait/cancel running jobs | use when: polling job output\n' +
310
+ ' hl schema — print JSON Schemas | use when: validating inputs\n' +
311
+ '\n' +
312
+ 'Globals: -h / --help on any node.\n')
313
+ .helpOption('-h, --help', 'Show help')
314
+ .addHelpCommand(false);
315
+ // ── deck ──────────────────────────────────────────────────────────────────────
316
+ const deckCmd = program.command('deck').description('Write questions, get answers from the human.');
317
+ deckCmd
318
+ .command('ask')
319
+ .description('Kickoff: spawn the decisions TUI and return immediately.\n' +
320
+ '\n' +
321
+ 'stdin { deck: object (required), dir?: string|null,\n' +
322
+ ' sessionId?: string|null, visuals?: bool=true, tmux?: bool=true }\n' +
323
+ 'stdout { job_id: string, dir: string (absolute), follow_up: string }\n' +
324
+ '\n' +
325
+ 'Effects: writes <dir>/deck.json, <dir>/progress.json (live),\n' +
326
+ ' <dir>/response.json (on finish), <dir>/job.log (JSONL).\n' +
327
+ ' Spawns TUI detached in a tmux pane when tmux=true and $TMUX set.\n')
328
+ .helpOption('-h, --help', 'Show help')
329
+ .action(async () => {
330
+ const input = parseStdinJson();
331
+ if (!input.deck || typeof input.deck !== 'object') {
332
+ emitError({
333
+ error: 'bad_input',
334
+ message: 'deck is required and must be an object',
335
+ field: 'deck',
336
+ next: "Run: echo '{\"kind\":\"deck\"}' | hl schema show",
337
+ });
338
+ }
339
+ let deck;
340
+ try {
341
+ const _v = {};
342
+ void _v;
343
+ deck = validateDeck(input.deck);
344
+ }
345
+ catch (validationErr) {
346
+ emitError({
347
+ error: 'deck_invalid',
348
+ message: `deck validation failed: ${validationErr instanceof Error ? validationErr.message : String(validationErr)}`,
349
+ received: input.deck,
350
+ next: "Fix the deck object. Run: echo '{\"deck\":{...}}' | hl deck validate",
351
+ });
352
+ }
353
+ const dir = input.dir ? resolve(input.dir) : mkdtempSync(join(tmpdir(), 'hl-ix-'));
354
+ mkdirSync(dir, { recursive: true });
355
+ atomicWriteJson(deckPath(dir), deck);
356
+ const jobId = basename(dir);
357
+ const visuals = input.visuals !== false;
358
+ const useTmux = input.tmux !== false;
359
+ let sessionId;
360
+ if (visuals) {
361
+ if (input.sessionId && typeof input.sessionId === 'string') {
362
+ sessionId = input.sessionId;
363
+ }
364
+ else {
365
+ // dynamic import to avoid pulling heavy dep into parse path
366
+ const { findRecentSessionId } = await import('./conversation/reader.js');
367
+ sessionId = findRecentSessionId(process.cwd()) || findRecentSessionId() || undefined;
368
+ }
369
+ }
370
+ appendJobLog(dir, { level: 'info', event: 'job_started', message: 'deck ask job started', data: { jobId } });
371
+ if (INTERNAL_WRITE_TO) {
372
+ // Child mode: run ask() in-process, write result to INTERNAL_WRITE_TO
373
+ try {
374
+ const result = await ask(deck, { dir, sessionId });
375
+ appendJobLog(dir, { level: 'info', event: 'job_finished', message: 'deck resolved', data: { jobId } });
376
+ writeFileSync(INTERNAL_WRITE_TO, JSON.stringify(result) + '\n');
377
+ process.exit(0);
378
+ }
379
+ catch (askErr) {
380
+ appendJobLog(dir, {
381
+ level: 'error', event: 'job_failed',
382
+ message: askErr instanceof Error ? askErr.message : String(askErr),
383
+ });
384
+ process.exit(1);
385
+ }
386
+ }
387
+ if (process.env['TMUX'] && useTmux) {
388
+ const scriptPath = process.argv[1];
389
+ if (!scriptPath) {
390
+ emitError({ error: 'internal', message: 'Cannot determine hl script path', next: 'Report this as a bug.' });
391
+ }
392
+ const sq = (s) => /^[a-zA-Z0-9_\-./:@%+=]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
393
+ const childInput = JSON.stringify({ deck, dir, sessionId, visuals, tmux: false });
394
+ const cmd = `echo ${sq(childInput)} | ${sq(process.execPath)} ${sq(scriptPath)} deck ask`;
395
+ try {
396
+ execFileSync('tmux', ['split-window', '-d', '-h', cmd], { stdio: 'ignore' });
397
+ }
398
+ catch (spawnErr) {
399
+ const msg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr);
400
+ appendJobLog(dir, { level: 'error', event: 'job_failed', message: `tmux spawn failed: ${msg}` });
401
+ emitError({
402
+ error: 'internal',
403
+ message: `tmux spawn failed: ${msg}`,
404
+ next: 'Check that $TMUX is set. Or pass tmux:false.',
405
+ });
406
+ }
407
+ process.stdout.write(JSON.stringify({
408
+ job_id: jobId,
409
+ dir,
410
+ follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to block until the human finishes.`,
411
+ }) + '\n');
412
+ process.exit(0);
413
+ }
414
+ // Non-tmux path: ask() runs in-process (will block or fail without TTY — degraded).
415
+ appendJobLog(dir, { level: 'warn', event: 'job_started', message: 'no tmux; ask() runs in-process' });
416
+ try {
417
+ const result = await ask(deck, { dir, sessionId });
418
+ appendJobLog(dir, { level: 'info', event: 'job_finished', message: 'deck resolved', data: { jobId } });
419
+ writeFileSync(join(dir, 'response.json'), JSON.stringify({ responses: result.responses, completedAt: result.completedAt }, null, 2));
420
+ process.stdout.write(JSON.stringify({
421
+ job_id: jobId,
422
+ dir,
423
+ follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to retrieve the result.`,
424
+ _note: 'Non-tmux path: ask() blocked synchronously. Result is already available.',
425
+ }) + '\n');
426
+ process.exit(0);
427
+ }
428
+ catch (askErr) {
429
+ const msg = askErr instanceof Error ? askErr.message : String(askErr);
430
+ appendJobLog(dir, { level: 'error', event: 'job_failed', message: msg });
431
+ emitError({
432
+ error: 'internal',
433
+ message: `ask() failed: ${msg}`,
434
+ next: 'Set tmux:true (or run inside tmux) so the TUI can open an interactive pane.',
435
+ });
436
+ }
437
+ });
438
+ deckCmd
439
+ .command('validate')
440
+ .description('Preflight deck validation — no side effects.\n' +
441
+ '\n' +
442
+ 'stdin { deck: object }\n' +
443
+ 'stdout { ok: bool, errors: [{field, message}] }\n' +
444
+ 'exit 0 if ok, 1 if invalid\n')
445
+ .helpOption('-h, --help', 'Show help')
446
+ .action(() => {
447
+ const input = parseStdinJson();
448
+ if (!input.deck) {
449
+ emitError({ error: 'bad_input', message: 'deck is required', field: 'deck', next: 'Provide: {"deck": {...}}' });
450
+ }
451
+ try {
452
+ validateDeck(input.deck);
453
+ process.stdout.write(JSON.stringify({ ok: true, errors: [] }) + '\n');
454
+ process.exit(0);
455
+ }
456
+ catch (validationErr) {
457
+ const errors = parseValidationErrors(validationErr);
458
+ process.stdout.write(JSON.stringify({ ok: false, errors }) + '\n');
459
+ process.exit(1);
460
+ }
461
+ });
462
+ function parseValidationErrors(e) {
463
+ if (e && typeof e === 'object' && 'issues' in e) {
464
+ const issues = e.issues;
465
+ return issues.map((iss) => ({ field: iss.path.join('.'), message: iss.message }));
466
+ }
467
+ return [{ field: '', message: e instanceof Error ? e.message : String(e) }];
468
+ }
469
+ // ── review ────────────────────────────────────────────────────────────────────
470
+ const reviewCmd = program.command('review').description('Markdown document review with anchored comments.');
471
+ reviewCmd
472
+ .command('open')
473
+ .description('Kickoff: spawn the read-only editor review and return immediately.\n' +
474
+ '\n' +
475
+ 'stdin { file: string (required, .md), output?: string|null,\n' +
476
+ ' editor?: string|null, tmux?: bool=true }\n' +
477
+ 'stdout { job_id: string, output: string (absolute), follow_up: string }\n' +
478
+ '\n' +
479
+ 'Effects: spawns nvim/vim read-only; autosaves feedback JSON.\n')
480
+ .helpOption('-h, --help', 'Show help')
481
+ .action(async () => {
482
+ const input = parseStdinJson();
483
+ if (!input.file || typeof input.file !== 'string') {
484
+ emitError({ error: 'bad_input', message: 'file is required', field: 'file', next: 'Provide: {"file": "/path/to/doc.md"}' });
485
+ }
486
+ const absFile = resolve(input.file);
487
+ if (!existsSync(absFile)) {
488
+ emitError({ error: 'file_not_found', message: `File not found: ${absFile}`, field: 'file', next: 'Check the file path.' });
489
+ }
490
+ const output = resolve(input.output ? input.output : `${absFile}.feedback.json`);
491
+ const noTmux = input.tmux === false;
492
+ const jobDir = mkdtempSync(join(tmpdir(), 'hl-review-'));
493
+ const jobId = basename(jobDir);
494
+ appendJobLog(jobDir, { level: 'info', event: 'job_started', message: 'review open job started', data: { jobId, file: absFile } });
495
+ try {
496
+ const result = await launchReview(absFile, {
497
+ output,
498
+ editor: (input.editor && typeof input.editor === 'string') ? input.editor : undefined,
499
+ noTmux,
500
+ });
501
+ appendJobLog(jobDir, { level: 'info', event: 'job_finished', message: 'review finished', data: { comments: result.comments.length } });
502
+ writeFileSync(join(jobDir, 'feedback.json'), JSON.stringify(result, null, 2));
503
+ process.stdout.write(JSON.stringify({
504
+ job_id: jobId,
505
+ output,
506
+ follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to retrieve the feedback.`,
507
+ }) + '\n');
508
+ process.exit(0);
509
+ }
510
+ catch (reviewErr) {
511
+ const msg = reviewErr instanceof Error ? reviewErr.message : String(reviewErr);
512
+ appendJobLog(jobDir, { level: 'error', event: 'job_failed', message: msg });
513
+ if (msg.startsWith('Markdown file not found')) {
514
+ emitError({ error: 'file_not_found', message: msg, next: 'Check the file path.' });
515
+ }
516
+ if (msg.startsWith('Editor not found') || msg.startsWith('No editor found')) {
517
+ emitError({ error: 'editor_not_found', message: msg, next: 'Install Neovim (brew install neovim) or pass editor.' });
518
+ }
519
+ emitError({ error: 'internal', message: msg, next: 'Check stderr for details.' });
520
+ }
521
+ });
522
+ // ── view ──────────────────────────────────────────────────────────────────────
523
+ const viewCmd = program.command('view').description('Passive live render of a file in a tmux pane.');
524
+ viewCmd
525
+ .command('show')
526
+ .description('Render a file live in a tmux pane — passive, no result.\n' +
527
+ '\n' +
528
+ 'stdin { path: string (required), watch?: bool=true, window?: "split"|"new"="split" }\n' +
529
+ 'stdout { pane_id: string|null, reason: string|null }\n' +
530
+ 'exit 0 always (not-in-tmux / renderer-unavailable is NOT an error)\n')
531
+ .helpOption('-h, --help', 'Show help')
532
+ .action(() => {
533
+ const input = parseStdinJson();
534
+ if (!input.path || typeof input.path !== 'string') {
535
+ emitError({ error: 'bad_input', message: 'path is required', field: 'path', next: 'Provide: {"path": "/abs/path/file.md"}' });
536
+ }
537
+ const absPath = resolve(input.path);
538
+ const watch = input.watch !== false;
539
+ const window = input.window === 'new' ? 'new' : 'split';
540
+ const res = display(absPath, { watch, window });
541
+ if (res.paneId) {
542
+ process.stdout.write(JSON.stringify({ pane_id: res.paneId, reason: null }) + '\n');
543
+ }
544
+ else {
545
+ process.stdout.write(JSON.stringify({ pane_id: null, reason: 'Not in tmux or termrender unavailable.' }) + '\n');
546
+ }
547
+ process.exit(0);
548
+ });
549
+ // ── inbox ─────────────────────────────────────────────────────────────────────
550
+ const inboxCmd = program.command('inbox').description('Browse and resolve pending interactions across root dirs.');
551
+ inboxCmd
552
+ .command('list')
553
+ .description('Read-only paginated query of pending interactions.\n' +
554
+ '\n' +
555
+ 'stdin { roots: string[] (required, ≥1), limit?: int=20 (max 100), cursor?: string|null }\n' +
556
+ 'stdout { items: [{dir,title,askedBy,blockedSince,interactionCount}],\n' +
557
+ ' next_cursor: string|null, total: int|null }\n' +
558
+ 'Sorted by blockedSince ascending.\n')
559
+ .helpOption('-h, --help', 'Show help')
560
+ .action(() => {
561
+ const input = parseStdinJson();
562
+ if (!Array.isArray(input.roots) || input.roots.length === 0) {
563
+ emitError({ error: 'bad_input', message: 'roots must be a non-empty array', field: 'roots', next: 'Provide: {"roots": ["/path/to/inbox"]}' });
564
+ }
565
+ const roots = input.roots.map((r) => resolve(r));
566
+ const limit = Math.min(typeof input.limit === 'number' ? input.limit : 20, 100);
567
+ const allItems = scanInbox(roots);
568
+ const total = allItems.length;
569
+ let startIdx = 0;
570
+ if (input.cursor) {
571
+ const idx = allItems.findIndex((it) => it.dir === input.cursor);
572
+ startIdx = idx >= 0 ? idx : 0;
573
+ }
574
+ const page = allItems.slice(startIdx, startIdx + limit);
575
+ const nextCursor = startIdx + limit < total ? allItems[startIdx + limit]?.dir : null;
576
+ const items = page.map((it) => {
577
+ const dk = readJson(deckPath(it.dir));
578
+ return {
579
+ dir: it.dir,
580
+ title: it.title,
581
+ askedBy: it.source?.askedBy,
582
+ blockedSince: it.blockedSince,
583
+ interactionCount: dk ? dk.interactions.length : 1,
584
+ };
585
+ });
586
+ process.stdout.write(JSON.stringify({
587
+ items,
588
+ next_cursor: nextCursor !== undefined ? nextCursor : null,
589
+ total,
590
+ }) + '\n');
591
+ process.exit(0);
592
+ });
593
+ inboxCmd
594
+ .command('resolve')
595
+ .description('Kickoff: spawn the inbox-walker TUI detached.\n' +
596
+ '\n' +
597
+ 'stdin { roots: string[] (required) }\n' +
598
+ 'stdout { job_id: string, follow_up: string }\n')
599
+ .helpOption('-h, --help', 'Show help')
600
+ .action(async () => {
601
+ const input = parseStdinJson();
602
+ if (!Array.isArray(input.roots) || input.roots.length === 0) {
603
+ emitError({ error: 'bad_input', message: 'roots must be a non-empty array', field: 'roots', next: 'Provide: {"roots": ["/path/to/inbox"]}' });
604
+ }
605
+ const roots = input.roots.map((r) => resolve(r));
606
+ const jobDir = mkdtempSync(join(tmpdir(), 'hl-inbox-'));
607
+ const jobId = basename(jobDir);
608
+ appendJobLog(jobDir, { level: 'info', event: 'job_started', message: 'inbox resolve job started', data: { jobId, roots } });
609
+ try {
610
+ await inbox(roots);
611
+ appendJobLog(jobDir, { level: 'info', event: 'job_finished', message: 'inbox resolved', data: { jobId } });
612
+ process.stdout.write(JSON.stringify({
613
+ job_id: jobId,
614
+ follow_up: `Inbox walk complete. Call hl job result with stdin {"job_id":"${jobId}"} for summary.`,
615
+ }) + '\n');
616
+ process.exit(0);
617
+ }
618
+ catch (inboxErr) {
619
+ const msg = inboxErr instanceof Error ? inboxErr.message : String(inboxErr);
620
+ appendJobLog(jobDir, { level: 'error', event: 'job_failed', message: msg });
621
+ emitError({ error: 'internal', message: msg, next: 'Ensure the roots are valid directories.' });
622
+ }
623
+ });
624
+ // ── job ───────────────────────────────────────────────────────────────────────
625
+ const jobCmd = program.command('job').description('Inspect, wait on, or cancel running jobs.');
626
+ jobCmd
627
+ .command('status')
628
+ .description('Read-only job state snapshot.\n' +
447
629
  '\n' +
448
- 'Examples:\n' +
449
- ' hl schema > deck.schema.json # save the request schema\n' +
450
- ' hl schema response | jq # pretty-print the response schema\n')
451
- .action((kind) => {
452
- const schema = kind === 'response' ? RESPONSE_SCHEMA : REQUEST_SCHEMA;
453
- process.stdout.write(JSON.stringify(schema, null, 2) + '\n');
630
+ 'stdin { job_id: string }\n' +
631
+ 'stdout { state: "live"|"done"|"failed"|"canceled", kind: "deck"|"review"|"inbox",\n' +
632
+ ' age_seconds: number, last_event: {ts,event,message}|null }\n')
633
+ .helpOption('-h, --help', 'Show help')
634
+ .action(() => {
635
+ const input = parseStdinJson();
636
+ if (!input.job_id || typeof input.job_id !== 'string') {
637
+ emitError({ error: 'bad_input', message: 'job_id is required', field: 'job_id', next: 'Provide: {"job_id": "<id>"}' });
638
+ }
639
+ const dir = resolveJobDir(input.job_id);
640
+ if (!existsSync(dir)) {
641
+ emitError({ error: 'job_not_found', message: `Job not found: ${input.job_id}`, next: 'Check the job_id.' });
642
+ }
643
+ let ageSecs = 0;
644
+ try {
645
+ const _st = {};
646
+ void _st;
647
+ const st = statSync(dir);
648
+ ageSecs = Math.round((Date.now() - st.birthtimeMs) / 1000);
649
+ }
650
+ catch (statErr) {
651
+ void String(statErr); // stat unavailable — report 0
652
+ }
653
+ const state = detectJobState(dir);
654
+ const kind = detectJobKind(dir);
655
+ const last = lastLogEvent(dir);
656
+ process.stdout.write(JSON.stringify({
657
+ state,
658
+ kind,
659
+ age_seconds: ageSecs,
660
+ last_event: last ? { ts: last.ts, event: last.event, message: last.message } : null,
661
+ }) + '\n');
662
+ process.exit(0);
663
+ });
664
+ function tryReadJobResult(dir, kind) {
665
+ if (kind === 'deck') {
666
+ const rp = responsePath(dir);
667
+ if (!existsSync(rp))
668
+ return null;
669
+ const raw = tryParseJson(readFileSync(rp, 'utf8'));
670
+ if (!raw)
671
+ return null;
672
+ const dk = readJson(deckPath(dir));
673
+ return {
674
+ summary: '',
675
+ responsePath: rp,
676
+ schema: 'humanloop.response/v2',
677
+ responses: raw.responses,
678
+ completedAt: raw.completedAt,
679
+ _note: dk ? `Deck had ${dk.interactions.length} interaction(s)` : undefined,
680
+ };
681
+ }
682
+ if (kind === 'review') {
683
+ const fp = join(dir, 'feedback.json');
684
+ if (!existsSync(fp))
685
+ return null;
686
+ return tryParseJson(readFileSync(fp, 'utf8'));
687
+ }
688
+ // inbox
689
+ const logPath = jobLogPath(dir);
690
+ if (!existsSync(logPath))
691
+ return null;
692
+ if (detectJobState(dir) !== 'done')
693
+ return null;
694
+ const lines = readLogLines(logPath);
695
+ let resolved = 0;
696
+ for (const line of lines) {
697
+ const entry = tryParseJson(line);
698
+ if (entry && entry.event === 'inbox_resolved')
699
+ resolved++;
700
+ }
701
+ return { resolved };
702
+ }
703
+ jobCmd
704
+ .command('result')
705
+ .description('Retrieve terminal payload of a finished job.\n' +
706
+ '\n' +
707
+ 'stdin { job_id: string, wait?: bool=false }\n' +
708
+ 'stdout deck → ResolutionEnvelope (humanloop.response/v2)\n' +
709
+ ' review → FeedbackResult\n' +
710
+ ' inbox → { resolved: int }\n' +
711
+ ' not done + wait:false → { error:"not_ready", ... } exit 1\n' +
712
+ ' wait:true blocks until sidecar appears or job terminates.\n')
713
+ .helpOption('-h, --help', 'Show help')
714
+ .action(async () => {
715
+ const input = parseStdinJson();
716
+ if (!input.job_id || typeof input.job_id !== 'string') {
717
+ emitError({ error: 'bad_input', message: 'job_id is required', field: 'job_id', next: 'Provide: {"job_id": "<id>", "wait": true}' });
718
+ }
719
+ const dir = resolveJobDir(input.job_id);
720
+ if (!existsSync(dir)) {
721
+ emitError({ error: 'job_not_found', message: `Job not found: ${input.job_id}`, next: 'Check the job_id.' });
722
+ }
723
+ const wait = input.wait === true;
724
+ const kind = detectJobKind(dir);
725
+ if (!wait) {
726
+ const result = tryReadJobResult(dir, kind);
727
+ if (result === null) {
728
+ emitError({ error: 'not_ready', message: 'Job is not yet complete.', next: 'Retry with wait:true to block until done.' }, 1);
729
+ }
730
+ process.stdout.write(JSON.stringify(result) + '\n');
731
+ process.exit(0);
732
+ }
733
+ await new Promise((resolvePromise) => {
734
+ const poll = setInterval(() => {
735
+ const result = tryReadJobResult(dir, kind);
736
+ if (result !== null) {
737
+ clearInterval(poll);
738
+ process.stdout.write(JSON.stringify(result) + '\n');
739
+ process.exit(0);
740
+ }
741
+ const state = detectJobState(dir);
742
+ if (state === 'failed' || state === 'canceled') {
743
+ clearInterval(poll);
744
+ emitError({ error: 'not_ready', message: `Job ended with state: ${state}`, next: 'Check hl job logs for details.' }, 1);
745
+ }
746
+ }, 200);
747
+ void resolvePromise;
748
+ });
749
+ });
750
+ jobCmd
751
+ .command('logs')
752
+ .description('Stream job.log events (JSONL).\n' +
753
+ '\n' +
754
+ 'stdin { job_id: string, since?: string|null,\n' +
755
+ ' level?: "debug"|"info"|"warn"|"error"="info", follow?: bool=false }\n' +
756
+ 'stdout JSONL — one event per line: { ts, level, event, message, data? }\n' +
757
+ 'follow:false → emit historical then exit; follow:true → stream until done.\n')
758
+ .helpOption('-h, --help', 'Show help')
759
+ .action(async () => {
760
+ const input = parseStdinJson();
761
+ if (!input.job_id || typeof input.job_id !== 'string') {
762
+ emitError({ error: 'bad_input', message: 'job_id is required', field: 'job_id', next: 'Provide: {"job_id": "<id>"}' });
763
+ }
764
+ const dir = resolveJobDir(input.job_id);
765
+ if (!existsSync(dir)) {
766
+ emitError({ error: 'job_not_found', message: `Job not found: ${input.job_id}`, next: 'Check the job_id.' });
767
+ }
768
+ const levelOrder = { debug: 0, info: 1, warn: 2, error: 3 };
769
+ const inputLevel = (input.level && input.level in levelOrder) ? input.level : 'info';
770
+ const minLevel = levelOrder[inputLevel];
771
+ const since = input.since;
772
+ const follow = input.follow === true;
773
+ const logPath = jobLogPath(dir);
774
+ let emittedCount = 0;
775
+ function emitLogLines() {
776
+ const lines = readLogLines(logPath);
777
+ for (let i = emittedCount; i < lines.length; i++) {
778
+ const entry = tryParseJson(lines[i]);
779
+ if (!entry)
780
+ continue;
781
+ if (since && entry.ts <= since)
782
+ continue;
783
+ const entryLevel = entry.level in levelOrder ? levelOrder[entry.level] : 0;
784
+ if (entryLevel < minLevel)
785
+ continue;
786
+ process.stdout.write(JSON.stringify(entry) + '\n');
787
+ }
788
+ emittedCount = lines.length;
789
+ }
790
+ emitLogLines();
791
+ if (!follow) {
792
+ process.exit(0);
793
+ }
794
+ await new Promise((resolvePromise) => {
795
+ const poll = setInterval(() => {
796
+ emitLogLines();
797
+ const state = detectJobState(dir);
798
+ if (state === 'done' || state === 'failed' || state === 'canceled') {
799
+ clearInterval(poll);
800
+ process.exit(0);
801
+ }
802
+ }, 200);
803
+ void resolvePromise;
804
+ });
805
+ });
806
+ jobCmd
807
+ .command('cancel')
808
+ .description('Best-effort cancel: signal the job pane and close it if possible.\n' +
809
+ '\n' +
810
+ 'stdin { job_id: string }\n' +
811
+ 'stdout { canceled: bool, message: string }\n')
812
+ .helpOption('-h, --help', 'Show help')
813
+ .action(() => {
814
+ const input = parseStdinJson();
815
+ if (!input.job_id || typeof input.job_id !== 'string') {
816
+ emitError({ error: 'bad_input', message: 'job_id is required', field: 'job_id', next: 'Provide: {"job_id": "<id>"}' });
817
+ }
818
+ const dir = resolveJobDir(input.job_id);
819
+ if (!existsSync(dir)) {
820
+ emitError({ error: 'job_not_found', message: `Job not found: ${input.job_id}`, next: 'Check the job_id.' });
821
+ }
822
+ let canceled = false;
823
+ let message = 'No tmux pane found; signal not delivered (job may already be done).';
824
+ if (process.env['TMUX']) {
825
+ try {
826
+ const panes = execFileSync('tmux', ['list-panes', '-a', '-F', '#{pane_id} #{pane_current_command}'], { encoding: 'utf8' });
827
+ for (const line of panes.split('\n')) {
828
+ const paneId = line.split(' ')[0];
829
+ if (!paneId)
830
+ continue;
831
+ try {
832
+ execFileSync('tmux', ['send-keys', '-t', paneId, 'q', ''], { stdio: 'ignore' });
833
+ }
834
+ catch (sendErr) {
835
+ void String(sendErr); // best-effort per pane
836
+ }
837
+ }
838
+ canceled = true;
839
+ message = 'Signal delivered to tmux pane(s).';
840
+ }
841
+ catch (tmuxErr) {
842
+ message = `tmux pane lookup failed: ${tmuxErr instanceof Error ? tmuxErr.message : String(tmuxErr)}`;
843
+ }
844
+ }
845
+ appendJobLog(dir, { level: 'info', event: 'job_canceled', message: `cancel requested: ${message}` });
846
+ process.stdout.write(JSON.stringify({ canceled, message }) + '\n');
847
+ process.exit(0);
848
+ });
849
+ // ── schema ────────────────────────────────────────────────────────────────────
850
+ const schemaCmd = program.command('schema').description('Print JSON Schemas for hl data types.');
851
+ schemaCmd
852
+ .command('show')
853
+ .description('Print the JSON Schema for a data kind.\n' +
854
+ '\n' +
855
+ 'stdin { kind?: "deck"|"resolution"|"feedback"="deck" }\n' +
856
+ 'stdout the JSON Schema object\n')
857
+ .helpOption('-h, --help', 'Show help')
858
+ .action(() => {
859
+ const input = parseStdinJson();
860
+ const kind = (typeof input.kind === 'string' && input.kind) ? input.kind : 'deck';
861
+ if (kind === 'resolution') {
862
+ process.stdout.write(JSON.stringify(RESPONSE_SCHEMA, null, 2) + '\n');
863
+ }
864
+ else if (kind === 'feedback') {
865
+ process.stdout.write(JSON.stringify(FEEDBACK_SCHEMA, null, 2) + '\n');
866
+ }
867
+ else if (kind === 'deck') {
868
+ process.stdout.write(JSON.stringify(REQUEST_SCHEMA, null, 2) + '\n');
869
+ }
870
+ else {
871
+ emitError({
872
+ error: 'bad_input',
873
+ message: `Unknown kind: ${kind}. Valid: deck, resolution, feedback`,
874
+ field: 'kind',
875
+ next: 'Provide: {"kind": "deck"} or {"kind": "resolution"} or {"kind": "feedback"}',
876
+ });
877
+ }
454
878
  });
455
879
  program.parse();