@crouton-kit/humanloop 0.1.3 → 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/api.d.ts +35 -0
- package/dist/api.js +119 -0
- package/dist/cli.js +348 -97
- package/dist/editor/review.d.ts +24 -0
- package/dist/editor/review.js +425 -0
- package/dist/inbox/convention.d.ts +17 -0
- package/dist/inbox/convention.js +87 -0
- package/dist/inbox/deck-schema.d.ts +41 -0
- package/dist/inbox/deck-schema.js +109 -0
- package/dist/inbox/scan.d.ts +2 -0
- package/dist/inbox/scan.js +62 -0
- package/dist/inbox/tui.d.ts +9 -0
- package/dist/inbox/tui.js +158 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +13 -0
- package/dist/render/termrender.d.ts +36 -0
- package/dist/render/termrender.js +236 -0
- package/dist/render/version.d.ts +1 -0
- package/dist/render/version.js +1 -0
- package/dist/scripts/install-renderer.d.ts +2 -0
- package/dist/scripts/install-renderer.js +16 -0
- package/dist/surfaces/display.d.ts +5 -0
- package/dist/surfaces/display.js +19 -0
- package/dist/tui/app.d.ts +24 -1
- package/dist/tui/app.js +52 -114
- package/dist/tui/input.js +19 -3
- package/dist/tui/render.js +48 -53
- package/dist/tui/tmux.d.ts +4 -6
- package/dist/tui/tmux.js +6 -4
- package/dist/types.d.ts +65 -0
- package/dist/visuals/generate.js +2 -27
- package/package.json +4 -2
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 {
|
|
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
|
|
13
|
-
'
|
|
14
|
-
'
|
|
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
|
|
19
|
-
' 3. Parse the JSON;
|
|
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
|
|
26
|
-
' hl
|
|
27
|
-
' hl
|
|
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('
|
|
46
|
+
.command('ask')
|
|
30
47
|
.description('Open the decisions TUI on <file> and block until the human finishes review.\n' +
|
|
31
|
-
'Prints
|
|
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 `
|
|
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
|
-
' {
|
|
46
|
-
'
|
|
47
|
-
'
|
|
48
|
-
'
|
|
49
|
-
'
|
|
50
|
-
'
|
|
51
|
-
'
|
|
52
|
-
'
|
|
53
|
-
'
|
|
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
|
|
73
|
-
'
|
|
74
|
-
'
|
|
75
|
-
'
|
|
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
|
|
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('
|
|
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('
|
|
121
|
-
process.stderr.write('\nFix: the file must
|
|
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
|
|
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('
|
|
137
|
-
.description('
|
|
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
|
-
'
|
|
140
|
-
'\n' +
|
|
141
|
-
'
|
|
142
|
-
'
|
|
143
|
-
'
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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>;
|