@crouton-kit/humanloop 0.3.9 → 0.3.10
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 +50 -6
- package/dist/editor/review.d.ts +9 -0
- package/dist/editor/review.js +146 -21
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import { readdirSync } from 'node:fs';
|
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { resolve, join, basename } from 'node:path';
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
|
-
import { launchReview } from './editor/review.js';
|
|
8
|
+
import { launchReview, reviewVimscript } from './editor/review.js';
|
|
9
9
|
import { validateDeck } from './inbox/deck-schema.js';
|
|
10
10
|
import { ask, inbox } from './api.js';
|
|
11
11
|
import { display } from './surfaces/display.js';
|
|
@@ -564,7 +564,13 @@ reviewCmd
|
|
|
564
564
|
' editor?: string|null, tmux?: bool=true }\n' +
|
|
565
565
|
'stdout { job_id: string, output: string (absolute), follow_up: string }\n' +
|
|
566
566
|
'\n' +
|
|
567
|
-
'Effects: spawns nvim/vim read-only
|
|
567
|
+
'Effects: spawns nvim/vim read-only DETACHED in a tmux pane when tmux=true\n' +
|
|
568
|
+
' and $TMUX set; returns before the human starts. Writes\n' +
|
|
569
|
+
' <dir>/review.vim, <dir>/feedback.json (on finish), <dir>/job.log.\n' +
|
|
570
|
+
' autosaves feedback JSON; the open pane live-reloads the source on\n' +
|
|
571
|
+
' disk edits. The review is open-ended (a human may take many\n' +
|
|
572
|
+
' minutes) — collect via `hl job result` with wait:true, run\n' +
|
|
573
|
+
' backgrounded so the call does not block on the human.\n')
|
|
568
574
|
.helpOption('-h, --help', 'Show help')
|
|
569
575
|
.action(async () => {
|
|
570
576
|
const input = parseStdinJson();
|
|
@@ -576,15 +582,52 @@ reviewCmd
|
|
|
576
582
|
emitError({ error: 'file_not_found', message: `File not found: ${absFile}`, field: 'file', next: 'Check the file path.' });
|
|
577
583
|
}
|
|
578
584
|
const output = resolve(input.output ? input.output : `${absFile}.feedback.json`);
|
|
579
|
-
const
|
|
580
|
-
|
|
585
|
+
const useTmux = input.tmux !== false;
|
|
586
|
+
// Shared job dir: the detached child reuses the parent's via input.dir;
|
|
587
|
+
// a top-level call mints one. The review.vim is written up front (and the
|
|
588
|
+
// job_started logged) so the job is recognizable as a live review — and so
|
|
589
|
+
// the child sources the exact vimscript — before any pane is spawned.
|
|
590
|
+
const jobDir = input.dir ? resolve(input.dir) : mkdtempSync(join(tmpdir(), 'hl-review-'));
|
|
591
|
+
mkdirSync(jobDir, { recursive: true });
|
|
581
592
|
const jobId = basename(jobDir);
|
|
582
|
-
|
|
593
|
+
if (!input.dir) {
|
|
594
|
+
writeFileSync(join(jobDir, 'review.vim'), reviewVimscript());
|
|
595
|
+
appendJobLog(jobDir, { level: 'info', event: 'job_started', message: 'review open job started', data: { jobId, file: absFile } });
|
|
596
|
+
}
|
|
597
|
+
// tmux path: detach a child that owns the editor pane and return the handle
|
|
598
|
+
// now, mirroring `deck ask`. The child re-enters this leaf with tmux:false
|
|
599
|
+
// and the shared dir, falling through to the in-process branch below.
|
|
600
|
+
if (process.env['TMUX'] && useTmux) {
|
|
601
|
+
const scriptPath = process.argv[1];
|
|
602
|
+
if (!scriptPath) {
|
|
603
|
+
emitError({ error: 'internal', message: 'Cannot determine hl script path', next: 'Report this as a bug.' });
|
|
604
|
+
}
|
|
605
|
+
const sq = (s) => /^[a-zA-Z0-9_\-./:@%+=]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
|
|
606
|
+
const childInput = JSON.stringify({ file: absFile, output, editor: input.editor ?? null, tmux: false, dir: jobDir });
|
|
607
|
+
const cmd = `echo ${sq(childInput)} | ${sq(process.execPath)} ${sq(scriptPath)} review open`;
|
|
608
|
+
try {
|
|
609
|
+
execFileSync('tmux', ['split-window', '-d', '-h', cmd], { stdio: 'ignore' });
|
|
610
|
+
}
|
|
611
|
+
catch (spawnErr) {
|
|
612
|
+
const msg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr);
|
|
613
|
+
appendJobLog(jobDir, { level: 'error', event: 'job_failed', message: `tmux spawn failed: ${msg}` });
|
|
614
|
+
emitError({ error: 'internal', message: `tmux spawn failed: ${msg}`, next: 'Check that $TMUX is set. Or pass tmux:false.' });
|
|
615
|
+
}
|
|
616
|
+
process.stdout.write(JSON.stringify({
|
|
617
|
+
job_id: jobId,
|
|
618
|
+
output,
|
|
619
|
+
follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to block until the human finishes the review. They may take many minutes — run this backgrounded; you will be notified on completion.`,
|
|
620
|
+
}) + '\n');
|
|
621
|
+
process.exit(0);
|
|
622
|
+
}
|
|
623
|
+
// In-process path: the detached child (this pane is its TTY), or a degraded
|
|
624
|
+
// top-level call with no tmux. launchReview blocks until the editor exits.
|
|
583
625
|
try {
|
|
584
626
|
const result = await launchReview(absFile, {
|
|
585
627
|
output,
|
|
586
628
|
editor: (input.editor && typeof input.editor === 'string') ? input.editor : undefined,
|
|
587
|
-
noTmux,
|
|
629
|
+
noTmux: true,
|
|
630
|
+
jobDir,
|
|
588
631
|
});
|
|
589
632
|
appendJobLog(jobDir, { level: 'info', event: 'job_finished', message: 'review finished', data: { comments: result.comments.length } });
|
|
590
633
|
writeFileSync(join(jobDir, 'feedback.json'), JSON.stringify(result, null, 2));
|
|
@@ -592,6 +635,7 @@ reviewCmd
|
|
|
592
635
|
job_id: jobId,
|
|
593
636
|
output,
|
|
594
637
|
follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to retrieve the feedback.`,
|
|
638
|
+
...(input.dir ? {} : { _note: 'No tmux: launchReview blocked synchronously. Result is already available.' }),
|
|
595
639
|
}) + '\n');
|
|
596
640
|
process.exit(0);
|
|
597
641
|
}
|
package/dist/editor/review.d.ts
CHANGED
|
@@ -23,7 +23,16 @@ export interface ReviewOptions {
|
|
|
23
23
|
w: string;
|
|
24
24
|
h: string;
|
|
25
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* Reuse this directory for `review.vim` instead of minting a fresh temp dir.
|
|
28
|
+
* The async `review open` kickoff passes its job dir so the detached child
|
|
29
|
+
* sources the same vimscript the parent already wrote there (which also lets
|
|
30
|
+
* the job be recognized as a review while it's still live). When the file
|
|
31
|
+
* already exists it is left untouched.
|
|
32
|
+
*/
|
|
33
|
+
jobDir?: string;
|
|
26
34
|
}
|
|
35
|
+
export declare function reviewVimscript(): string;
|
|
27
36
|
/**
|
|
28
37
|
* Compact stdout rendering for the agent: per comment just the line:col
|
|
29
38
|
* range, the original text in that span, and the note — plus the source
|
package/dist/editor/review.js
CHANGED
|
@@ -26,12 +26,13 @@ function resolveEditor(override) {
|
|
|
26
26
|
// The entire review UX as a clean, minimal Vimscript config sourced via `-u`.
|
|
27
27
|
// Works in both Neovim and Vim 8+. The source file is opened read-only; the
|
|
28
28
|
// human anchors comments to real source lines/selections and quits to submit.
|
|
29
|
-
function reviewVimscript() {
|
|
29
|
+
export function reviewVimscript() {
|
|
30
30
|
return [
|
|
31
31
|
`" hl propose — review layer. Runs on a CLEAN config (nvim -u NONE: no`,
|
|
32
32
|
`" init.lua, no LazyVim, no plugins/keymaps). Look/feel is ONLY the user's`,
|
|
33
|
-
`" 'gloam' colorscheme + built-in treesitter markdown highlighting
|
|
34
|
-
`"
|
|
33
|
+
`" 'gloam' colorscheme + built-in treesitter markdown highlighting + (when`,
|
|
34
|
+
`" installed) render-markdown.nvim for GFM tables/headings, applied below.`,
|
|
35
|
+
`" The rest is the read-only guard, comment commands, and autosave.`,
|
|
35
36
|
`let g:hl_out = $HL_OUTPUT`,
|
|
36
37
|
`let g:hl_src = $HL_SOURCE`,
|
|
37
38
|
`let s:comments = []`,
|
|
@@ -146,35 +147,96 @@ function reviewVimscript() {
|
|
|
146
147
|
` echo 'Removed last comment — ' . len(s:comments) . ' left'`,
|
|
147
148
|
`endfunction`,
|
|
148
149
|
``,
|
|
149
|
-
`
|
|
150
|
-
`
|
|
151
|
-
`
|
|
152
|
-
`
|
|
153
|
-
`
|
|
150
|
+
`" Build the list buffer lines plus a parallel map from buffer line -> comment`,
|
|
151
|
+
`" index (0-based; -1 for non-actionable rows like the quote continuation or`,
|
|
152
|
+
`" the empty-state hint). Stored on the buffer so the action maps can resolve`,
|
|
153
|
+
`" which comment the cursor is on.`,
|
|
154
|
+
`function! s:ListLines() abort`,
|
|
154
155
|
` let l:lines = []`,
|
|
156
|
+
` let l:map = []`,
|
|
155
157
|
` if empty(s:comments)`,
|
|
156
|
-
`
|
|
158
|
+
` call add(l:lines, '(no comments yet — select text or put the cursor on a line, then <Space>c or :HLComment)')`,
|
|
159
|
+
` call add(l:map, -1)`,
|
|
157
160
|
` else`,
|
|
158
|
-
` let l:
|
|
161
|
+
` let l:idx = 0`,
|
|
159
162
|
` for l:c in s:comments`,
|
|
160
163
|
` let l:ln = get(l:c,'line',0)`,
|
|
161
164
|
` let l:end = get(l:c,'endLine',l:ln)`,
|
|
162
165
|
` let l:loc = l:ln == l:end ? ('L' . l:ln) : ('L' . l:ln . '-' . l:end)`,
|
|
163
|
-
` call add(l:lines, l:
|
|
166
|
+
` call add(l:lines, (l:idx+1) . '. [' . l:loc . '] ' . get(l:c,'comment',''))`,
|
|
167
|
+
` call add(l:map, l:idx)`,
|
|
164
168
|
` if !empty(get(l:c,'quote',''))`,
|
|
165
169
|
` call add(l:lines, ' > ' . substitute(get(l:c,'quote',''), "\\n", ' / ', 'g'))`,
|
|
170
|
+
` call add(l:map, l:idx)`,
|
|
166
171
|
` endif`,
|
|
167
|
-
` let l:
|
|
172
|
+
` let l:idx += 1`,
|
|
168
173
|
` endfor`,
|
|
169
174
|
` endif`,
|
|
170
|
-
`
|
|
171
|
-
`
|
|
172
|
-
|
|
175
|
+
` return [l:lines, l:map]`,
|
|
176
|
+
`endfunction`,
|
|
177
|
+
``,
|
|
178
|
+
`" Refill the (already-open) comments buffer in place and refresh the line map.`,
|
|
179
|
+
`function! s:RenderList() abort`,
|
|
180
|
+
` let [l:lines, l:map] = s:ListLines()`,
|
|
181
|
+
` let b:hl_map = l:map`,
|
|
173
182
|
` setlocal modifiable`,
|
|
183
|
+
` silent! 1,$delete _`,
|
|
174
184
|
` call setline(1, l:lines)`,
|
|
175
185
|
` setlocal nomodifiable`,
|
|
186
|
+
`endfunction`,
|
|
187
|
+
``,
|
|
188
|
+
`" Comment index (0-based) under the cursor in the comments buffer, or -1.`,
|
|
189
|
+
`function! s:ListIdx() abort`,
|
|
190
|
+
` if !exists('b:hl_map') | return -1 | endif`,
|
|
191
|
+
` let l:lnum = line('.')`,
|
|
192
|
+
` if l:lnum < 1 || l:lnum > len(b:hl_map) | return -1 | endif`,
|
|
193
|
+
` return b:hl_map[l:lnum - 1]`,
|
|
194
|
+
`endfunction`,
|
|
195
|
+
``,
|
|
196
|
+
`function! s:ListDelete() abort`,
|
|
197
|
+
` let l:idx = s:ListIdx()`,
|
|
198
|
+
` if l:idx < 0`,
|
|
199
|
+
` echohl WarningMsg | echo 'No comment on this line' | echohl NONE | return`,
|
|
200
|
+
` endif`,
|
|
201
|
+
` call remove(s:comments, l:idx)`,
|
|
202
|
+
` call s:Save()`,
|
|
203
|
+
` call s:Marks()`,
|
|
204
|
+
` call s:RenderList()`,
|
|
205
|
+
` echo 'Deleted comment — ' . len(s:comments) . ' left'`,
|
|
206
|
+
`endfunction`,
|
|
207
|
+
``,
|
|
208
|
+
`function! s:ListEdit() abort`,
|
|
209
|
+
` let l:idx = s:ListIdx()`,
|
|
210
|
+
` if l:idx < 0`,
|
|
211
|
+
` echohl WarningMsg | echo 'No comment on this line' | echohl NONE | return`,
|
|
212
|
+
` endif`,
|
|
213
|
+
` let l:cur = get(s:comments[l:idx], 'comment', '')`,
|
|
214
|
+
` let l:txt = input('Edit comment: ', l:cur)`,
|
|
215
|
+
` redraw`,
|
|
216
|
+
` if empty(trim(l:txt))`,
|
|
217
|
+
` echohl WarningMsg | echo 'Edit cancelled — comment unchanged' | echohl NONE | return`,
|
|
218
|
+
` endif`,
|
|
219
|
+
` let s:comments[l:idx]['comment'] = l:txt`,
|
|
220
|
+
` call s:Save()`,
|
|
221
|
+
` call s:RenderList()`,
|
|
222
|
+
` echo 'Updated comment'`,
|
|
223
|
+
`endfunction`,
|
|
224
|
+
``,
|
|
225
|
+
`function! s:List() abort`,
|
|
226
|
+
` let l:wid = bufwinid('__HL_Comments__')`,
|
|
227
|
+
` if l:wid != -1`,
|
|
228
|
+
` call win_gotoid(l:wid) | close | return`,
|
|
229
|
+
` endif`,
|
|
230
|
+
` botright 10split __HL_Comments__`,
|
|
231
|
+
` setlocal buftype=nofile bufhidden=wipe noswapfile nobuflisted`,
|
|
232
|
+
` setlocal nonumber cursorline winfixheight signcolumn=no`,
|
|
233
|
+
` call s:RenderList()`,
|
|
176
234
|
` nnoremap <buffer> <silent> q :close<CR>`,
|
|
177
235
|
` nnoremap <buffer> <silent> <Space>l :close<CR>`,
|
|
236
|
+
` nnoremap <buffer> <silent> dd :call <SID>ListDelete()<CR>`,
|
|
237
|
+
` nnoremap <buffer> <silent> e :call <SID>ListEdit()<CR>`,
|
|
238
|
+
` nnoremap <buffer> <silent> <CR> :call <SID>ListEdit()<CR>`,
|
|
239
|
+
` echohl Question | echo 'list — e/<CR> edit · dd delete · q/<Space>l close' | echohl NONE`,
|
|
178
240
|
`endfunction`,
|
|
179
241
|
``,
|
|
180
242
|
`function! s:Submit() abort`,
|
|
@@ -189,7 +251,7 @@ function reviewVimscript() {
|
|
|
189
251
|
`command! HLList call <SID>List()`,
|
|
190
252
|
`command! HLUndo call <SID>Undo()`,
|
|
191
253
|
`command! HLSubmit call <SID>Submit()`,
|
|
192
|
-
`command! HLHelp echo 'REVIEW <Space>c comment
|
|
254
|
+
`command! HLHelp echo 'REVIEW <Space>c comment <Space>l list <Space>u undo-last <Space>s submit & quit | in list: e/<CR> edit · dd delete · q close (any quit submits)'`,
|
|
193
255
|
``,
|
|
194
256
|
`" Highlights are (re)applied after any colorscheme so the user's theme`,
|
|
195
257
|
`" (e.g. gloam) loads first, then our anchor highlight sits on top.`,
|
|
@@ -215,9 +277,51 @@ function reviewVimscript() {
|
|
|
215
277
|
` if &filetype !=# 'markdown' | setlocal filetype=markdown | endif`,
|
|
216
278
|
` " gloam only defines treesitter @markup.* highlight groups for markdown,`,
|
|
217
279
|
` " so the styling needs treesitter active. Built-in treesitter plus the`,
|
|
218
|
-
` " site-dir markdown parser render it with zero plugins
|
|
280
|
+
` " site-dir markdown parser render it with zero plugins. When the user has`,
|
|
281
|
+
` " render-markdown.nvim installed (lazy dir / packpath), pull it in too so`,
|
|
282
|
+
` " GFM tables and headings render the same as their normal editor — it only`,
|
|
283
|
+
` " draws extmarks/conceal, so the read-only buffer text (and the line:col`,
|
|
284
|
+
` " anchors comments hang off of) is never touched.`,
|
|
219
285
|
` if has('nvim')`,
|
|
220
|
-
` silent! lua
|
|
286
|
+
` silent! lua << HLLUA`,
|
|
287
|
+
`pcall(vim.treesitter.start, 0, 'markdown')`,
|
|
288
|
+
`pcall(function()`,
|
|
289
|
+
` local path = vim.fn.stdpath('data') .. '/lazy/render-markdown.nvim'`,
|
|
290
|
+
` if vim.fn.isdirectory(path) == 0 then`,
|
|
291
|
+
` local hits = vim.fn.globpath(vim.o.packpath, '*/*/render-markdown.nvim', false, true)`,
|
|
292
|
+
` path = hits[1]`,
|
|
293
|
+
` end`,
|
|
294
|
+
` if not path or vim.fn.isdirectory(path) == 0 then return end`,
|
|
295
|
+
` vim.opt.runtimepath:prepend(path)`,
|
|
296
|
+
` vim.g.render_markdown_config = {`,
|
|
297
|
+
` file_types = { 'markdown' },`,
|
|
298
|
+
` completions = { lsp = { enabled = false } },`,
|
|
299
|
+
` heading = { border = false },`,
|
|
300
|
+
` }`,
|
|
301
|
+
` vim.cmd('silent! runtime plugin/render-markdown.lua')`,
|
|
302
|
+
` -- Defer the initial paint to the next tick: during VimEnter the window`,
|
|
303
|
+
` -- isn't laid out yet, so an immediate render no-ops and tables show raw`,
|
|
304
|
+
` -- until the first cursor move. api.render() forces a paint for the`,
|
|
305
|
+
` -- buffer's window (the live autocmds the plugin installed keep it updated`,
|
|
306
|
+
` -- on edits/scroll thereafter). render-markdown refuses to draw while a`,
|
|
307
|
+
` -- prompt mode is active ('r' hit-enter, 'rm' more, 'r?' confirm) — e.g. a`,
|
|
308
|
+
` -- wrapped :echo — so re-defer until we're back in a normal state.`,
|
|
309
|
+
` local function paint()`,
|
|
310
|
+
` local m = vim.api.nvim_get_mode().mode`,
|
|
311
|
+
` if m == 'r' or m == 'rm' or m == 'r?' then`,
|
|
312
|
+
` vim.defer_fn(paint, 60)`,
|
|
313
|
+
` return`,
|
|
314
|
+
` end`,
|
|
315
|
+
` pcall(function()`,
|
|
316
|
+
` require('render-markdown.api').render({`,
|
|
317
|
+
` buf = vim.api.nvim_get_current_buf(),`,
|
|
318
|
+
` win = vim.api.nvim_get_current_win(),`,
|
|
319
|
+
` })`,
|
|
320
|
+
` end)`,
|
|
321
|
+
` end`,
|
|
322
|
+
` vim.schedule(paint)`,
|
|
323
|
+
`end)`,
|
|
324
|
+
`HLLUA`,
|
|
221
325
|
` endif`,
|
|
222
326
|
` " Buffer-local <Space> maps. Clean config has no which-key/<leader>`,
|
|
223
327
|
` " bindings to collide with, and these are gone outside this buffer.`,
|
|
@@ -230,11 +334,29 @@ function reviewVimscript() {
|
|
|
230
334
|
` call s:Load()`,
|
|
231
335
|
` call s:Marks()`,
|
|
232
336
|
` redraw`,
|
|
233
|
-
`
|
|
337
|
+
` " Keep this hint short: a line that wraps past the cmdline triggers the`,
|
|
338
|
+
` " hit-enter prompt, whose mode blocks the deferred render-markdown paint.`,
|
|
339
|
+
` " The full key list lives in :HLHelp (and is printed to stderr on launch).`,
|
|
340
|
+
` echohl Question | echo 'hl review — <Space>c comment · l list · u undo · s submit (:HLHelp)' | echohl NONE`,
|
|
234
341
|
`endfunction`,
|
|
235
342
|
`autocmd VimEnter * call s:Setup()`,
|
|
236
343
|
`autocmd VimLeavePre * call s:Save()`,
|
|
237
344
|
``,
|
|
345
|
+
`" Watchmode: the source is opened read-only here but may be rewritten on`,
|
|
346
|
+
`" disk by the agent while the human reviews. autoread + a periodic checktime`,
|
|
347
|
+
`" (timers fire even when the tmux pane is unfocused) reload the buffer`,
|
|
348
|
+
`" silently — it is never locally modified, so there is nothing to clobber.`,
|
|
349
|
+
`" On reload, re-run Setup (read-only guard, treesitter, maps) and redraw the`,
|
|
350
|
+
`" comment anchors, which the reload cleared. Comments live in s:comments /`,
|
|
351
|
+
`" the autosave file, not the buffer, so they survive; their line anchors may`,
|
|
352
|
+
`" drift if the edit moved text, which is expected — the human sees the latest.`,
|
|
353
|
+
`set autoread`,
|
|
354
|
+
`autocmd FocusGained,BufEnter,CursorHold * silent! checktime`,
|
|
355
|
+
`autocmd FileChangedShellPost * call s:Setup() | call s:Marks() | redraw`,
|
|
356
|
+
`if exists('*timer_start')`,
|
|
357
|
+
` let s:hl_watch = timer_start(1000, {-> execute('silent! checktime')}, {'repeat': -1})`,
|
|
358
|
+
`endif`,
|
|
359
|
+
``,
|
|
238
360
|
].join('\n');
|
|
239
361
|
}
|
|
240
362
|
function atomicWrite(path, data) {
|
|
@@ -384,9 +506,12 @@ export async function launchReview(file, opts) {
|
|
|
384
506
|
}
|
|
385
507
|
const outPath = resolve(opts.output);
|
|
386
508
|
const bin = resolveEditor(opts.editor);
|
|
387
|
-
|
|
509
|
+
// When the async kickoff passes its job dir, source the review.vim the parent
|
|
510
|
+
// already wrote there; otherwise mint a private temp dir as before.
|
|
511
|
+
const dir = opts.jobDir ?? mkdtempSync(join(tmpdir(), 'hl-review-'));
|
|
388
512
|
const initPath = join(dir, 'review.vim');
|
|
389
|
-
|
|
513
|
+
if (!existsSync(initPath))
|
|
514
|
+
writeFileSync(initPath, reviewVimscript());
|
|
390
515
|
const env = {
|
|
391
516
|
...process.env,
|
|
392
517
|
HL_OUTPUT: outPath,
|