@crouton-kit/humanloop 0.1.4 → 0.3.1

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,204 +1,879 @@
1
1
  #!/usr/bin/env node
2
- import { Command, Option } from 'commander';
3
- import { writeFileSync } from 'fs';
4
- import { launchTui } from './tui/app.js';
5
- import { dispatchToTmuxPane } from './tui/tmux.js';
6
- import { findRecentSessionId } from './conversation/reader.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
+ import { ask, inbox } from './api.js';
11
+ import { display } from './surfaces/display.js';
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: '' };
28
+ try {
29
+ const _io = {};
30
+ void _io;
31
+ stdinResult.value = readFileSync('/dev/stdin', 'utf8');
32
+ stdinResult.ok = true;
33
+ }
34
+ catch (stdinErr) {
35
+ process.stderr.write(`[hl] stdin read error: ${stdinErr instanceof Error ? stdinErr.message : String(stdinErr)}\n`);
36
+ }
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 };
51
+ try {
52
+ const _p = {};
53
+ void _p;
54
+ parseResult.value = JSON.parse(raw);
55
+ parseResult.ok = true;
56
+ }
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
+ });
64
+ }
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;
81
+ }
82
+ catch (writeErr) {
83
+ void Object.assign(logResult, { error: String(writeErr) }); // best-effort; never throws
84
+ }
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: [] };
96
+ try {
97
+ const _s = {};
98
+ void _s;
99
+ scanResult.entries = readdirSync(td);
100
+ }
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;
111
+ }
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';
165
+ }
166
+ }
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 ───────────────────────────────────────────────────────────────────
186
+ const REQUEST_SCHEMA = {
187
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
188
+ description: 'Input schema for hl deck ask (v2)',
189
+ type: 'object',
190
+ required: ['interactions'],
191
+ properties: {
192
+ title: { type: 'string', description: 'Optional deck title shown in the TUI header' },
193
+ interactions: {
194
+ type: 'array',
195
+ minItems: 1,
196
+ items: {
197
+ type: 'object',
198
+ required: ['id', 'title', 'options'],
199
+ properties: {
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.' },
205
+ options: {
206
+ type: 'array',
207
+ items: {
208
+ type: 'object',
209
+ required: ['id', 'label'],
210
+ properties: {
211
+ id: { type: 'string' },
212
+ label: { type: 'string' },
213
+ description: { type: 'string' },
214
+ shortcut: { type: 'string', description: 'Single char. Auto-assigned if absent.' },
215
+ },
216
+ },
217
+ },
218
+ allowFreetext: { type: 'boolean' },
219
+ freetextLabel: { type: 'string' },
220
+ kind: { type: 'string', enum: ['notify', 'validation', 'decision', 'context', 'error'] },
221
+ },
222
+ },
223
+ },
224
+ },
225
+ };
226
+ const RESPONSE_SCHEMA = {
227
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
228
+ $id: 'humanloop.response/v2',
229
+ description: 'Resolution envelope emitted by hl deck ask / returned by ask().',
230
+ type: 'object',
231
+ required: ['summary', 'responsePath', 'schema', 'responses', 'completedAt'],
232
+ properties: {
233
+ summary: { type: 'string' },
234
+ responsePath: { type: 'string' },
235
+ schema: { const: 'humanloop.response/v2' },
236
+ responses: {
237
+ type: 'array',
238
+ items: {
239
+ type: 'object',
240
+ required: ['id'],
241
+ properties: {
242
+ id: { type: 'string' },
243
+ selectedOptionId: { type: 'string' },
244
+ selectedOptionIds: { type: 'array', items: { type: 'string' } },
245
+ freetext: { type: 'string' },
246
+ },
247
+ },
248
+ },
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
+ },
278
+ },
279
+ submittedAt: { type: 'string' },
280
+ savedAt: { type: 'string' },
281
+ },
282
+ };
283
+ // ── Commander tree ────────────────────────────────────────────────────────────
7
284
  const program = new Command();
8
285
  program
9
286
  .name('hl')
