@crouton-kit/humanloop 0.3.30 → 0.3.31
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.js +6 -0
- package/dist/browser/open.d.ts +7 -0
- package/dist/browser/open.js +26 -0
- package/dist/browser/server.d.ts +62 -0
- package/dist/browser/server.js +440 -0
- package/dist/cli.js +43 -12
- package/dist/editor/feedback.d.ts +18 -0
- package/dist/editor/feedback.js +204 -0
- package/dist/editor/review.js +162 -93
- package/dist/inbox/deck-schema.d.ts +14 -1
- package/dist/inbox/deck-schema.js +40 -18
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/tui/app.js +122 -3
- package/dist/tui/input.js +4 -1
- package/dist/tui/render.d.ts +7 -0
- package/dist/tui/render.js +25 -0
- package/dist/types.d.ts +2 -1
- package/dist/types.js +5 -1
- package/dist/web/assets/index-BxJZgs8l.css +2 -0
- package/dist/web/assets/index-DhbBiRqS.js +46 -0
- package/dist/web/index.html +13 -0
- package/package.json +8 -3
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { FeedbackComment, FeedbackResult } from '../types.js';
|
|
2
|
+
export declare function sanitizeFeedbackComments(raw: unknown): FeedbackComment[];
|
|
3
|
+
export declare function parseFeedbackComments(raw: unknown): {
|
|
4
|
+
ok: true;
|
|
5
|
+
comments: FeedbackComment[];
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
message: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function buildDraftFeedbackResult(file: string, comments: FeedbackComment[], savedAt?: string): FeedbackResult;
|
|
11
|
+
export declare function buildFinalFeedbackResult(file: string, comments: FeedbackComment[], timestamp?: string): FeedbackResult;
|
|
12
|
+
export declare function readStoredFeedbackResult(path: string, expectedFile?: string): FeedbackResult | null;
|
|
13
|
+
export declare function readStoredDraftFeedbackResult(path: string, expectedFile: string): FeedbackResult | null;
|
|
14
|
+
export declare function serializeFeedbackResult(result: FeedbackResult): string;
|
|
15
|
+
export declare function writeFeedbackResult(path: string, result: FeedbackResult): string;
|
|
16
|
+
export declare function writeDraftFeedbackResult(path: string, file: string, comments: FeedbackComment[], savedAt?: string): FeedbackResult;
|
|
17
|
+
export declare function writeFinalFeedbackResult(path: string, file: string, comments: FeedbackComment[], timestamp?: string): FeedbackResult;
|
|
18
|
+
export declare function writeSubmitFlag(path: string): void;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
function isRecord(value) {
|
|
4
|
+
return typeof value === 'object' && value !== null;
|
|
5
|
+
}
|
|
6
|
+
function nowIso() {
|
|
7
|
+
return new Date().toISOString();
|
|
8
|
+
}
|
|
9
|
+
// Column validity is only meaningful WITHIN a single line: `colEnd > colStart`
|
|
10
|
+
// compares two byte offsets into the SAME line. For a multi-line range,
|
|
11
|
+
// `colStart` is relative to the START line and `colEnd` is relative to the
|
|
12
|
+
// (different) END line, so a numeric `colEnd > colStart` comparison is
|
|
13
|
+
// meaningless and can reject a perfectly valid range (e.g. a short last line
|
|
14
|
+
// legitimately has a smaller colEnd than the start line's colStart). Mirrors
|
|
15
|
+
// web/src/lib/sourceMap.ts's `hasValidRangeColumns` — src/ can't import from
|
|
16
|
+
// web/, so this is the local equivalent.
|
|
17
|
+
function hasValidRangeColumns(line, endLine, colStart, colEnd) {
|
|
18
|
+
if (colStart === undefined || colEnd === undefined)
|
|
19
|
+
return false;
|
|
20
|
+
if (line === endLine)
|
|
21
|
+
return colEnd > colStart;
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
export function sanitizeFeedbackComments(raw) {
|
|
25
|
+
if (!Array.isArray(raw))
|
|
26
|
+
return [];
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const r of raw) {
|
|
29
|
+
if (!isRecord(r))
|
|
30
|
+
continue;
|
|
31
|
+
const comment = typeof r.comment === 'string' ? r.comment.trim() : '';
|
|
32
|
+
if (!comment)
|
|
33
|
+
continue;
|
|
34
|
+
const line = Number(r.line) || 1;
|
|
35
|
+
const endLine = Number(r.endLine) || line;
|
|
36
|
+
const colStart = Number.isInteger(r.colStart) ? r.colStart : undefined;
|
|
37
|
+
const colEnd = Number.isInteger(r.colEnd) ? r.colEnd : undefined;
|
|
38
|
+
const validCols = hasValidRangeColumns(line, endLine, colStart, colEnd);
|
|
39
|
+
out.push({
|
|
40
|
+
id: typeof r.id === 'string' && r.id ? r.id : `c${out.length}`,
|
|
41
|
+
line,
|
|
42
|
+
endLine,
|
|
43
|
+
colStart: validCols ? colStart : undefined,
|
|
44
|
+
colEnd: validCols ? colEnd : undefined,
|
|
45
|
+
quote: typeof r.quote === 'string' && r.quote ? r.quote : undefined,
|
|
46
|
+
lineText: typeof r.lineText === 'string' ? r.lineText : '',
|
|
47
|
+
comment,
|
|
48
|
+
createdAt: typeof r.createdAt === 'string' ? r.createdAt : nowIso(),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
function isNonEmptyString(v) {
|
|
54
|
+
return typeof v === 'string' && v.trim().length > 0;
|
|
55
|
+
}
|
|
56
|
+
function isPositiveInteger(v) {
|
|
57
|
+
return typeof v === 'number' && Number.isInteger(v) && v > 0;
|
|
58
|
+
}
|
|
59
|
+
function isNonNegativeInteger(v) {
|
|
60
|
+
return typeof v === 'number' && Number.isInteger(v) && v >= 0;
|
|
61
|
+
}
|
|
62
|
+
// Strict, per-item validator for the browser-write API (PUT /api/review/draft,
|
|
63
|
+
// POST /api/review/submit) — malformed anchors are rejected with a 400
|
|
64
|
+
// instead of being silently normalized/dropped. `sanitizeFeedbackComments`
|
|
65
|
+
// stays the separate, deliberately permissive path for loading legacy on-disk
|
|
66
|
+
// drafts (readStoredFeedbackResult/readStoredDraftFeedbackResult), which must
|
|
67
|
+
// keep tolerating old/hand-edited files.
|
|
68
|
+
export function parseFeedbackComments(raw) {
|
|
69
|
+
if (!Array.isArray(raw)) {
|
|
70
|
+
return { ok: false, message: 'comments must be an array.' };
|
|
71
|
+
}
|
|
72
|
+
const comments = [];
|
|
73
|
+
for (let i = 0; i < raw.length; i++) {
|
|
74
|
+
const item = raw[i];
|
|
75
|
+
if (!isRecord(item))
|
|
76
|
+
return { ok: false, message: `comments[${i}] must be an object.` };
|
|
77
|
+
const { id, comment, line, endLine, lineText, createdAt, colStart, colEnd, quote } = item;
|
|
78
|
+
if (!isNonEmptyString(id))
|
|
79
|
+
return { ok: false, message: `comments[${i}].id must be a non-empty string.` };
|
|
80
|
+
if (!isNonEmptyString(comment))
|
|
81
|
+
return { ok: false, message: `comments[${i}].comment must be a non-empty string.` };
|
|
82
|
+
if (!isPositiveInteger(line))
|
|
83
|
+
return { ok: false, message: `comments[${i}].line must be a positive integer.` };
|
|
84
|
+
if (!isPositiveInteger(endLine))
|
|
85
|
+
return { ok: false, message: `comments[${i}].endLine must be a positive integer.` };
|
|
86
|
+
if (endLine < line)
|
|
87
|
+
return { ok: false, message: `comments[${i}].endLine must be >= line.` };
|
|
88
|
+
if (typeof lineText !== 'string')
|
|
89
|
+
return { ok: false, message: `comments[${i}].lineText must be a string.` };
|
|
90
|
+
if (!isNonEmptyString(createdAt))
|
|
91
|
+
return { ok: false, message: `comments[${i}].createdAt must be a non-empty string.` };
|
|
92
|
+
const hasColStart = colStart !== undefined;
|
|
93
|
+
const hasColEnd = colEnd !== undefined;
|
|
94
|
+
if (hasColStart !== hasColEnd)
|
|
95
|
+
return { ok: false, message: `comments[${i}].colStart/colEnd must be provided together.` };
|
|
96
|
+
let outColStart;
|
|
97
|
+
let outColEnd;
|
|
98
|
+
if (hasColStart && hasColEnd) {
|
|
99
|
+
if (!isNonNegativeInteger(colStart) || !isNonNegativeInteger(colEnd)) {
|
|
100
|
+
return { ok: false, message: `comments[${i}].colStart/colEnd must be non-negative integers.` };
|
|
101
|
+
}
|
|
102
|
+
if (line === endLine && colEnd <= colStart) {
|
|
103
|
+
return { ok: false, message: `comments[${i}].colEnd must be greater than colStart on a single-line range.` };
|
|
104
|
+
}
|
|
105
|
+
outColStart = colStart;
|
|
106
|
+
outColEnd = colEnd;
|
|
107
|
+
}
|
|
108
|
+
if (quote !== undefined && typeof quote !== 'string') {
|
|
109
|
+
return { ok: false, message: `comments[${i}].quote must be a string when present.` };
|
|
110
|
+
}
|
|
111
|
+
comments.push({
|
|
112
|
+
id, line, endLine,
|
|
113
|
+
colStart: outColStart, colEnd: outColEnd,
|
|
114
|
+
quote: typeof quote === 'string' && quote.length > 0 ? quote : undefined,
|
|
115
|
+
lineText, comment: comment.trim(), createdAt,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return { ok: true, comments };
|
|
119
|
+
}
|
|
120
|
+
export function buildDraftFeedbackResult(file, comments, savedAt = nowIso()) {
|
|
121
|
+
return {
|
|
122
|
+
file: resolve(file),
|
|
123
|
+
submitted: false,
|
|
124
|
+
approved: false,
|
|
125
|
+
comments: sanitizeFeedbackComments(comments),
|
|
126
|
+
savedAt,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
export function buildFinalFeedbackResult(file, comments, timestamp = nowIso()) {
|
|
130
|
+
const sanitized = sanitizeFeedbackComments(comments);
|
|
131
|
+
return {
|
|
132
|
+
file: resolve(file),
|
|
133
|
+
submitted: true,
|
|
134
|
+
approved: sanitized.length === 0,
|
|
135
|
+
comments: sanitized,
|
|
136
|
+
submittedAt: timestamp,
|
|
137
|
+
savedAt: timestamp,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
export function readStoredFeedbackResult(path, expectedFile) {
|
|
141
|
+
if (!existsSync(path))
|
|
142
|
+
return null;
|
|
143
|
+
let raw;
|
|
144
|
+
try {
|
|
145
|
+
raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
if (!isRecord(raw) || typeof raw.file !== 'string')
|
|
151
|
+
return null;
|
|
152
|
+
if (expectedFile !== undefined && raw.file !== expectedFile)
|
|
153
|
+
return null;
|
|
154
|
+
const comments = sanitizeFeedbackComments(raw.comments);
|
|
155
|
+
if (raw.submitted === true) {
|
|
156
|
+
const submittedAt = typeof raw.submittedAt === 'string' ? raw.submittedAt : nowIso();
|
|
157
|
+
const savedAt = typeof raw.savedAt === 'string' ? raw.savedAt : submittedAt;
|
|
158
|
+
return {
|
|
159
|
+
file: raw.file,
|
|
160
|
+
submitted: true,
|
|
161
|
+
approved: comments.length === 0,
|
|
162
|
+
comments,
|
|
163
|
+
submittedAt,
|
|
164
|
+
savedAt,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const savedAt = typeof raw.savedAt === 'string' ? raw.savedAt : nowIso();
|
|
168
|
+
return {
|
|
169
|
+
file: raw.file,
|
|
170
|
+
submitted: false,
|
|
171
|
+
approved: false,
|
|
172
|
+
comments,
|
|
173
|
+
savedAt,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
export function readStoredDraftFeedbackResult(path, expectedFile) {
|
|
177
|
+
const result = readStoredFeedbackResult(path, resolve(expectedFile));
|
|
178
|
+
return result !== null && !result.submitted ? result : null;
|
|
179
|
+
}
|
|
180
|
+
export function serializeFeedbackResult(result) {
|
|
181
|
+
return JSON.stringify(result, null, 2) + '\n';
|
|
182
|
+
}
|
|
183
|
+
export function writeFeedbackResult(path, result) {
|
|
184
|
+
const payload = serializeFeedbackResult(result);
|
|
185
|
+
const tmp = `${path}.tmp`;
|
|
186
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
187
|
+
writeFileSync(tmp, payload);
|
|
188
|
+
renameSync(tmp, path);
|
|
189
|
+
return path;
|
|
190
|
+
}
|
|
191
|
+
export function writeDraftFeedbackResult(path, file, comments, savedAt = nowIso()) {
|
|
192
|
+
const result = buildDraftFeedbackResult(file, comments, savedAt);
|
|
193
|
+
writeFeedbackResult(path, result);
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
export function writeFinalFeedbackResult(path, file, comments, timestamp = nowIso()) {
|
|
197
|
+
const result = buildFinalFeedbackResult(file, comments, timestamp);
|
|
198
|
+
writeFeedbackResult(path, result);
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
export function writeSubmitFlag(path) {
|
|
202
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
203
|
+
writeFileSync(path, '');
|
|
204
|
+
}
|
package/dist/editor/review.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { spawn, spawnSync, execFileSync } from 'child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
|
|
3
3
|
import { tmpdir } from 'os';
|
|
4
4
|
import { resolve, join } from 'path';
|
|
5
|
+
import { openBrowser } from '../browser/open.js';
|
|
6
|
+
import { startReviewWebServer } from '../browser/server.js';
|
|
7
|
+
import { readStoredFeedbackResult, writeDraftFeedbackResult, writeFinalFeedbackResult, } from './feedback.js';
|
|
5
8
|
function shellQuote(s) {
|
|
6
9
|
if (s.length > 0 && /^[a-zA-Z0-9_\-./:@%+=]+$/.test(s))
|
|
7
10
|
return s;
|
|
@@ -35,6 +38,8 @@ export function reviewVimscript() {
|
|
|
35
38
|
`" The rest is the read-only guard, comment commands, and autosave.`,
|
|
36
39
|
`let g:hl_out = $HL_OUTPUT`,
|
|
37
40
|
`let g:hl_src = $HL_SOURCE`,
|
|
41
|
+
`let g:hl_review_url = $HL_REVIEW_URL`,
|
|
42
|
+
`let g:hl_handoff_flag = $HL_HANDOFF_FLAG`,
|
|
38
43
|
`let s:comments = []`,
|
|
39
44
|
`let s:idseq = 0`,
|
|
40
45
|
`let s:ns = exists('*nvim_create_namespace') ? nvim_create_namespace('hl_review') : -1`,
|
|
@@ -306,11 +311,22 @@ export function reviewVimscript() {
|
|
|
306
311
|
` qa!`,
|
|
307
312
|
`endfunction`,
|
|
308
313
|
``,
|
|
314
|
+
`function! s:HandOff() abort`,
|
|
315
|
+
` call s:Save()`,
|
|
316
|
+
` if g:hl_handoff_flag ==# ''`,
|
|
317
|
+
` echohl ErrorMsg | echo 'Browser handoff unavailable' | echohl NONE`,
|
|
318
|
+
` return`,
|
|
319
|
+
` endif`,
|
|
320
|
+
` call writefile([''], g:hl_handoff_flag)`,
|
|
321
|
+
` qa!`,
|
|
322
|
+
`endfunction`,
|
|
323
|
+
``,
|
|
309
324
|
`command! HLComment call <SID>Comment('n')`,
|
|
310
325
|
`command! HLList call <SID>List()`,
|
|
311
326
|
`command! HLUndo call <SID>Undo()`,
|
|
312
327
|
`command! HLSubmit call <SID>Submit()`,
|
|
313
|
-
`command!
|
|
328
|
+
`command! HLBrowser call <SID>HandOff()`,
|
|
329
|
+
`command! HLHelp echo 'REVIEW <Space>c comment <Space>l list <Space>u undo-last <Space>s submit & quit <Space>w browser handoff | in list: e/<CR> edit · dd delete · q close'`,
|
|
314
330
|
``,
|
|
315
331
|
`" Highlights are (re)applied after any colorscheme so the user's theme`,
|
|
316
332
|
`" (e.g. gloam) loads first, then our anchor highlight sits on top.`,
|
|
@@ -397,6 +413,7 @@ export function reviewVimscript() {
|
|
|
397
413
|
` nnoremap <buffer> <silent> <Space>l :call <SID>List()<CR>`,
|
|
398
414
|
` nnoremap <buffer> <silent> <Space>u :call <SID>Undo()<CR>`,
|
|
399
415
|
` nnoremap <buffer> <silent> <Space>s :call <SID>Submit()<CR>`,
|
|
416
|
+
` nnoremap <buffer> <silent> <Space>w :call <SID>HandOff()<CR>`,
|
|
400
417
|
` call s:Hi()`,
|
|
401
418
|
` call s:Load()`,
|
|
402
419
|
` call s:Marks()`,
|
|
@@ -404,7 +421,7 @@ export function reviewVimscript() {
|
|
|
404
421
|
` " Keep this hint short: a line that wraps past the cmdline triggers the`,
|
|
405
422
|
` " hit-enter prompt, whose mode blocks the deferred render-markdown paint.`,
|
|
406
423
|
` " The full key list lives in :HLHelp (and is printed to stderr on launch).`,
|
|
407
|
-
` echohl Question | echo 'hl review —
|
|
424
|
+
` echohl Question | echo 'hl review — c comment · l list · u undo · s submit · w browser (:HLHelp)' | echohl NONE`,
|
|
408
425
|
`endfunction`,
|
|
409
426
|
`autocmd VimEnter * call s:Setup()`,
|
|
410
427
|
`autocmd VimLeavePre * call s:Save()`,
|
|
@@ -426,40 +443,6 @@ export function reviewVimscript() {
|
|
|
426
443
|
``,
|
|
427
444
|
].join('\n');
|
|
428
445
|
}
|
|
429
|
-
function atomicWrite(path, data) {
|
|
430
|
-
const tmp = `${path}.tmp`;
|
|
431
|
-
writeFileSync(tmp, data);
|
|
432
|
-
renameSync(tmp, path);
|
|
433
|
-
}
|
|
434
|
-
function sanitizeComments(raw) {
|
|
435
|
-
if (!Array.isArray(raw))
|
|
436
|
-
return [];
|
|
437
|
-
const out = [];
|
|
438
|
-
for (const r of raw) {
|
|
439
|
-
if (typeof r !== 'object' || r === null)
|
|
440
|
-
continue;
|
|
441
|
-
const c = r;
|
|
442
|
-
const comment = typeof c.comment === 'string' ? c.comment.trim() : '';
|
|
443
|
-
if (!comment)
|
|
444
|
-
continue;
|
|
445
|
-
const line = Number(c.line) || 1;
|
|
446
|
-
const endLine = Number(c.endLine) || line;
|
|
447
|
-
const colStart = Number.isInteger(c.colStart) ? c.colStart : undefined;
|
|
448
|
-
const colEnd = Number.isInteger(c.colEnd) ? c.colEnd : undefined;
|
|
449
|
-
out.push({
|
|
450
|
-
id: typeof c.id === 'string' && c.id ? c.id : `c${out.length}`,
|
|
451
|
-
line,
|
|
452
|
-
endLine,
|
|
453
|
-
colStart: colStart !== undefined && colEnd !== undefined && colEnd > colStart ? colStart : undefined,
|
|
454
|
-
colEnd: colStart !== undefined && colEnd !== undefined && colEnd > colStart ? colEnd : undefined,
|
|
455
|
-
quote: typeof c.quote === 'string' && c.quote ? c.quote : undefined,
|
|
456
|
-
lineText: typeof c.lineText === 'string' ? c.lineText : '',
|
|
457
|
-
comment,
|
|
458
|
-
createdAt: typeof c.createdAt === 'string' ? c.createdAt : new Date().toISOString(),
|
|
459
|
-
});
|
|
460
|
-
}
|
|
461
|
-
return out;
|
|
462
|
-
}
|
|
463
446
|
const FEEDBACK_SCHEMA = '{file, submitted, approved, comments:[{id, line, endLine, quote?, colStart?, colEnd?, lineText, comment, createdAt}], submittedAt, savedAt}';
|
|
464
447
|
function rangeLabel(c) {
|
|
465
448
|
const cols = Number.isInteger(c.colStart) && Number.isInteger(c.colEnd);
|
|
@@ -559,6 +542,61 @@ function runInCurrentTerminal(bin, args, env) {
|
|
|
559
542
|
child.on('exit', () => resolvePromise());
|
|
560
543
|
});
|
|
561
544
|
}
|
|
545
|
+
function clearFlag(path) {
|
|
546
|
+
rmSync(path, { force: true });
|
|
547
|
+
}
|
|
548
|
+
function readDraftComments(path, absFile) {
|
|
549
|
+
const stored = readStoredFeedbackResult(path, absFile);
|
|
550
|
+
return stored?.comments ?? [];
|
|
551
|
+
}
|
|
552
|
+
function finalizeReviewOutput(outPath, absFile, didSubmit) {
|
|
553
|
+
const comments = readDraftComments(outPath, absFile);
|
|
554
|
+
return didSubmit
|
|
555
|
+
? writeFinalFeedbackResult(outPath, absFile, comments)
|
|
556
|
+
: writeDraftFeedbackResult(outPath, absFile, comments);
|
|
557
|
+
}
|
|
558
|
+
async function waitForParkedReviewSubmit(submitted) {
|
|
559
|
+
process.stderr.write('\nhumanloop: browser review handoff is active.\n' +
|
|
560
|
+
' The terminal editor is parked; the browser is the editing authority.\n' +
|
|
561
|
+
' Press w to take back into nvim, or Ctrl+C to exit with an unsubmitted draft.\n\n');
|
|
562
|
+
if (!process.stdin.isTTY) {
|
|
563
|
+
const result = await submitted;
|
|
564
|
+
return { type: 'submitted', result };
|
|
565
|
+
}
|
|
566
|
+
return new Promise((resolveAction) => {
|
|
567
|
+
const stdin = process.stdin;
|
|
568
|
+
const wasRaw = stdin.isRaw;
|
|
569
|
+
let settled = false;
|
|
570
|
+
const cleanup = () => {
|
|
571
|
+
stdin.off('data', onData);
|
|
572
|
+
if (stdin.setRawMode)
|
|
573
|
+
stdin.setRawMode(wasRaw);
|
|
574
|
+
stdin.pause();
|
|
575
|
+
};
|
|
576
|
+
const finish = (action) => {
|
|
577
|
+
if (settled)
|
|
578
|
+
return;
|
|
579
|
+
settled = true;
|
|
580
|
+
cleanup();
|
|
581
|
+
resolveAction(action);
|
|
582
|
+
};
|
|
583
|
+
const onData = (chunk) => {
|
|
584
|
+
const text = chunk.toString('utf8');
|
|
585
|
+
if (text === 'w' || text === 'W')
|
|
586
|
+
finish({ type: 'take-back' });
|
|
587
|
+
if (text === '\u0003')
|
|
588
|
+
finish({ type: 'cancel' });
|
|
589
|
+
};
|
|
590
|
+
stdin.setRawMode(true);
|
|
591
|
+
stdin.resume();
|
|
592
|
+
stdin.on('data', onData);
|
|
593
|
+
submitted.then((result) => finish({ type: 'submitted', result }), () => finish({ type: 'cancel' }));
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
async function stopReviewServer(handle) {
|
|
597
|
+
if (handle !== null)
|
|
598
|
+
await handle.stop();
|
|
599
|
+
}
|
|
562
600
|
/**
|
|
563
601
|
* Open a markdown file in a clean, read-only Neovim/Vim review session. The
|
|
564
602
|
* human anchors comments to source lines/selections with native vim motions
|
|
@@ -579,76 +617,107 @@ export async function launchReview(file, opts) {
|
|
|
579
617
|
const initPath = join(dir, 'review.vim');
|
|
580
618
|
if (!existsSync(initPath))
|
|
581
619
|
writeFileSync(initPath, reviewVimscript());
|
|
582
|
-
const
|
|
583
|
-
...process.env,
|
|
584
|
-
HL_OUTPUT: outPath,
|
|
585
|
-
HL_SOURCE: absFile,
|
|
586
|
-
...(opts.submitFlagPath ? { HL_SUBMIT_FLAG: opts.submitFlagPath } : {}),
|
|
587
|
-
};
|
|
620
|
+
const handoffFlagPath = join(dir, 'browser-handoff.flag');
|
|
588
621
|
// `-u NONE`: do NOT load the user's init.lua / LazyVim / plugins / keymaps.
|
|
589
622
|
// Default runtimepath still includes the config dir (for the gloam
|
|
590
623
|
// colorscheme) and the site dir (for the treesitter markdown parser), so the
|
|
591
624
|
// review layer pulls in ONLY the colorscheme + treesitter styling itself.
|
|
592
625
|
const editorArgs = ['-u', 'NONE', '-n', '-i', 'NONE', absFile, '-c', `source ${initPath}`];
|
|
593
626
|
const inTmux = !!process.env.TMUX && !opts.noTmux;
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
627
|
+
async function runEditor(reviewUrl) {
|
|
628
|
+
const env = {
|
|
629
|
+
...process.env,
|
|
630
|
+
HL_OUTPUT: outPath,
|
|
631
|
+
HL_SOURCE: absFile,
|
|
632
|
+
HL_REVIEW_URL: reviewUrl,
|
|
633
|
+
HL_HANDOFF_FLAG: handoffFlagPath,
|
|
634
|
+
...(opts.submitFlagPath ? { HL_SUBMIT_FLAG: opts.submitFlagPath } : {}),
|
|
635
|
+
};
|
|
636
|
+
process.stderr.write(`\nhumanloop: opening "${absFile}" for review in ${bin}` +
|
|
637
|
+
(inTmux ? ' (tmux pane).\n' : '.\n') +
|
|
638
|
+
` Answers : ${outPath}\n` +
|
|
639
|
+
` Browser : available after <Space>w hands off (or :HLBrowser)\n` +
|
|
640
|
+
` Keys : <Space>c comment · <Space>l list · <Space>u undo · <Space>s submit & quit · <Space>w browser (or :HLComment/:HLSubmit/:HLBrowser)\n` +
|
|
641
|
+
` Status : BLOCKING — waiting for you to finish the review.\n\n`);
|
|
642
|
+
if (inTmux) {
|
|
643
|
+
// `exec env …` so the editor replaces the shell and becomes the pane's
|
|
644
|
+
// process (so tmux `#{pane_current_command}` is `nvim`, not the shell).
|
|
645
|
+
const envPairs = [
|
|
646
|
+
`HL_OUTPUT=${shellQuote(outPath)}`,
|
|
647
|
+
`HL_SOURCE=${shellQuote(absFile)}`,
|
|
648
|
+
`HL_REVIEW_URL=${shellQuote(reviewUrl)}`,
|
|
649
|
+
`HL_HANDOFF_FLAG=${shellQuote(handoffFlagPath)}`,
|
|
650
|
+
...(opts.submitFlagPath ? [`HL_SUBMIT_FLAG=${shellQuote(opts.submitFlagPath)}`] : []),
|
|
651
|
+
];
|
|
652
|
+
const paneCmd = [
|
|
653
|
+
'exec',
|
|
654
|
+
'env',
|
|
655
|
+
...envPairs,
|
|
656
|
+
shellQuote(bin),
|
|
657
|
+
...editorArgs.map(shellQuote),
|
|
658
|
+
].join(' ');
|
|
659
|
+
try {
|
|
660
|
+
if (opts.tmuxPopup) {
|
|
661
|
+
const w = opts.popupSize ? opts.popupSize.w : '90%';
|
|
662
|
+
const h = opts.popupSize ? opts.popupSize.h : '90%';
|
|
663
|
+
execFileSync('tmux', ['display-popup', '-E', '-w', w, '-h', h, paneCmd], { stdio: 'inherit' });
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
await runInTmuxPane(paneCmd);
|
|
667
|
+
}
|
|
619
668
|
}
|
|
620
|
-
|
|
621
|
-
|
|
669
|
+
catch (err) {
|
|
670
|
+
process.stderr.write(`tmux dispatch failed, running in current terminal: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
671
|
+
await runInCurrentTerminal(bin, editorArgs, env);
|
|
622
672
|
}
|
|
623
673
|
}
|
|
624
|
-
|
|
625
|
-
process.stderr.write(`tmux dispatch failed, running in current terminal: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
674
|
+
else {
|
|
626
675
|
await runInCurrentTerminal(bin, editorArgs, env);
|
|
627
676
|
}
|
|
628
677
|
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
678
|
+
while (true) {
|
|
679
|
+
clearFlag(handoffFlagPath);
|
|
680
|
+
let resolveSubmitted;
|
|
681
|
+
const submitted = new Promise((resolveSubmittedPromise) => {
|
|
682
|
+
resolveSubmitted = resolveSubmittedPromise;
|
|
683
|
+
});
|
|
684
|
+
const server = await startReviewWebServer({
|
|
685
|
+
jobDir: dir,
|
|
686
|
+
file: absFile,
|
|
687
|
+
output: outPath,
|
|
688
|
+
submitFlagPath: opts.submitFlagPath,
|
|
689
|
+
onSubmit: (result) => resolveSubmitted(result),
|
|
690
|
+
});
|
|
634
691
|
try {
|
|
635
|
-
|
|
636
|
-
|
|
692
|
+
await runEditor(server.url);
|
|
693
|
+
if (existsSync(handoffFlagPath)) {
|
|
694
|
+
server.activate();
|
|
695
|
+
process.stderr.write(`humanloop: browser review handoff active — ${server.url}\n`);
|
|
696
|
+
openBrowser(server.url);
|
|
697
|
+
const action = await waitForParkedReviewSubmit(submitted);
|
|
698
|
+
if (action.type === 'submitted') {
|
|
699
|
+
await stopReviewServer(server);
|
|
700
|
+
clearFlag(handoffFlagPath);
|
|
701
|
+
return action.result;
|
|
702
|
+
}
|
|
703
|
+
if (action.type === 'take-back') {
|
|
704
|
+
await server.requestTakeBack();
|
|
705
|
+
await stopReviewServer(server);
|
|
706
|
+
clearFlag(handoffFlagPath);
|
|
707
|
+
process.stderr.write('humanloop: taking review back into the terminal editor.\n');
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
await stopReviewServer(server);
|
|
711
|
+
clearFlag(handoffFlagPath);
|
|
712
|
+
return finalizeReviewOutput(outPath, absFile, false);
|
|
713
|
+
}
|
|
714
|
+
await stopReviewServer(server);
|
|
715
|
+
const didSubmit = opts.submitFlagPath ? existsSync(opts.submitFlagPath) : true;
|
|
716
|
+
return finalizeReviewOutput(outPath, absFile, didSubmit);
|
|
637
717
|
}
|
|
638
|
-
catch {
|
|
639
|
-
|
|
718
|
+
catch (err) {
|
|
719
|
+
await stopReviewServer(server);
|
|
720
|
+
throw err;
|
|
640
721
|
}
|
|
641
722
|
}
|
|
642
|
-
const now = new Date().toISOString();
|
|
643
|
-
const didSubmit = opts.submitFlagPath ? existsSync(opts.submitFlagPath) : true;
|
|
644
|
-
const result = {
|
|
645
|
-
file: absFile,
|
|
646
|
-
submitted: didSubmit,
|
|
647
|
-
approved: didSubmit && comments.length === 0,
|
|
648
|
-
comments,
|
|
649
|
-
...(didSubmit ? { submittedAt: now } : {}),
|
|
650
|
-
savedAt: now,
|
|
651
|
-
};
|
|
652
|
-
atomicWrite(outPath, JSON.stringify(result, null, 2) + '\n');
|
|
653
|
-
return result;
|
|
654
723
|
}
|
|
@@ -38,6 +38,7 @@ export declare const deckSchema: z.ZodObject<{
|
|
|
38
38
|
decision: "decision";
|
|
39
39
|
context: "context";
|
|
40
40
|
error: "error";
|
|
41
|
+
review: "review";
|
|
41
42
|
}>>;
|
|
42
43
|
preAnswered: z.ZodOptional<z.ZodObject<{
|
|
43
44
|
selectedOptionId: z.ZodOptional<z.ZodString>;
|
|
@@ -47,6 +48,18 @@ export declare const deckSchema: z.ZodObject<{
|
|
|
47
48
|
}, z.core.$strip>>;
|
|
48
49
|
}, z.core.$strip>>;
|
|
49
50
|
}, z.core.$strip>;
|
|
50
|
-
|
|
51
|
+
/**
|
|
52
|
+
* The ONE canonical `bodyPath` → `body` normalization boundary. Resolves every
|
|
53
|
+
* interaction's `bodyPath` (relative to `dir`, the interaction directory) into
|
|
54
|
+
* `body` and strips `bodyPath` from the result.
|
|
55
|
+
*
|
|
56
|
+
* Call this once, right before a deck is (re)written to `<dir>/deck.json` —
|
|
57
|
+
* `hl deck ask`, `hl deck update`, and the public `ask()` API all do — so
|
|
58
|
+
* every reader downstream (the terminal TUI's render + its live-reload
|
|
59
|
+
* poller, the browser server's `/api/interaction`) only ever sees a plain
|
|
60
|
+
* `body` and never has to special-case `bodyPath` itself. `parseDeck` (below)
|
|
61
|
+
* reuses this same function when reading a deck straight off disk.
|
|
62
|
+
*/
|
|
63
|
+
export declare function resolveDeckBodyPaths(deck: Deck, dir: string): Deck;
|
|
51
64
|
export declare function parseDeck(deckPath: string): Deck;
|
|
52
65
|
export declare function validateDeck(parsed: unknown): Deck;
|