@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
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { spawn, spawnSync, execFileSync } from 'child_process';
|
|
2
|
+
import { readFileSync, existsSync, writeFileSync, renameSync, mkdtempSync } from 'fs';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { resolve, join } from 'path';
|
|
5
|
+
function shellQuote(s) {
|
|
6
|
+
if (s.length > 0 && /^[a-zA-Z0-9_\-./:@%+=]+$/.test(s))
|
|
7
|
+
return s;
|
|
8
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
9
|
+
}
|
|
10
|
+
function resolveEditor(override) {
|
|
11
|
+
const candidates = override ? [override] : ['nvim', 'vim'];
|
|
12
|
+
for (const bin of candidates) {
|
|
13
|
+
try {
|
|
14
|
+
const r = spawnSync(bin, ['--version'], { stdio: 'ignore' });
|
|
15
|
+
if (r.status === 0)
|
|
16
|
+
return bin;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// not on PATH — try next
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
throw new Error(override
|
|
23
|
+
? `Editor not found or not runnable: ${override}`
|
|
24
|
+
: 'No editor found: install Neovim (nvim) or Vim (vim) — `hl propose` runs the review in your editor.');
|
|
25
|
+
}
|
|
26
|
+
// The entire review UX as a clean, minimal Vimscript config sourced via `-u`.
|
|
27
|
+
// Works in both Neovim and Vim 8+. The source file is opened read-only; the
|
|
28
|
+
// human anchors comments to real source lines/selections and quits to submit.
|
|
29
|
+
function reviewVimscript() {
|
|
30
|
+
return [
|
|
31
|
+
`" hl propose — review layer. Runs on a CLEAN config (nvim -u NONE: no`,
|
|
32
|
+
`" init.lua, no LazyVim, no plugins/keymaps). Look/feel is ONLY the user's`,
|
|
33
|
+
`" 'gloam' colorscheme + built-in treesitter markdown highlighting, applied`,
|
|
34
|
+
`" below. The rest is the read-only guard, comment commands, and autosave.`,
|
|
35
|
+
`let g:hl_out = $HL_OUTPUT`,
|
|
36
|
+
`let g:hl_src = $HL_SOURCE`,
|
|
37
|
+
`let s:comments = []`,
|
|
38
|
+
`let s:idseq = 0`,
|
|
39
|
+
`let s:ns = exists('*nvim_create_namespace') ? nvim_create_namespace('hl_review') : -1`,
|
|
40
|
+
``,
|
|
41
|
+
`function! s:Load() abort`,
|
|
42
|
+
` if g:hl_out ==# '' || !filereadable(g:hl_out)`,
|
|
43
|
+
` return`,
|
|
44
|
+
` endif`,
|
|
45
|
+
` try`,
|
|
46
|
+
` let l:obj = json_decode(join(readfile(g:hl_out), "\\n"))`,
|
|
47
|
+
` if type(l:obj) == type({}) && get(l:obj,'file','') ==# g:hl_src && !get(l:obj,'submitted',0)`,
|
|
48
|
+
` let s:comments = get(l:obj,'comments',[])`,
|
|
49
|
+
` endif`,
|
|
50
|
+
` catch`,
|
|
51
|
+
` endtry`,
|
|
52
|
+
`endfunction`,
|
|
53
|
+
``,
|
|
54
|
+
`function! s:Save() abort`,
|
|
55
|
+
` let l:obj = {'file': g:hl_src, 'submitted': v:false, 'approved': v:false, 'comments': s:comments, 'savedAt': strftime('%Y-%m-%dT%H:%M:%S')}`,
|
|
56
|
+
` let l:tmp = g:hl_out . '.tmp'`,
|
|
57
|
+
` call writefile([json_encode(l:obj)], l:tmp)`,
|
|
58
|
+
` call rename(l:tmp, g:hl_out)`,
|
|
59
|
+
`endfunction`,
|
|
60
|
+
``,
|
|
61
|
+
`function! s:Marks() abort`,
|
|
62
|
+
` silent! sign unplace *`,
|
|
63
|
+
` if s:ns >= 0`,
|
|
64
|
+
` call nvim_buf_clear_namespace(0, s:ns, 0, -1)`,
|
|
65
|
+
` endif`,
|
|
66
|
+
` let l:sid = 1`,
|
|
67
|
+
` for l:c in s:comments`,
|
|
68
|
+
` let l:ln = get(l:c,'line',1)`,
|
|
69
|
+
` let l:end = get(l:c,'endLine',l:ln)`,
|
|
70
|
+
` execute 'sign place ' . l:sid . ' line=' . l:ln . ' name=HLgutter buffer=' . bufnr('%')`,
|
|
71
|
+
` let l:sid += 1`,
|
|
72
|
+
` let l:cs = get(l:c,'colStart',-1)`,
|
|
73
|
+
` let l:ce = get(l:c,'colEnd',-1)`,
|
|
74
|
+
` let l:ranged = 0`,
|
|
75
|
+
` if s:ns >= 0 && type(l:cs) == type(0) && type(l:ce) == type(0) && l:cs >= 0 && l:ce > l:cs`,
|
|
76
|
+
` try`,
|
|
77
|
+
` call nvim_buf_set_extmark(0, s:ns, l:ln - 1, l:cs, {'end_row': l:end - 1, 'end_col': l:ce, 'hl_group': 'HLRange', 'priority': 200})`,
|
|
78
|
+
` let l:ranged = 1`,
|
|
79
|
+
` catch`,
|
|
80
|
+
` endtry`,
|
|
81
|
+
` endif`,
|
|
82
|
+
` if !l:ranged`,
|
|
83
|
+
` let l:k = l:ln`,
|
|
84
|
+
` while l:k <= l:end`,
|
|
85
|
+
` execute 'sign place ' . l:sid . ' line=' . l:k . ' name=HLline buffer=' . bufnr('%')`,
|
|
86
|
+
` let l:sid += 1`,
|
|
87
|
+
` let l:k += 1`,
|
|
88
|
+
` endwhile`,
|
|
89
|
+
` endif`,
|
|
90
|
+
` endfor`,
|
|
91
|
+
`endfunction`,
|
|
92
|
+
``,
|
|
93
|
+
`function! s:Comment(mode) abort`,
|
|
94
|
+
` let l:sz = getreg('z')`,
|
|
95
|
+
` let l:szt = getregtype('z')`,
|
|
96
|
+
` let l:cs = -1`,
|
|
97
|
+
` let l:ce = -1`,
|
|
98
|
+
` if a:mode ==# 'v'`,
|
|
99
|
+
` silent! normal! gv"zy`,
|
|
100
|
+
` let l:vm = visualmode()`,
|
|
101
|
+
` let l:l1 = line("'<")`,
|
|
102
|
+
` let l:l2 = line("'>")`,
|
|
103
|
+
` let l:raw = getreg('z')`,
|
|
104
|
+
` let l:quote = substitute(l:raw, '\\n\\+$', '', '')`,
|
|
105
|
+
` if l:vm ==# 'v'`,
|
|
106
|
+
` let l:segs = split(l:raw, "\\n", 1)`,
|
|
107
|
+
` let l:cs = col("'<") - 1`,
|
|
108
|
+
` let l:ce = len(l:segs) <= 1 ? (l:cs + strlen(l:segs[0])) : strlen(l:segs[-1])`,
|
|
109
|
+
` endif`,
|
|
110
|
+
` else`,
|
|
111
|
+
` let l:l1 = line('.')`,
|
|
112
|
+
` let l:l2 = l:l1`,
|
|
113
|
+
` let l:quote = ''`,
|
|
114
|
+
` endif`,
|
|
115
|
+
` call setreg('z', l:sz, l:szt)`,
|
|
116
|
+
` let l:label = l:l1 == l:l2 ? ('line ' . l:l1) : ('lines ' . l:l1 . '-' . l:l2)`,
|
|
117
|
+
` let l:txt = input('Comment on ' . l:label . ': ')`,
|
|
118
|
+
` redraw`,
|
|
119
|
+
` if empty(trim(l:txt))`,
|
|
120
|
+
` echohl WarningMsg | echo 'Comment cancelled' | echohl NONE`,
|
|
121
|
+
` return`,
|
|
122
|
+
` endif`,
|
|
123
|
+
` let s:idseq += 1`,
|
|
124
|
+
` let l:item = {'id': 'c' . localtime() . s:idseq, 'line': l:l1, 'endLine': l:l2, 'lineText': join(getline(l:l1, l:l2), "\\n"), 'comment': l:txt, 'createdAt': strftime('%Y-%m-%dT%H:%M:%S')}`,
|
|
125
|
+
` if a:mode ==# 'v' && !empty(l:quote)`,
|
|
126
|
+
` let l:item['quote'] = l:quote`,
|
|
127
|
+
` endif`,
|
|
128
|
+
` if l:cs >= 0 && l:ce > l:cs`,
|
|
129
|
+
` let l:item['colStart'] = l:cs`,
|
|
130
|
+
` let l:item['colEnd'] = l:ce`,
|
|
131
|
+
` endif`,
|
|
132
|
+
` call add(s:comments, l:item)`,
|
|
133
|
+
` call s:Save()`,
|
|
134
|
+
` call s:Marks()`,
|
|
135
|
+
` echo 'Saved — ' . len(s:comments) . ' comment' . (len(s:comments)==1?'':'s')`,
|
|
136
|
+
`endfunction`,
|
|
137
|
+
``,
|
|
138
|
+
`function! s:Undo() abort`,
|
|
139
|
+
` if empty(s:comments)`,
|
|
140
|
+
` echohl WarningMsg | echo 'No comments to undo' | echohl NONE`,
|
|
141
|
+
` return`,
|
|
142
|
+
` endif`,
|
|
143
|
+
` call remove(s:comments, -1)`,
|
|
144
|
+
` call s:Save()`,
|
|
145
|
+
` call s:Marks()`,
|
|
146
|
+
` echo 'Removed last comment — ' . len(s:comments) . ' left'`,
|
|
147
|
+
`endfunction`,
|
|
148
|
+
``,
|
|
149
|
+
`function! s:List() abort`,
|
|
150
|
+
` let l:wid = bufwinid('__HL_Comments__')`,
|
|
151
|
+
` if l:wid != -1`,
|
|
152
|
+
` call win_gotoid(l:wid) | close | return`,
|
|
153
|
+
` endif`,
|
|
154
|
+
` let l:lines = []`,
|
|
155
|
+
` if empty(s:comments)`,
|
|
156
|
+
` let l:lines = ['(no comments yet — select text or put the cursor on a line, then <Space>c or :HLComment)']`,
|
|
157
|
+
` else`,
|
|
158
|
+
` let l:i = 1`,
|
|
159
|
+
` for l:c in s:comments`,
|
|
160
|
+
` let l:ln = get(l:c,'line',0)`,
|
|
161
|
+
` let l:end = get(l:c,'endLine',l:ln)`,
|
|
162
|
+
` let l:loc = l:ln == l:end ? ('L' . l:ln) : ('L' . l:ln . '-' . l:end)`,
|
|
163
|
+
` call add(l:lines, l:i . '. [' . l:loc . '] ' . get(l:c,'comment',''))`,
|
|
164
|
+
` if !empty(get(l:c,'quote',''))`,
|
|
165
|
+
` call add(l:lines, ' > ' . substitute(get(l:c,'quote',''), "\\n", ' / ', 'g'))`,
|
|
166
|
+
` endif`,
|
|
167
|
+
` let l:i += 1`,
|
|
168
|
+
` endfor`,
|
|
169
|
+
` endif`,
|
|
170
|
+
` botright 10split __HL_Comments__`,
|
|
171
|
+
` setlocal buftype=nofile bufhidden=wipe noswapfile nobuflisted`,
|
|
172
|
+
` setlocal nonumber nocursorline winfixheight signcolumn=no`,
|
|
173
|
+
` setlocal modifiable`,
|
|
174
|
+
` call setline(1, l:lines)`,
|
|
175
|
+
` setlocal nomodifiable`,
|
|
176
|
+
` nnoremap <buffer> <silent> q :close<CR>`,
|
|
177
|
+
` nnoremap <buffer> <silent> <Space>l :close<CR>`,
|
|
178
|
+
`endfunction`,
|
|
179
|
+
``,
|
|
180
|
+
`function! s:Submit() abort`,
|
|
181
|
+
` call s:Save()`,
|
|
182
|
+
` qa!`,
|
|
183
|
+
`endfunction`,
|
|
184
|
+
``,
|
|
185
|
+
`command! HLComment call <SID>Comment('n')`,
|
|
186
|
+
`command! HLList call <SID>List()`,
|
|
187
|
+
`command! HLUndo call <SID>Undo()`,
|
|
188
|
+
`command! HLSubmit call <SID>Submit()`,
|
|
189
|
+
`command! HLHelp echo 'REVIEW <Space>c comment (visual or line) <Space>l list <Space>u undo-last <Space>s submit & quit — or :HLComment/:HLList/:HLUndo/:HLSubmit (any quit submits)'`,
|
|
190
|
+
``,
|
|
191
|
+
`" Highlights are (re)applied after any colorscheme so the user's theme`,
|
|
192
|
+
`" (e.g. gloam) loads first, then our anchor highlight sits on top.`,
|
|
193
|
+
`function! s:Hi() abort`,
|
|
194
|
+
` sign define HLgutter text=>> texthl=HLSign`,
|
|
195
|
+
` sign define HLline linehl=HLLine`,
|
|
196
|
+
` hi! HLSign ctermfg=178 guifg=#d7af00`,
|
|
197
|
+
` hi! HLLine ctermbg=229 guibg=#fff3bf guifg=#3a2f00`,
|
|
198
|
+
` hi! HLRange ctermbg=222 guibg=#ffe066 guifg=#1c1500`,
|
|
199
|
+
`endfunction`,
|
|
200
|
+
`call s:Hi()`,
|
|
201
|
+
`autocmd ColorScheme * call s:Hi()`,
|
|
202
|
+
`" Load ONLY the user's colorscheme: self-contained at`,
|
|
203
|
+
`" ~/.config/nvim/colors/gloam.lua, needs no plugins, and -u NONE keeps the`,
|
|
204
|
+
`" config dir on runtimepath so it resolves. Fires ColorScheme, so the`,
|
|
205
|
+
`" autocmd above reapplies our anchor highlights on top of gloam.`,
|
|
206
|
+
`silent! colorscheme gloam`,
|
|
207
|
+
``,
|
|
208
|
+
`function! s:Setup() abort`,
|
|
209
|
+
` " Read-only guard so review never mutates the source doc.`,
|
|
210
|
+
` setlocal nomodifiable`,
|
|
211
|
+
` setlocal signcolumn=yes`,
|
|
212
|
+
` if &filetype !=# 'markdown' | setlocal filetype=markdown | endif`,
|
|
213
|
+
` " gloam only defines treesitter @markup.* highlight groups for markdown,`,
|
|
214
|
+
` " so the styling needs treesitter active. Built-in treesitter plus the`,
|
|
215
|
+
` " site-dir markdown parser render it with zero plugins.`,
|
|
216
|
+
` if has('nvim')`,
|
|
217
|
+
` silent! lua pcall(vim.treesitter.start, 0, 'markdown')`,
|
|
218
|
+
` endif`,
|
|
219
|
+
` " Buffer-local <Space> maps. Clean config has no which-key/<leader>`,
|
|
220
|
+
` " bindings to collide with, and these are gone outside this buffer.`,
|
|
221
|
+
` vnoremap <buffer> <silent> <Space>c :<C-u>call <SID>Comment('v')<CR>`,
|
|
222
|
+
` nnoremap <buffer> <silent> <Space>c :call <SID>Comment('n')<CR>`,
|
|
223
|
+
` nnoremap <buffer> <silent> <Space>l :call <SID>List()<CR>`,
|
|
224
|
+
` nnoremap <buffer> <silent> <Space>u :call <SID>Undo()<CR>`,
|
|
225
|
+
` nnoremap <buffer> <silent> <Space>s :call <SID>Submit()<CR>`,
|
|
226
|
+
` call s:Hi()`,
|
|
227
|
+
` call s:Load()`,
|
|
228
|
+
` call s:Marks()`,
|
|
229
|
+
` redraw`,
|
|
230
|
+
` echohl Question | echo 'hl review — <Space>c comment <Space>l list <Space>u undo <Space>s submit & quit (:HLHelp)' | echohl NONE`,
|
|
231
|
+
`endfunction`,
|
|
232
|
+
`autocmd VimEnter * call s:Setup()`,
|
|
233
|
+
`autocmd VimLeavePre * call s:Save()`,
|
|
234
|
+
``,
|
|
235
|
+
].join('\n');
|
|
236
|
+
}
|
|
237
|
+
function atomicWrite(path, data) {
|
|
238
|
+
const tmp = `${path}.tmp`;
|
|
239
|
+
writeFileSync(tmp, data);
|
|
240
|
+
renameSync(tmp, path);
|
|
241
|
+
}
|
|
242
|
+
function sanitizeComments(raw) {
|
|
243
|
+
if (!Array.isArray(raw))
|
|
244
|
+
return [];
|
|
245
|
+
const out = [];
|
|
246
|
+
for (const r of raw) {
|
|
247
|
+
if (typeof r !== 'object' || r === null)
|
|
248
|
+
continue;
|
|
249
|
+
const c = r;
|
|
250
|
+
const comment = typeof c.comment === 'string' ? c.comment.trim() : '';
|
|
251
|
+
if (!comment)
|
|
252
|
+
continue;
|
|
253
|
+
const line = Number(c.line) || 1;
|
|
254
|
+
const endLine = Number(c.endLine) || line;
|
|
255
|
+
const colStart = Number.isInteger(c.colStart) ? c.colStart : undefined;
|
|
256
|
+
const colEnd = Number.isInteger(c.colEnd) ? c.colEnd : undefined;
|
|
257
|
+
out.push({
|
|
258
|
+
id: typeof c.id === 'string' && c.id ? c.id : `c${out.length}`,
|
|
259
|
+
line,
|
|
260
|
+
endLine,
|
|
261
|
+
colStart: colStart !== undefined && colEnd !== undefined && colEnd > colStart ? colStart : undefined,
|
|
262
|
+
colEnd: colStart !== undefined && colEnd !== undefined && colEnd > colStart ? colEnd : undefined,
|
|
263
|
+
quote: typeof c.quote === 'string' && c.quote ? c.quote : undefined,
|
|
264
|
+
lineText: typeof c.lineText === 'string' ? c.lineText : '',
|
|
265
|
+
comment,
|
|
266
|
+
createdAt: typeof c.createdAt === 'string' ? c.createdAt : new Date().toISOString(),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
const FEEDBACK_SCHEMA = '{file, submitted, approved, comments:[{id, line, endLine, quote?, colStart?, colEnd?, lineText, comment, createdAt}], submittedAt, savedAt}';
|
|
272
|
+
function rangeLabel(c) {
|
|
273
|
+
const cols = Number.isInteger(c.colStart) && Number.isInteger(c.colEnd);
|
|
274
|
+
if (c.line === c.endLine) {
|
|
275
|
+
return cols ? `L${c.line}:${c.colStart}-${c.colEnd}` : `L${c.line}`;
|
|
276
|
+
}
|
|
277
|
+
return cols ? `L${c.line}:${c.colStart}-${c.endLine}:${c.colEnd}` : `L${c.line}-${c.endLine}`;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Compact stdout rendering for the agent: per comment just the line:col
|
|
281
|
+
* range, the original text in that span, and the note — plus the source
|
|
282
|
+
* path, a pointer to the full JSON on disk, and a one-line schema hint.
|
|
283
|
+
* The verbose fields are deliberately not printed so they don't clog context.
|
|
284
|
+
*/
|
|
285
|
+
export function formatFeedbackSummary(result, feedbackJsonPath) {
|
|
286
|
+
const n = result.comments.length;
|
|
287
|
+
const out = [];
|
|
288
|
+
if (n === 0) {
|
|
289
|
+
out.push(`hl propose — approved: 0 comments on ${result.file} (human signalled looks-good; proceed)`);
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
out.push(`hl propose — ${n} comment${n === 1 ? '' : 's'} on ${result.file}`);
|
|
293
|
+
out.push('');
|
|
294
|
+
result.comments.forEach((c, i) => {
|
|
295
|
+
const original = c.quote && c.quote.length > 0 ? c.quote : c.lineText;
|
|
296
|
+
const lines = original.split('\n');
|
|
297
|
+
out.push(` ${i + 1}. ${rangeLabel(c)}`);
|
|
298
|
+
lines.forEach((ln, k) => out.push(k === 0 ? ` text: ${ln}` : ` ${ln}`));
|
|
299
|
+
out.push(` comment: ${c.comment}`);
|
|
300
|
+
out.push('');
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
out.push(`Full record → ${feedbackJsonPath} (other fields rarely needed)`);
|
|
304
|
+
out.push(` schema: ${FEEDBACK_SCHEMA} · cols are 0-based byte offsets, colEnd exclusive`);
|
|
305
|
+
return out.join('\n');
|
|
306
|
+
}
|
|
307
|
+
async function runInTmuxPane(paneCmd) {
|
|
308
|
+
// `paneCmd` MUST be an `exec`-prefixed command so the editor replaces the
|
|
309
|
+
// shell and becomes the pane's process. tmux's `#{pane_current_command}`
|
|
310
|
+
// then reports `nvim` — many users gate "let Ctrl-D/Ctrl-U/etc. through to
|
|
311
|
+
// the app vs. take over with tmux copy-mode" on exactly that. If the pane
|
|
312
|
+
// command were `nvim …; tmux wait-for` the pane process stays the shell,
|
|
313
|
+
// pane_current_command is `zsh`/`sh`, and those bindings hijack native vim
|
|
314
|
+
// keys. Because the editor is exec'd there is no "after" to signal from, so
|
|
315
|
+
// completion is detected purely by the pane disappearing on exit.
|
|
316
|
+
const paneId = execFileSync('tmux', ['split-window', '-h', '-d', '-P', '-F', '#{pane_id}', paneCmd], { encoding: 'utf8' }).trim();
|
|
317
|
+
// Ensure the pane closes when the editor exits (some configs set
|
|
318
|
+
// remain-on-exit globally, which would hang the poll below).
|
|
319
|
+
try {
|
|
320
|
+
execFileSync('tmux', ['set-option', '-p', '-t', paneId, 'remain-on-exit', 'off'], { stdio: 'ignore' });
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// older tmux without -p pane scope — default is already 'off'
|
|
324
|
+
}
|
|
325
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
326
|
+
let settled = false;
|
|
327
|
+
const finish = (fn) => { if (!settled) {
|
|
328
|
+
settled = true;
|
|
329
|
+
clearInterval(poll);
|
|
330
|
+
fn();
|
|
331
|
+
} };
|
|
332
|
+
// The editor IS the pane process; when it exits the pane is destroyed.
|
|
333
|
+
const poll = setInterval(() => {
|
|
334
|
+
try {
|
|
335
|
+
const panes = execFileSync('tmux', ['list-panes', '-a', '-F', '#{pane_id}'], { encoding: 'utf8' });
|
|
336
|
+
if (!panes.split('\n').map((s) => s.trim()).includes(paneId)) {
|
|
337
|
+
finish(resolvePromise);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
catch (e) {
|
|
341
|
+
finish(() => rejectPromise(new Error(`tmux list-panes failed: ${e instanceof Error ? e.message : String(e)}`)));
|
|
342
|
+
}
|
|
343
|
+
}, 200);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
function runInCurrentTerminal(bin, args, env) {
|
|
347
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
348
|
+
const child = spawn(bin, args, { stdio: 'inherit', env });
|
|
349
|
+
child.on('error', rejectPromise);
|
|
350
|
+
child.on('exit', () => resolvePromise());
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Open a markdown file in a clean, read-only Neovim/Vim review session. The
|
|
355
|
+
* human anchors comments to source lines/selections with native vim motions
|
|
356
|
+
* and quits to submit. Blocks until the editor exits, then finalizes and
|
|
357
|
+
* returns the feedback. Autosaved continuously so a kill is recoverable and
|
|
358
|
+
* the next run resumes.
|
|
359
|
+
*/
|
|
360
|
+
export async function launchReview(file, opts) {
|
|
361
|
+
const absFile = resolve(file);
|
|
362
|
+
if (!existsSync(absFile)) {
|
|
363
|
+
throw new Error(`Markdown file not found: ${absFile}`);
|
|
364
|
+
}
|
|
365
|
+
const outPath = resolve(opts.output);
|
|
366
|
+
const bin = resolveEditor(opts.editor);
|
|
367
|
+
const dir = mkdtempSync(join(tmpdir(), 'hl-review-'));
|
|
368
|
+
const initPath = join(dir, 'review.vim');
|
|
369
|
+
writeFileSync(initPath, reviewVimscript());
|
|
370
|
+
const env = { ...process.env, HL_OUTPUT: outPath, HL_SOURCE: absFile };
|
|
371
|
+
// `-u NONE`: do NOT load the user's init.lua / LazyVim / plugins / keymaps.
|
|
372
|
+
// Default runtimepath still includes the config dir (for the gloam
|
|
373
|
+
// colorscheme) and the site dir (for the treesitter markdown parser), so the
|
|
374
|
+
// review layer pulls in ONLY the colorscheme + treesitter styling itself.
|
|
375
|
+
const editorArgs = ['-u', 'NONE', '-n', '-i', 'NONE', absFile, '-c', `source ${initPath}`];
|
|
376
|
+
const inTmux = !!process.env.TMUX && !opts.noTmux;
|
|
377
|
+
process.stderr.write(`\nhumanloop: opening "${absFile}" for review in ${bin}` +
|
|
378
|
+
(inTmux ? ' (tmux pane).\n' : '.\n') +
|
|
379
|
+
` Answers : ${outPath}\n` +
|
|
380
|
+
` Keys : <Space>c comment · <Space>l list · <Space>u undo · <Space>s submit & quit (or :HLComment/:HLSubmit)\n` +
|
|
381
|
+
` Status : BLOCKING — waiting for you to finish the review and quit the editor.\n\n`);
|
|
382
|
+
if (inTmux) {
|
|
383
|
+
// `exec env …` so the editor replaces the shell and becomes the pane's
|
|
384
|
+
// process (so tmux `#{pane_current_command}` is `nvim`, not the shell).
|
|
385
|
+
const paneCmd = [
|
|
386
|
+
'exec',
|
|
387
|
+
'env',
|
|
388
|
+
`HL_OUTPUT=${shellQuote(outPath)}`,
|
|
389
|
+
`HL_SOURCE=${shellQuote(absFile)}`,
|
|
390
|
+
shellQuote(bin),
|
|
391
|
+
...editorArgs.map(shellQuote),
|
|
392
|
+
].join(' ');
|
|
393
|
+
try {
|
|
394
|
+
await runInTmuxPane(paneCmd);
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
process.stderr.write(`tmux dispatch failed, running in current terminal: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
398
|
+
await runInCurrentTerminal(bin, editorArgs, env);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
await runInCurrentTerminal(bin, editorArgs, env);
|
|
403
|
+
}
|
|
404
|
+
let comments = [];
|
|
405
|
+
if (existsSync(outPath)) {
|
|
406
|
+
try {
|
|
407
|
+
const prior = JSON.parse(readFileSync(outPath, 'utf8'));
|
|
408
|
+
comments = sanitizeComments(prior.comments);
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
// unreadable autosave — treat as no comments rather than failing the run
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const now = new Date().toISOString();
|
|
415
|
+
const result = {
|
|
416
|
+
file: absFile,
|
|
417
|
+
submitted: true,
|
|
418
|
+
approved: comments.length === 0,
|
|
419
|
+
comments,
|
|
420
|
+
submittedAt: now,
|
|
421
|
+
savedAt: now,
|
|
422
|
+
};
|
|
423
|
+
atomicWrite(outPath, JSON.stringify(result, null, 2) + '\n');
|
|
424
|
+
return result;
|
|
425
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { InteractionResponse } from '../types.js';
|
|
2
|
+
export declare function deckPath(dir: string): string;
|
|
3
|
+
export declare function responsePath(dir: string): string;
|
|
4
|
+
export declare function progressPath(dir: string): string;
|
|
5
|
+
export declare function visualsDir(dir: string): string;
|
|
6
|
+
export declare function visualMdPath(dir: string, id: string): string;
|
|
7
|
+
export declare function visualAnsiPath(dir: string, id: string): string;
|
|
8
|
+
export type InteractionState = 'pending' | 'in-progress' | 'resolved' | 'missing';
|
|
9
|
+
export declare function interactionState(dir: string): InteractionState;
|
|
10
|
+
export declare function isResolved(dir: string): boolean;
|
|
11
|
+
/** Returns true if a live resolver owns this dir (progress.json mtime < 300s). */
|
|
12
|
+
export declare function isClaimed(dir: string): boolean;
|
|
13
|
+
export declare function atomicWriteJson(path: string, value: unknown): void;
|
|
14
|
+
export declare function readJson<T>(path: string): T | null;
|
|
15
|
+
export declare function writeResponse(dir: string, responses: InteractionResponse[], completedAt: string): string;
|
|
16
|
+
export declare function writeProgress(dir: string, responses: InteractionResponse[]): void;
|
|
17
|
+
export declare function clearProgress(dir: string): void;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync, statSync, writeFileSync, renameSync, readFileSync, unlinkSync, mkdirSync } from 'fs';
|
|
2
|
+
import { dirname } from 'path';
|
|
3
|
+
// ── Path helpers ──────────────────────────────────────────────────────────────
|
|
4
|
+
export function deckPath(dir) {
|
|
5
|
+
return `${dir}/deck.json`;
|
|
6
|
+
}
|
|
7
|
+
export function responsePath(dir) {
|
|
8
|
+
return `${dir}/response.json`;
|
|
9
|
+
}
|
|
10
|
+
export function progressPath(dir) {
|
|
11
|
+
return `${dir}/progress.json`;
|
|
12
|
+
}
|
|
13
|
+
export function visualsDir(dir) {
|
|
14
|
+
return `${dir}/visuals`;
|
|
15
|
+
}
|
|
16
|
+
export function visualMdPath(dir, id) {
|
|
17
|
+
return `${dir}/visuals/${id}.md`;
|
|
18
|
+
}
|
|
19
|
+
export function visualAnsiPath(dir, id) {
|
|
20
|
+
return `${dir}/visuals/${id}.ansi`;
|
|
21
|
+
}
|
|
22
|
+
export function interactionState(dir) {
|
|
23
|
+
const hasDeck = existsSync(deckPath(dir));
|
|
24
|
+
const hasResponse = existsSync(responsePath(dir));
|
|
25
|
+
const hasProgress = existsSync(progressPath(dir));
|
|
26
|
+
if (!hasDeck)
|
|
27
|
+
return 'missing';
|
|
28
|
+
if (hasResponse)
|
|
29
|
+
return 'resolved';
|
|
30
|
+
if (hasProgress)
|
|
31
|
+
return 'in-progress';
|
|
32
|
+
return 'pending';
|
|
33
|
+
}
|
|
34
|
+
export function isResolved(dir) {
|
|
35
|
+
return existsSync(responsePath(dir));
|
|
36
|
+
}
|
|
37
|
+
/** Returns true if a live resolver owns this dir (progress.json mtime < 300s). */
|
|
38
|
+
export function isClaimed(dir) {
|
|
39
|
+
const p = progressPath(dir);
|
|
40
|
+
if (!existsSync(p))
|
|
41
|
+
return false;
|
|
42
|
+
try {
|
|
43
|
+
const { mtimeMs } = statSync(p);
|
|
44
|
+
return Date.now() - mtimeMs < 300_000;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// ── Atomic I/O ────────────────────────────────────────────────────────────────
|
|
51
|
+
export function atomicWriteJson(path, value) {
|
|
52
|
+
const payload = JSON.stringify(value, null, 2);
|
|
53
|
+
const tmp = `${path}.tmp`;
|
|
54
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
55
|
+
writeFileSync(tmp, payload);
|
|
56
|
+
renameSync(tmp, path);
|
|
57
|
+
}
|
|
58
|
+
export function readJson(path) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// ── High-level write helpers ──────────────────────────────────────────────────
|
|
67
|
+
export function writeResponse(dir, responses, completedAt) {
|
|
68
|
+
const p = responsePath(dir);
|
|
69
|
+
atomicWriteJson(p, { responses, completedAt });
|
|
70
|
+
return p;
|
|
71
|
+
}
|
|
72
|
+
export function writeProgress(dir, responses) {
|
|
73
|
+
atomicWriteJson(progressPath(dir), {
|
|
74
|
+
partial: true,
|
|
75
|
+
responses,
|
|
76
|
+
savedAt: new Date().toISOString(),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
export function clearProgress(dir) {
|
|
80
|
+
try {
|
|
81
|
+
unlinkSync(progressPath(dir));
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (err.code !== 'ENOENT')
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { Deck } from '../types.js';
|
|
3
|
+
export declare const interactionOptionSchema: z.ZodObject<{
|
|
4
|
+
id: z.ZodString;
|
|
5
|
+
label: z.ZodString;
|
|
6
|
+
description: z.ZodOptional<z.ZodString>;
|
|
7
|
+
shortcut: z.ZodOptional<z.ZodString>;
|
|
8
|
+
}, z.core.$strip>;
|
|
9
|
+
export declare const deckSchema: z.ZodObject<{
|
|
10
|
+
title: z.ZodOptional<z.ZodString>;
|
|
11
|
+
source: z.ZodOptional<z.ZodObject<{
|
|
12
|
+
sessionName: z.ZodOptional<z.ZodString>;
|
|
13
|
+
askedBy: z.ZodOptional<z.ZodString>;
|
|
14
|
+
blockedSince: z.ZodOptional<z.ZodString>;
|
|
15
|
+
}, z.core.$strip>>;
|
|
16
|
+
interactions: z.ZodArray<z.ZodObject<{
|
|
17
|
+
id: z.ZodString;
|
|
18
|
+
title: z.ZodString;
|
|
19
|
+
subtitle: z.ZodOptional<z.ZodString>;
|
|
20
|
+
body: z.ZodOptional<z.ZodString>;
|
|
21
|
+
bodyPath: z.ZodOptional<z.ZodString>;
|
|
22
|
+
options: z.ZodArray<z.ZodObject<{
|
|
23
|
+
id: z.ZodString;
|
|
24
|
+
label: z.ZodString;
|
|
25
|
+
description: z.ZodOptional<z.ZodString>;
|
|
26
|
+
shortcut: z.ZodOptional<z.ZodString>;
|
|
27
|
+
}, z.core.$strip>>;
|
|
28
|
+
allowFreetext: z.ZodOptional<z.ZodBoolean>;
|
|
29
|
+
freetextLabel: z.ZodOptional<z.ZodString>;
|
|
30
|
+
kind: z.ZodOptional<z.ZodEnum<{
|
|
31
|
+
notify: "notify";
|
|
32
|
+
validation: "validation";
|
|
33
|
+
decision: "decision";
|
|
34
|
+
context: "context";
|
|
35
|
+
error: "error";
|
|
36
|
+
}>>;
|
|
37
|
+
}, z.core.$strip>>;
|
|
38
|
+
}, z.core.$strip>;
|
|
39
|
+
export declare function inlineBodyPath(deckPath: string, bodyPath: string): string;
|
|
40
|
+
export declare function parseDeck(deckPath: string): Deck;
|
|
41
|
+
export declare function validateDeck(parsed: unknown): Deck;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve, sep } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { checkMarkdown } from '../render/termrender.js';
|
|
5
|
+
// ── zod v4 building blocks ────────────────────────────────────────────────────
|
|
6
|
+
// v4 notes: .nonempty() → .min(1); error messages use {error: 'string'} per check.
|
|
7
|
+
export const interactionOptionSchema = z.object({
|
|
8
|
+
id: z.string().min(1),
|
|
9
|
+
label: z.string().min(1),
|
|
10
|
+
description: z.string().optional(),
|
|
11
|
+
shortcut: z.string().optional(),
|
|
12
|
+
});
|
|
13
|
+
const interactionSchema = z.object({
|
|
14
|
+
id: z.string().regex(/^[A-Za-z0-9_-]+$/, { error: 'interaction id must match /^[A-Za-z0-9_-]+$/' }).min(1).max(64),
|
|
15
|
+
title: z.string().min(1, { error: 'title must be non-empty' }),
|
|
16
|
+
subtitle: z.string().min(1, { error: 'subtitle must be non-empty when present' }).optional(),
|
|
17
|
+
body: z.string().optional(),
|
|
18
|
+
bodyPath: z.string().optional(),
|
|
19
|
+
options: z.array(interactionOptionSchema),
|
|
20
|
+
allowFreetext: z.boolean().optional(),
|
|
21
|
+
freetextLabel: z.string().optional(),
|
|
22
|
+
kind: z.enum(['notify', 'validation', 'decision', 'context', 'error']).optional(),
|
|
23
|
+
});
|
|
24
|
+
const deckSourceSchema = z.object({
|
|
25
|
+
sessionName: z.string().optional(),
|
|
26
|
+
askedBy: z.string().optional(),
|
|
27
|
+
blockedSince: z.string().optional(),
|
|
28
|
+
});
|
|
29
|
+
export const deckSchema = z.object({
|
|
30
|
+
title: z.string().optional(),
|
|
31
|
+
source: deckSourceSchema.optional(),
|
|
32
|
+
interactions: z.array(interactionSchema).min(1, { error: 'interactions[] must be non-empty' }),
|
|
33
|
+
}).superRefine((input, ctx) => {
|
|
34
|
+
const seen = new Map();
|
|
35
|
+
for (let i = 0; i < input.interactions.length; i++) {
|
|
36
|
+
const interaction = input.interactions[i];
|
|
37
|
+
if (interaction.body !== undefined && interaction.bodyPath !== undefined) {
|
|
38
|
+
ctx.addIssue({
|
|
39
|
+
code: 'custom',
|
|
40
|
+
message: 'body and bodyPath are mutually exclusive',
|
|
41
|
+
path: ['interactions', i],
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const prev = seen.get(interaction.id);
|
|
45
|
+
if (prev !== undefined) {
|
|
46
|
+
ctx.addIssue({
|
|
47
|
+
code: 'custom',
|
|
48
|
+
message: `duplicate interaction id "${interaction.id}" at indices ${prev} and ${i}`,
|
|
49
|
+
path: ['interactions', i, 'id'],
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
seen.set(interaction.id, i);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
// ── C2 bodyPath defense + inlining ────────────────────────────────────────────
|
|
56
|
+
export function inlineBodyPath(deckPath, bodyPath) {
|
|
57
|
+
const deckDir = dirname(deckPath);
|
|
58
|
+
const joined = resolve(deckDir, bodyPath);
|
|
59
|
+
// STEP 1: existence + lstat BEFORE realpath to catch symlinks and directories.
|
|
60
|
+
if (!existsSync(joined)) {
|
|
61
|
+
throw new Error(`bodyPath does not exist: '${bodyPath}' (resolved against deck dir '${deckDir}'). bodyPath is interpreted relative to the deck JSON's directory; place the body file there and use a relative path (e.g. "completion-summary.md").`);
|
|
62
|
+
}
|
|
63
|
+
const stat = lstatSync(joined);
|
|
64
|
+
if (!stat.isFile()) {
|
|
65
|
+
// Catches symlinks, directories, FIFOs — lstat does not follow symlinks.
|
|
66
|
+
throw new Error(`bodyPath must be a regular file (not a symlink, directory, or special file): ${bodyPath}`);
|
|
67
|
+
}
|
|
68
|
+
// STEP 2: realpath both sides, prefix-check (defense-in-depth for .. traversal).
|
|
69
|
+
// realpathSync is safe here: lstat already confirmed the path exists.
|
|
70
|
+
const realResolved = realpathSync(joined);
|
|
71
|
+
const realDeckDir = realpathSync(deckDir);
|
|
72
|
+
const prefix = realDeckDir + sep;
|
|
73
|
+
if (realResolved !== realDeckDir && !realResolved.startsWith(prefix)) {
|
|
74
|
+
throw new Error(`bodyPath '${bodyPath}' escapes the deck's directory ('${realDeckDir}'). bodyPath is resolved relative to the deck JSON file and must stay inside its directory (no '..', absolute paths pointing elsewhere, or symlinks out). Fix: write the deck JSON next to the body file (e.g. both inside $SISYPHUS_SESSION_DIR/context/) and use a relative path like "completion-summary.md".`);
|
|
75
|
+
}
|
|
76
|
+
// STEP 3: read. lstat confirmed regular file; realpath confirmed in-tree.
|
|
77
|
+
return readFileSync(joined, 'utf-8');
|
|
78
|
+
}
|
|
79
|
+
// ── public entry points ───────────────────────────────────────────────────────
|
|
80
|
+
export function parseDeck(deckPath) {
|
|
81
|
+
const raw = readFileSync(deckPath, 'utf-8');
|
|
82
|
+
let json;
|
|
83
|
+
try {
|
|
84
|
+
json = JSON.parse(raw);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
throw new Error('deck is not valid JSON');
|
|
88
|
+
}
|
|
89
|
+
const parsed = deckSchema.parse(json);
|
|
90
|
+
const inlinedInteractions = parsed.interactions.map(interaction => {
|
|
91
|
+
let body = interaction.body;
|
|
92
|
+
if (interaction.bodyPath !== undefined) {
|
|
93
|
+
body = inlineBodyPath(deckPath, interaction.bodyPath);
|
|
94
|
+
}
|
|
95
|
+
if (body !== undefined) {
|
|
96
|
+
const check = checkMarkdown(body);
|
|
97
|
+
if (!check.ok) {
|
|
98
|
+
throw new Error(check.error);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Drop bodyPath from persisted decisions.json (recipe §1.8).
|
|
102
|
+
const { bodyPath: _drop, ...rest } = interaction;
|
|
103
|
+
return body !== undefined ? { ...rest, body } : { ...rest };
|
|
104
|
+
});
|
|
105
|
+
return { ...parsed, interactions: inlinedInteractions };
|
|
106
|
+
}
|
|
107
|
+
export function validateDeck(parsed) {
|
|
108
|
+
return deckSchema.parse(parsed);
|
|
109
|
+
}
|