@crouton-kit/humanloop 0.1.4 → 0.2.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,65 +1,124 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command, Option } from 'commander';
3
- import { writeFileSync } from 'fs';
4
- import { launchTui } from './tui/app.js';
3
+ import { writeFileSync, mkdtempSync } from 'fs';
4
+ import { tmpdir } from 'os';
5
+ import { resolve, join } from 'path';
5
6
  import { dispatchToTmuxPane } from './tui/tmux.js';
6
7
  import { findRecentSessionId } from './conversation/reader.js';
8
+ import { launchReview, formatFeedbackSummary } from './editor/review.js';
9
+ import { parseDeck } from './inbox/deck-schema.js';
10
+ import { ask, inbox } from './api.js';
11
+ import { display } from './surfaces/display.js';
7
12
  const program = new Command();
8
13
  program
9
14
  .name('hl')
10
15
  .description('Human-in-the-loop decision TUI.\n' +
11
16
  '\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' +
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' +
15
31
  '\n' +
16
32
  'Workflow:\n' +
17
33
  ' 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.')
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).')
22
36
  .version('0.1.0')
23
37
  .addHelpText('after', '\nExamples:\n' +
24
38
  ' 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');
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');
28
45
  program
29
- .command('create')
46
+ .command('ask')
30
47
  .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).')
48
+ 'Prints a ResolutionEnvelope JSON to stdout (or to --output / --write-to).')
32
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().')
33
51
  .option('--session-id <id>', 'Claude session ID; enables per-interaction visual context from conversation history. Defaults to the most recent session in cwd.')
34
52
  .option('--no-visuals', 'Skip visual context generation (faster, no haiku calls)')
35
53
  .option('--output <path>', 'Write result JSON to <path> instead of stdout')
36
54
  .option('--no-tmux', 'Do not auto-dispatch the TUI to a new tmux pane even when $TMUX is set')
37
55
  .addOption(new Option('--write-to <path>', 'internal: tmux child mode').hideHelp())
38
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' +
39
82
  'INPUT FORMAT\n' +
40
83
  ' 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' +
84
+ ' `title`, `options[]`, and optional `subtitle`, `body`, `allowFreetext`.\n' +
85
+ ' Run `hl schema` for the full schema. Example with pyramid content:\n' +
43
86
  ' {\n' +
44
87
  ' "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' +
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' +
54
107
  ' ]\n' +
55
108
  ' }\n' +
56
109
  '\n' +
57
- 'OUTPUT FORMAT (stdout on success, JSON)\n' +
110
+ 'OUTPUT FORMAT (stdout on success, JSON — a ResolutionEnvelope)\n' +
58
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' +
59
115
  ' "responses": [ ... ],\n' +
60
116
  ' "completedAt": "2026-04-20T15:23:00.000Z"\n' +
61
117
  ' }\n' +
62
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' +
63
122
  ' Response shape:\n' +
64
123
  ' { id: string, selectedOptionId?: string, freetext?: string }\n' +
65
124
  '\n' +
@@ -69,10 +128,10 @@ program
69
128
  'BEHAVIOR\n' +
70
129
  ' tmux When $TMUX is set, the TUI auto-splits into a new pane to the\n' +
71
130
  ' 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' +
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' +
76
135
  ' visuals With --session-id (or auto-detected) haiku generates a short\n' +
77
136
  ' ANSI context block per interaction from recent conversation turns.\n' +
78
137
  '\n' +
@@ -84,6 +143,7 @@ program
84
143
  const sessionId = opts.visuals
85
144
  ? (opts.sessionId || findRecentSessionId(process.cwd()) || findRecentSessionId() || undefined)
86
145
  : undefined;
146
+ const dir = opts.dir ? resolve(opts.dir) : mkdtempSync(join(tmpdir(), 'hl-ix-'));
87
147
  const emit = (result) => {
88
148
  const json = JSON.stringify(result, null, 2) + '\n';
89
149
  if (opts.writeTo) {
@@ -99,7 +159,7 @@ program
99
159
  try {
100
160
  if (process.env.TMUX && opts.tmux && !opts.writeTo) {
101
161
  try {
102
- const result = await dispatchToTmuxPane(file, { sessionId, visuals: opts.visuals });
162
+ const result = await dispatchToTmuxPane(file, { sessionId, visuals: opts.visuals, dir });
103
163
  emit(result);
104
164
  process.exit(0);
105
165
  }
@@ -107,98 +167,289 @@ program
107
167
  process.stderr.write(`tmux dispatch failed, running locally: ${err instanceof Error ? err.message : String(err)}\n`);
108
168
  }
109
169
  }
110
- const result = await launchTui(file, sessionId);
170
+ const deck = parseDeck(resolve(file));
171
+ const result = await ask(deck, { dir, sessionId });
111
172
  emit(result);
112
173
  process.exit(0);
113
174
  }
114
175
  catch (err) {
115
176
  const msg = err instanceof Error ? err.message : String(err);
116
177
  process.stderr.write(`ERROR: ${msg}\n`);
117
- if (msg.includes('not found')) {
178
+ if (msg.includes('ENOENT') || msg.includes('no such file')) {
118
179
  process.stderr.write('\nFix: pass a path to an existing deck JSON file.\nSee format: hl schema\n');
119
180
  }
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');
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');
122
183
  }
123
184
  else if (msg.includes('TTY')) {
124
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');
125
186
  }
126
- else if (msg.includes('JSON')) {
127
- process.stderr.write('\nFix: the deck file must be valid JSON matching `hl schema`.\n');
128
- }
129
- else if (msg.startsWith('interactions[') || msg.includes('Duplicate interaction id') || msg.includes('must be')) {
187
+ else {
130
188
  process.stderr.write('\nFix: the deck file must match `hl schema`. Run `hl schema` to see the required shape.\n');
131
189
  }
132
190
  process.exit(1);
133
191
  }
134
192
  });