10
- .description('Human-in-the-loop decision TUI.\n' +
11
- '\n' +
12
- 'Use this when you (the agent) need the human to validate decisions, choose\n' +
13
- 'between options, or provide freetext input before you continue. The tool\n' +
14
- 'blocks until the human finishes review and returns their responses as JSON.\n' +
15
- '\n' +
16
- 'Workflow:\n' +
17
- ' 1. Write a deck file matching `hl schema` (a JSON object with interactions[]).\n' +
18
- ' 2. Run `hl create <file>`; it blocks and prints output JSON to stdout.\n' +
19
- ' 3. Parse the JSON; each response has id, optional selectedOptionId, optional freetext.\n' +
20
- '\n' +
21
- 'Interaction options: supply options[] for choices; set allowFreetext for comment/freetext.')
22
- .version('0.1.0')
23
- .addHelpText('after', '\nExamples:\n' +
24
- ' hl schema # print the input JSON schema\n' +
25
- ' hl create deck.json # open TUI, block, print responses JSON\n' +
26
- ' hl create deck.json --output out.json # write result to file\n' +
27
- ' hl create deck.json --no-tmux # run in current pane even inside tmux\n');
28
- program
29
- .command('create')
30
- .description('Open the decisions TUI on <file> and block until the human finishes review.\n' +
31
- 'Prints output JSON to stdout (or to --output / --write-to).')
32
- .argument('<file>', 'Path to deck JSON file (see `hl schema` for format)')
33
- .option('--session-id <id>', 'Claude session ID; enables per-interaction visual context from conversation history. Defaults to the most recent session in cwd.')
34
- .option('--no-visuals', 'Skip visual context generation (faster, no haiku calls)')
35
- .option('--output <path>', 'Write result JSON to <path> instead of stdout')
36
- .option('--no-tmux', 'Do not auto-dispatch the TUI to a new tmux pane even when $TMUX is set')
37
- .addOption(new Option('--write-to <path>', 'internal: tmux child mode').hideHelp())
38
- .addHelpText('after', '\n' +
39
- 'INPUT FORMAT\n' +
40
- ' JSON file with an `interactions` array. Each interaction has an `id`,\n' +
41
- ' `title`, `options[]`, and optional `allowFreetext`. Run `hl schema`\n' +
42
- ' for the full schema. Example:\n' +
43
- ' {\n' +
44
- ' "interactions": [\n' +
45
- ' {"id":"i1","title":"Use Postgres","options":[\n' +
46
- ' {"id":"approve","label":"Approve"},\n' +
47
- ' {"id":"reject","label":"Reject"}\n' +
48
- ' ],"allowFreetext":true},\n' +
49
- ' {"id":"i2","title":"Migration tool","options":[\n' +
50
- ' {"id":"prisma","label":"Prisma"},\n' +
51
- ' {"id":"drizzle","label":"Drizzle"}\n' +
52
- ' ]},\n' +
53
- ' {"id":"i3","title":"Rate limit policy","options":[],"allowFreetext":true}\n' +
54
- ' ]\n' +
55
- ' }\n' +
56
- '\n' +
57
- 'OUTPUT FORMAT (stdout on success, JSON)\n' +
58
- ' {\n' +
59
- ' "responses": [ ... ],\n' +
60
- ' "completedAt": "2026-04-20T15:23:00.000Z"\n' +
61
- ' }\n' +
62
- '\n' +
63
- ' Response shape:\n' +
64
- ' { id: string, selectedOptionId?: string, freetext?: string }\n' +
65
- '\n' +
66
- ' The human can skip interactions. `responses` may have FEWER entries than\n' +
67
- ' input interactions — look up by `id`, do not assume index alignment.\n' +
68
- '\n' +
69
- 'BEHAVIOR\n' +
70
- ' tmux When $TMUX is set, the TUI auto-splits into a new pane to the\n' +
71
- ' right (-d keeps focus on the caller). Disable with --no-tmux.\n' +
72
- ' progress Responses are persisted atomically to <file>.progress.json after\n' +
73
- ' every change. If the process is killed, the next run resumes\n' +
74
- ' from where the human left off. The file is removed on full\n' +
75
- ' completion; partial-response files are preserved.\n' +
76
- ' visuals With --session-id (or auto-detected) haiku generates a short\n' +
77
- ' ANSI context block per interaction from recent conversation turns.\n' +
78
- '\n' +
79
- 'EXIT CODES\n' +
80
- ' 0 success result JSON emitted\n' +
81
- ' 1 error message on stderr (file missing, invalid JSON, empty\n' +
82
- ' interactions, no TTY, etc.)\n')
83
- .action(async (file, opts) => {
84
- const sessionId = opts.visuals
85
- ? (opts.sessionId || findRecentSessionId(process.cwd()) || findRecentSessionId() || undefined)
86
- : undefined;
87
- const emit = (result) => {
88
- const json = JSON.stringify(result, null, 2) + '\n';
89
- if (opts.writeTo) {
90
- writeFileSync(opts.writeTo, json);
91
- }
92
- else if (opts.output) {
93
- writeFileSync(opts.output, json);
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;
94
363
  }
95
364
  else {
96
- process.stdout.write(json);
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);
97
378
  }
98
- };
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
+ }
99
451
  try {
100
- if (process.env.TMUX && opts.tmux && !opts.writeTo) {
101
- try {
102
- const result = await dispatchToTmuxPane(file, { sessionId, visuals: opts.visuals });
103
- emit(result);
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' +
629
+ '\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');
104
739
  process.exit(0);
105
740
  }
106
- catch (err) {
107
- process.stderr.write(`tmux dispatch failed, running locally: ${err instanceof Error ? err.message : String(err)}\n`);
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);
108
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');
109
787
  }
110
- const result = await launchTui(file, sessionId);
111
- emit(result);
788
+ emittedCount = lines.length;
789
+ }
790
+ emitLogLines();
791
+ if (!follow) {
112
792
  process.exit(0);
113
793
  }
114
- catch (err) {
115
- const msg = err instanceof Error ? err.message : String(err);
116
- process.stderr.write(`ERROR: ${msg}\n`);
117
- if (msg.includes('not found')) {
118
- process.stderr.write('\nFix: pass a path to an existing deck JSON file.\nSee format: hl schema\n');
119
- }
120
- else if (msg.includes('No interactions')) {
121
- process.stderr.write('\nFix: the file must contain a non-empty `interactions` array.\nSee format: hl schema\n');
122
- }
123
- else if (msg.includes('TTY')) {
124
- 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');
125
- }
126
- else if (msg.includes('JSON')) {
127
- process.stderr.write('\nFix: the deck file must be valid JSON matching `hl schema`.\n');
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).';
128
840
  }
129
- else if (msg.startsWith('interactions[') || msg.includes('Duplicate interaction id') || msg.includes('must be')) {
130
- process.stderr.write('\nFix: the deck file must match `hl schema`. Run `hl schema` to see the required shape.\n');
841
+ catch (tmuxErr) {
842
+ message = `tmux pane lookup failed: ${tmuxErr instanceof Error ? tmuxErr.message : String(tmuxErr)}`;
131
843
  }
132
- process.exit(1);
133
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);
134
848
  });
135
- program
136
- .command('schema')
137
- .description('Print the v2 Interaction[] deck schema to stdout')
138
- .addHelpText('after', '\n' +
139
- 'Use this to learn the exact input format for `hl create`.\n' +
140
- '\n' +
141
- 'Example:\n' +
142
- ' hl schema > deck.schema.json # save for reference\n' +
143
- ' hl schema | jq # pretty-print\n')
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')
144
858
  .action(() => {
145
- const schema = {
146
- $schema: 'https://json-schema.org/draft/2020-12/schema',
147
- description: 'Input schema for hl create (v2)',
148
- type: 'object',
149
- required: ['interactions'],
150
- properties: {
151
- title: {
152
- type: 'string',
153
- description: 'Optional deck title shown in the TUI header',
154
- },
155
- interactions: {
156
- type: 'array',
157
- minItems: 1,
158
- items: {
159
- type: 'object',
160
- required: ['id', 'title', 'options'],
161
- properties: {
162
- id: { type: 'string', description: 'Unique identifier' },
163
- title: { type: 'string', description: 'Short display label (≤4 words). Required.' },
164
- subtitle: { type: 'string', description: 'One-line "why this matters"' },
165
- body: { type: 'string', description: 'Markdown body shown in item-review' },
166
- bodyPath: { type: 'string', description: 'Path to body file; sisyphus inlines before mount' },
167
- options: {
168
- type: 'array',
169
- description: 'Selectable choices. Empty for freetext-only interactions.',
170
- items: {
171
- type: 'object',
172
- required: ['id', 'label'],
173
- properties: {
174
- id: { type: 'string' },
175
- label: { type: 'string' },
176
- description: { type: 'string' },
177
- shortcut: {
178
- type: 'string',
179
- description: 'Single char shortcut. Auto-assigned if absent. Avoid: c r n p q j k space',
180
- },
181
- },
182
- },
183
- },
184
- allowFreetext: {
185
- type: 'boolean',
186
- description: 'If true, user can add a freetext comment (or respond freely if options is empty)',
187
- },
188
- freetextLabel: {
189
- type: 'string',
190
- description: 'Prompt shown above the freetext input. Default: "Comment" or "Response"',
191
- },
192
- kind: {
193
- type: 'string',
194
- enum: ['notify', 'validation', 'decision', 'context', 'error'],
195
- description: 'Display hint — opaque to humanloop, used by sisyphus for inbox icons',
196
- },
197
- },
198
- },
199
- },
200
- },
201
- };
202
- process.stdout.write(JSON.stringify(schema, null, 2) + '\n');
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
+ }
203
878
  });
204
879
  program.parse();