135
193
  program
136
- .command('schema')
137
- .description('Print the v2 Interaction[] deck schema to stdout')
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')
138
200
  .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')
144
- .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
- },
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) => {
214
+ try {
215
+ await inbox(roots.map((r) => resolve(r)));
216
+ process.exit(0);
217
+ }
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);
225
+ }
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`);
251
+ }
252
+ else {
253
+ process.stderr.write('display: no pane opened (not in tmux, or termrender unavailable)\n');
254
+ }
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) => {
327
+ 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);
332
+ }
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');
338
+ }
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');
341
+ }
342
+ process.exit(1);
343
+ }
344
+ });
345
+ const REQUEST_SCHEMA = {
346
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
347
+ description: 'Input schema for hl ask (v2)',
348
+ type: 'object',
349
+ required: ['interactions'],
350
+ properties: {
351
+ title: {
352
+ type: 'string',
353
+ description: 'Optional deck title shown in the TUI header',
354
+ },
355
+ interactions: {
356
+ type: 'array',
357
+ minItems: 1,
358
+ items: {
359
+ type: 'object',
360
+ required: ['id', 'title', 'options'],
361
+ 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`.' },
367
+ options: {
368
+ type: 'array',
369
+ description: 'Selectable choices. Empty for freetext-only interactions.',
370
+ items: {
371
+ type: 'object',
372
+ required: ['id', 'label'],
373
+ properties: {
374
+ id: { type: 'string' },
375
+ label: { type: 'string' },
376
+ 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',
181
380
  },
182
381
  },
183
382
  },
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
- },
383
+ },
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',
197
396
  },
198
397
  },
199
398
  },
200
399
  },
201
- };
400
+ },
401
+ };
402
+ const RESPONSE_SCHEMA = {
403
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
404
+ $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.',
406
+ type: 'object',
407
+ required: ['summary', 'responsePath', 'schema', 'responses', 'completedAt'],
408
+ 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
+ },
421
+ responses: {
422
+ type: 'array',
423
+ description: 'Inline answers. May have FEWER entries than input interactions — humans can skip. Look up by id.',
424
+ items: {
425
+ type: 'object',
426
+ required: ['id'],
427
+ properties: {
428
+ id: { type: 'string' },
429
+ selectedOptionId: { type: 'string' },
430
+ freetext: { type: 'string' },
431
+ },
432
+ },
433
+ },
434
+ completedAt: {
435
+ type: 'string',
436
+ description: 'ISO 8601 timestamp when the human finished.',
437
+ },
438
+ },
439
+ };
440
+ 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' +
447
+ '\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;
202
453
  process.stdout.write(JSON.stringify(schema, null, 2) + '\n');
203
454
  });
204
455
  program.parse();
@@ -0,0 +1,24 @@
1
+ import type { FeedbackResult } from '../types.js';
2
+ export interface ReviewOptions {
3
+ /** Where the answers JSON is written (live autosave + finalized on exit). */
4
+ output: string;
5
+ /** Editor binary override. Default: first of nvim, vim on PATH. */
6
+ editor?: string;
7
+ /** Force running in the current terminal even when $TMUX is set. */
8
+ noTmux?: boolean;
9
+ }
10
+ /**
11
+ * Compact stdout rendering for the agent: per comment just the line:col
12
+ * range, the original text in that span, and the note — plus the source
13
+ * path, a pointer to the full JSON on disk, and a one-line schema hint.
14
+ * The verbose fields are deliberately not printed so they don't clog context.
15
+ */
16
+ export declare function formatFeedbackSummary(result: FeedbackResult, feedbackJsonPath: string): string;
17
+ /**
18
+ * Open a markdown file in a clean, read-only Neovim/Vim review session. The
19
+ * human anchors comments to source lines/selections with native vim motions
20
+ * and quits to submit. Blocks until the editor exits, then finalizes and
21
+ * returns the feedback. Autosaved continuously so a kill is recoverable and
22
+ * the next run resumes.
23
+ */
24
+ export declare function launchReview(file: string, opts: ReviewOptions): Promise<FeedbackResult>;