@crouton-kit/humanloop 0.3.33 → 0.3.34

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.
Files changed (44) hide show
  1. package/dist/api.d.ts +4 -8
  2. package/dist/api.js +6 -29
  3. package/dist/browser/server.d.ts +3 -3
  4. package/dist/browser/server.js +22 -25
  5. package/dist/cli.js +126 -1114
  6. package/dist/editor/feedback.d.ts +9 -0
  7. package/dist/editor/feedback.js +23 -0
  8. package/dist/editor/review.d.ts +8 -30
  9. package/dist/editor/review.js +79 -118
  10. package/dist/inbox/claim.d.ts +23 -0
  11. package/dist/inbox/claim.js +67 -0
  12. package/dist/inbox/completion.d.ts +4 -0
  13. package/dist/inbox/completion.js +100 -0
  14. package/dist/inbox/controller.d.ts +57 -0
  15. package/dist/inbox/controller.js +311 -0
  16. package/dist/inbox/convention.d.ts +15 -10
  17. package/dist/inbox/convention.js +149 -79
  18. package/dist/inbox/deck-adapter.d.ts +27 -0
  19. package/dist/inbox/deck-adapter.js +57 -0
  20. package/dist/inbox/deck-schema.d.ts +18 -14
  21. package/dist/inbox/deck-schema.js +59 -117
  22. package/dist/inbox/layout.d.ts +8 -0
  23. package/dist/inbox/layout.js +9 -0
  24. package/dist/inbox/registry.d.ts +29 -0
  25. package/dist/inbox/registry.js +97 -0
  26. package/dist/inbox/review-adapter.d.ts +22 -0
  27. package/dist/inbox/review-adapter.js +56 -0
  28. package/dist/inbox/scan.d.ts +3 -2
  29. package/dist/inbox/scan.js +71 -43
  30. package/dist/inbox/tickets.d.ts +63 -0
  31. package/dist/inbox/tickets.js +247 -0
  32. package/dist/inbox/tui.d.ts +3 -6
  33. package/dist/inbox/tui.js +24 -112
  34. package/dist/index.d.ts +12 -3
  35. package/dist/index.js +8 -2
  36. package/dist/summary.d.ts +2 -3
  37. package/dist/summary.js +2 -3
  38. package/dist/surfaces/inbox-popup.d.ts +2 -0
  39. package/dist/surfaces/inbox-popup.js +32 -0
  40. package/dist/tui/app.js +13 -0
  41. package/dist/tui/tmux.d.ts +30 -7
  42. package/dist/tui/tmux.js +179 -66
  43. package/dist/types.d.ts +68 -7
  44. package/package.json +2 -2
@@ -15,4 +15,13 @@ export declare function serializeFeedbackResult(result: FeedbackResult): string;
15
15
  export declare function writeFeedbackResult(path: string, result: FeedbackResult): string;
16
16
  export declare function writeDraftFeedbackResult(path: string, file: string, comments: FeedbackComment[], savedAt?: string): FeedbackResult;
17
17
  export declare function writeFinalFeedbackResult(path: string, file: string, comments: FeedbackComment[], timestamp?: string): FeedbackResult;
18
+ export interface ReviewDraft {
19
+ kind: 'review';
20
+ comments: FeedbackComment[];
21
+ savedAt: string;
22
+ version: number;
23
+ }
24
+ /** Durable, resumable review work. Canonical feedback is produced only by the ticket finalizer. */
25
+ export declare function readReviewDraft(path: string): ReviewDraft | null;
26
+ export declare function writeReviewDraft(path: string, comments: FeedbackComment[], version: number, savedAt?: string): ReviewDraft;
18
27
  export declare function writeSubmitFlag(path: string): void;
@@ -198,6 +198,29 @@ export function writeFinalFeedbackResult(path, file, comments, timestamp = nowIs
198
198
  writeFeedbackResult(path, result);
199
199
  return result;
200
200
  }
201
+ /** Durable, resumable review work. Canonical feedback is produced only by the ticket finalizer. */
202
+ export function readReviewDraft(path) {
203
+ if (!existsSync(path))
204
+ return null;
205
+ let raw;
206
+ try {
207
+ raw = JSON.parse(readFileSync(path, 'utf8'));
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ if (!isRecord(raw) || raw.kind !== 'review' || !Number.isInteger(raw.version) || raw.version < 0 || typeof raw.savedAt !== 'string')
213
+ return null;
214
+ return { kind: 'review', comments: sanitizeFeedbackComments(raw.comments), savedAt: raw.savedAt, version: raw.version };
215
+ }
216
+ export function writeReviewDraft(path, comments, version, savedAt = nowIso()) {
217
+ const draft = { kind: 'review', comments: sanitizeFeedbackComments(comments), savedAt, version };
218
+ const tmp = `${path}.tmp`;
219
+ mkdirSync(dirname(path), { recursive: true });
220
+ writeFileSync(tmp, `${JSON.stringify(draft, null, 2)}\n`);
221
+ renameSync(tmp, path);
222
+ return draft;
223
+ }
201
224
  export function writeSubmitFlag(path) {
202
225
  mkdirSync(dirname(path), { recursive: true });
203
226
  writeFileSync(path, '');
@@ -1,35 +1,14 @@
1
1
  import type { FeedbackResult } from '../types.js';
2
2
  export interface ReviewOptions {
3
- /** Where the answers JSON is written (live autosave + finalized on exit). */
3
+ /** Where the resumable review draft is written. */
4
4
  output: string;
5
5
  /** Editor binary override. Default: first of nvim, vim on PATH. */
6
6
  editor?: string;
7
- /** Force running in the current terminal even when $TMUX is set. */
8
- noTmux?: boolean;
9
- /**
10
- * When set, an explicit `<Space>s` / `:HLSubmit` invocation writes a sentinel
11
- * file at this path. Node-side uses the file's existence to gate `submitted: true`.
12
- * When omitted, any editor exit finalizes `submitted: true` (legacy behavior).
13
- */
14
- submitFlagPath?: string;
15
- /**
16
- * When true and $TMUX is set and !opts.noTmux, launch via
17
- * `tmux display-popup -E` instead of `tmux split-window`.
18
- * display-popup -E is synchronous so no poll loop is needed.
19
- */
20
- tmuxPopup?: boolean;
21
- /** Override the default 90%/90% popup size. Only used when tmuxPopup is true. */
22
- popupSize?: {
23
- w: string;
24
- h: string;
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
- */
7
+ /** Called exactly once when the human explicitly submits a final proposal. */
8
+ onPropose?: (result: FeedbackResult) => Promise<void> | void;
9
+ /** Controller cancellation closes the editor/browser handoff without proposing a result. */
10
+ signal?: AbortSignal;
11
+ /** Reuse this controller-owned directory for `review.vim`; an existing script is left untouched. */
33
12
  jobDir?: string;
34
13
  }
35
14
  export declare function reviewVimscript(): string;
@@ -43,8 +22,7 @@ export declare function formatFeedbackSummary(result: FeedbackResult, feedbackJs
43
22
  /**
44
23
  * Open a markdown file in a clean, read-only Neovim/Vim review session. The
45
24
  * human anchors comments to source lines/selections with native vim motions
46
- * and quits to submit. Blocks until the editor exits, then finalizes and
47
- * returns the feedback. Autosaved continuously so a kill is recoverable and
48
- * the next run resumes.
25
+ * and explicitly submits a proposal. Blocks until the editor exits. Autosaved
26
+ * drafts survive exit; canonical ticket finalization belongs to the controller.
49
27
  */
50
28
  export declare function launchReview(file: string, opts: ReviewOptions): Promise<FeedbackResult>;
@@ -1,15 +1,10 @@
1
- import { spawn, spawnSync, execFileSync } from 'child_process';
1
+ import { spawn, spawnSync } from 'child_process';
2
2
  import { existsSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
3
3
  import { tmpdir } from 'os';
4
4
  import { resolve, join } from 'path';
5
5
  import { openBrowser } from '../browser/open.js';
6
6
  import { startReviewWebServer } from '../browser/server.js';
7
- import { readStoredFeedbackResult, writeDraftFeedbackResult, writeFinalFeedbackResult, } from './feedback.js';
8
- function shellQuote(s) {
9
- if (s.length > 0 && /^[a-zA-Z0-9_\-./:@%+=]+$/.test(s))
10
- return s;
11
- return `'${s.replace(/'/g, `'\\''`)}'`;
12
- }
7
+ import { buildFinalFeedbackResult, readReviewDraft, } from './feedback.js';
13
8
  function resolveEditor(override) {
14
9
  const candidates = override ? [override] : ['nvim', 'vim'];
15
10
  for (const bin of candidates) {
@@ -41,6 +36,7 @@ export function reviewVimscript() {
41
36
  `let g:hl_review_url = $HL_REVIEW_URL`,
42
37
  `let g:hl_handoff_flag = $HL_HANDOFF_FLAG`,
43
38
  `let s:comments = []`,
39
+ `let s:version = 0`,
44
40
  `let s:idseq = 0`,
45
41
  `let s:ns = exists('*nvim_create_namespace') ? nvim_create_namespace('hl_review') : -1`,
46
42
  ``,
@@ -50,15 +46,17 @@ export function reviewVimscript() {
50
46
  ` endif`,
51
47
  ` try`,
52
48
  ` let l:obj = json_decode(join(readfile(g:hl_out), "\\n"))`,
53
- ` if type(l:obj) == type({}) && get(l:obj,'file','') ==# g:hl_src && !get(l:obj,'submitted',0)`,
49
+ ` if type(l:obj) == type({}) && get(l:obj,'kind','') ==# 'review'`,
54
50
  ` let s:comments = get(l:obj,'comments',[])`,
51
+ ` let s:version = get(l:obj,'version',0)`,
55
52
  ` endif`,
56
53
  ` catch`,
57
54
  ` endtry`,
58
55
  `endfunction`,
59
56
  ``,
60
57
  `function! s:Save() abort`,
61
- ` let l:obj = {'file': g:hl_src, 'submitted': v:false, 'approved': v:false, 'comments': s:comments, 'savedAt': strftime('%Y-%m-%dT%H:%M:%S')}`,
58
+ ` let s:version += 1`,
59
+ ` let l:obj = {'kind': 'review', 'comments': s:comments, 'savedAt': strftime('%Y-%m-%dT%H:%M:%S'), 'version': s:version}`,
62
60
  ` let l:tmp = g:hl_out . '.tmp'`,
63
61
  ` call writefile([json_encode(l:obj)], l:tmp)`,
64
62
  ` call rename(l:tmp, g:hl_out)`,
@@ -428,7 +426,7 @@ export function reviewVimscript() {
428
426
  ``,
429
427
  `" Watchmode: the source is opened read-only here but may be rewritten on`,
430
428
  `" disk by the agent while the human reviews. autoread + a periodic checktime`,
431
- `" (timers fire even when the tmux pane is unfocused) reload the buffer`,
429
+ `" (timers fire even when the terminal is unfocused) reload the buffer`,
432
430
  `" silently — it is never locally modified, so there is nothing to clobber.`,
433
431
  `" On reload, re-run Setup (read-only guard, treesitter, maps) and redraw the`,
434
432
  `" comment anchors, which the reload cleared. Comments live in s:comments /`,
@@ -496,78 +494,58 @@ export function formatFeedbackSummary(result, feedbackJsonPath) {
496
494
  out.push(` schema: ${FEEDBACK_SCHEMA} · cols are 0-based byte offsets, colEnd exclusive`);
497
495
  return out.join('\n');
498
496
  }
499
- async function runInTmuxPane(paneCmd) {
500
- // `paneCmd` MUST be an `exec`-prefixed command so the editor replaces the
501
- // shell and becomes the pane's process. tmux's `#{pane_current_command}`
502
- // then reports `nvim` — many users gate "let Ctrl-D/Ctrl-U/etc. through to
503
- // the app vs. take over with tmux copy-mode" on exactly that. If the pane
504
- // command were `nvim …; tmux wait-for` the pane process stays the shell,
505
- // pane_current_command is `zsh`/`sh`, and those bindings hijack native vim
506
- // keys. Because the editor is exec'd there is no "after" to signal from, so
507
- // completion is detected purely by the pane disappearing on exit.
508
- const paneId = execFileSync('tmux', ['split-window', '-h', '-d', '-P', '-F', '#{pane_id}', paneCmd], { encoding: 'utf8' }).trim();
509
- // Ensure the pane closes when the editor exits (some configs set
510
- // remain-on-exit globally, which would hang the poll below).
511
- try {
512
- execFileSync('tmux', ['set-option', '-p', '-t', paneId, 'remain-on-exit', 'off'], { stdio: 'ignore' });
513
- }
514
- catch {
515
- // older tmux without -p pane scope — default is already 'off'
516
- }
517
- await new Promise((resolvePromise, rejectPromise) => {
518
- let settled = false;
519
- const finish = (fn) => { if (!settled) {
520
- settled = true;
521
- clearInterval(poll);
522
- fn();
523
- } };
524
- // The editor IS the pane process; when it exits the pane is destroyed.
525
- const poll = setInterval(() => {
526
- try {
527
- const panes = execFileSync('tmux', ['list-panes', '-a', '-F', '#{pane_id}'], { encoding: 'utf8' });
528
- if (!panes.split('\n').map((s) => s.trim()).includes(paneId)) {
529
- finish(resolvePromise);
530
- }
531
- }
532
- catch (e) {
533
- finish(() => rejectPromise(new Error(`tmux list-panes failed: ${e instanceof Error ? e.message : String(e)}`)));
534
- }
535
- }, 200);
536
- });
537
- }
538
- function runInCurrentTerminal(bin, args, env) {
497
+ function runInCurrentTerminal(bin, args, env, signal) {
539
498
  return new Promise((resolvePromise, rejectPromise) => {
540
499
  const child = spawn(bin, args, { stdio: 'inherit', env });
541
- child.on('error', rejectPromise);
542
- child.on('exit', () => resolvePromise());
500
+ let settled = false;
501
+ const cleanup = () => signal?.removeEventListener('abort', abort);
502
+ const finish = (error) => {
503
+ if (settled)
504
+ return;
505
+ settled = true;
506
+ cleanup();
507
+ if (error === undefined)
508
+ resolvePromise();
509
+ else
510
+ rejectPromise(error);
511
+ };
512
+ const abort = () => child.kill('SIGTERM');
513
+ child.once('error', finish);
514
+ child.once('exit', () => finish());
515
+ signal?.addEventListener('abort', abort, { once: true });
516
+ if (signal?.aborted)
517
+ abort();
543
518
  });
544
519
  }
545
520
  function clearFlag(path) {
546
521
  rmSync(path, { force: true });
547
522
  }
548
- function readDraftComments(path, absFile) {
549
- const stored = readStoredFeedbackResult(path, absFile);
550
- return stored?.comments ?? [];
523
+ function proposedFeedback(path, absFile) {
524
+ return buildFinalFeedbackResult(absFile, readReviewDraft(path)?.comments ?? []);
551
525
  }
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);
526
+ function draftFeedback(path, absFile) {
527
+ const draft = readReviewDraft(path);
528
+ return {
529
+ file: absFile,
530
+ submitted: false,
531
+ approved: false,
532
+ comments: draft?.comments ?? [],
533
+ savedAt: draft?.savedAt ?? new Date().toISOString(),
534
+ };
557
535
  }
558
- async function waitForParkedReviewSubmit(submitted) {
536
+ async function waitForParkedReviewSubmit(submitted, signal) {
559
537
  process.stderr.write('\nhumanloop: browser review handoff is active.\n' +
560
538
  ' The terminal editor is parked; the browser is the editing authority.\n' +
561
539
  ' 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
540
  return new Promise((resolveAction) => {
567
541
  const stdin = process.stdin;
542
+ const interactive = stdin.isTTY;
568
543
  const wasRaw = stdin.isRaw;
569
544
  let settled = false;
570
545
  const cleanup = () => {
546
+ signal?.removeEventListener('abort', onAbort);
547
+ if (!interactive)
548
+ return;
571
549
  stdin.off('data', onData);
572
550
  if (stdin.setRawMode)
573
551
  stdin.setRawMode(wasRaw);
@@ -580,6 +558,7 @@ async function waitForParkedReviewSubmit(submitted) {
580
558
  cleanup();
581
559
  resolveAction(action);
582
560
  };
561
+ const onAbort = () => finish({ type: 'cancel' });
583
562
  const onData = (chunk) => {
584
563
  const text = chunk.toString('utf8');
585
564
  if (text === 'w' || text === 'W')
@@ -587,9 +566,14 @@ async function waitForParkedReviewSubmit(submitted) {
587
566
  if (text === '\u0003')
588
567
  finish({ type: 'cancel' });
589
568
  };
590
- stdin.setRawMode(true);
591
- stdin.resume();
592
- stdin.on('data', onData);
569
+ if (interactive) {
570
+ stdin.setRawMode(true);
571
+ stdin.resume();
572
+ stdin.on('data', onData);
573
+ }
574
+ signal?.addEventListener('abort', onAbort, { once: true });
575
+ if (signal?.aborted)
576
+ onAbort();
593
577
  submitted.then((result) => finish({ type: 'submitted', result }), () => finish({ type: 'cancel' }));
594
578
  });
595
579
  }
@@ -600,9 +584,8 @@ async function stopReviewServer(handle) {
600
584
  /**
601
585
  * Open a markdown file in a clean, read-only Neovim/Vim review session. The
602
586
  * human anchors comments to source lines/selections with native vim motions
603
- * and quits to submit. Blocks until the editor exits, then finalizes and
604
- * returns the feedback. Autosaved continuously so a kill is recoverable and
605
- * the next run resumes.
587
+ * and explicitly submits a proposal. Blocks until the editor exits. Autosaved
588
+ * drafts survive exit; canonical ticket finalization belongs to the controller.
606
589
  */
607
590
  export async function launchReview(file, opts) {
608
591
  const absFile = resolve(file);
@@ -611,19 +594,18 @@ export async function launchReview(file, opts) {
611
594
  }
612
595
  const outPath = resolve(opts.output);
613
596
  const bin = resolveEditor(opts.editor);
614
- // When the async kickoff passes its job dir, source the review.vim the parent
615
- // already wrote there; otherwise mint a private temp dir as before.
597
+ // Reuse the controller-owned job directory when supplied; otherwise create a private directory.
616
598
  const dir = opts.jobDir ?? mkdtempSync(join(tmpdir(), 'hl-review-'));
617
599
  const initPath = join(dir, 'review.vim');
618
600
  if (!existsSync(initPath))
619
601
  writeFileSync(initPath, reviewVimscript());
620
602
  const handoffFlagPath = join(dir, 'browser-handoff.flag');
603
+ const submitFlagPath = join(dir, 'review-submit.flag');
621
604
  // `-u NONE`: do NOT load the user's init.lua / LazyVim / plugins / keymaps.
622
605
  // Default runtimepath still includes the config dir (for the gloam
623
606
  // colorscheme) and the site dir (for the treesitter markdown parser), so the
624
607
  // review layer pulls in ONLY the colorscheme + treesitter styling itself.
625
608
  const editorArgs = ['-u', 'NONE', '-n', '-i', 'NONE', absFile, '-c', `source ${initPath}`];
626
- const inTmux = !!process.env.TMUX && !opts.noTmux;
627
609
  async function runEditor(reviewUrl) {
628
610
  const env = {
629
611
  ...process.env,
@@ -631,73 +613,47 @@ export async function launchReview(file, opts) {
631
613
  HL_SOURCE: absFile,
632
614
  HL_REVIEW_URL: reviewUrl,
633
615
  HL_HANDOFF_FLAG: handoffFlagPath,
634
- ...(opts.submitFlagPath ? { HL_SUBMIT_FLAG: opts.submitFlagPath } : {}),
616
+ HL_SUBMIT_FLAG: submitFlagPath,
635
617
  };
636
- process.stderr.write(`\nhumanloop: opening "${absFile}" for review in ${bin}` +
637
- (inTmux ? ' (tmux pane).\n' : '.\n') +
638
- ` Answers : ${outPath}\n` +
618
+ process.stderr.write(`\nhumanloop: opening "${absFile}" for review in ${bin}.\n` +
619
+ ` Draft : ${outPath}\n` +
639
620
  ` Browser : available after <Space>w hands off (or :HLBrowser)\n` +
640
621
  ` Keys : <Space>c comment · <Space>l list · <Space>u undo · <Space>s submit & quit · <Space>w browser (or :HLComment/:HLSubmit/:HLBrowser)\n` +
641
622
  ` 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
- }
668
- }
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);
672
- }
673
- }
674
- else {
675
- await runInCurrentTerminal(bin, editorArgs, env);
676
- }
623
+ await runInCurrentTerminal(bin, editorArgs, env, opts.signal);
677
624
  }
678
625
  while (true) {
679
626
  clearFlag(handoffFlagPath);
627
+ clearFlag(submitFlagPath);
680
628
  let resolveSubmitted;
681
629
  const submitted = new Promise((resolveSubmittedPromise) => {
682
630
  resolveSubmitted = resolveSubmittedPromise;
683
631
  });
632
+ if (opts.signal?.aborted)
633
+ return draftFeedback(outPath, absFile);
684
634
  const server = await startReviewWebServer({
685
635
  jobDir: dir,
686
636
  file: absFile,
687
637
  output: outPath,
688
- submitFlagPath: opts.submitFlagPath,
638
+ submitFlagPath,
689
639
  onSubmit: (result) => resolveSubmitted(result),
690
640
  });
641
+ if (opts.signal?.aborted) {
642
+ await stopReviewServer(server);
643
+ return draftFeedback(outPath, absFile);
644
+ }
691
645
  try {
692
646
  await runEditor(server.url);
693
647
  if (existsSync(handoffFlagPath)) {
694
648
  server.activate();
695
649
  process.stderr.write(`humanloop: browser review handoff active — ${server.url}\n`);
696
650
  openBrowser(server.url);
697
- const action = await waitForParkedReviewSubmit(submitted);
651
+ const action = await waitForParkedReviewSubmit(submitted, opts.signal);
698
652
  if (action.type === 'submitted') {
699
653
  await stopReviewServer(server);
700
654
  clearFlag(handoffFlagPath);
655
+ if (!opts.signal?.aborted)
656
+ await opts.onPropose?.(action.result);
701
657
  return action.result;
702
658
  }
703
659
  if (action.type === 'take-back') {
@@ -709,11 +665,16 @@ export async function launchReview(file, opts) {
709
665
  }
710
666
  await stopReviewServer(server);
711
667
  clearFlag(handoffFlagPath);
712
- return finalizeReviewOutput(outPath, absFile, false);
668
+ return draftFeedback(outPath, absFile);
713
669
  }
714
670
  await stopReviewServer(server);
715
- const didSubmit = opts.submitFlagPath ? existsSync(opts.submitFlagPath) : true;
716
- return finalizeReviewOutput(outPath, absFile, didSubmit);
671
+ if (existsSync(submitFlagPath)) {
672
+ const proposal = proposedFeedback(outPath, absFile);
673
+ if (!opts.signal?.aborted)
674
+ await opts.onPropose?.(proposal);
675
+ return proposal;
676
+ }
677
+ return draftFeedback(outPath, absFile);
717
678
  }
718
679
  catch (err) {
719
680
  await stopReviewServer(server);
@@ -0,0 +1,23 @@
1
+ export interface TicketClaim {
2
+ token: string;
3
+ host: string;
4
+ pid: number;
5
+ claimedAt: string;
6
+ heartbeatAt: string;
7
+ tmuxClient?: string;
8
+ }
9
+ export interface ClaimOptions {
10
+ host?: string;
11
+ pid?: number;
12
+ tmuxClient?: string;
13
+ now?: Date;
14
+ }
15
+ export declare function readTicketClaim(dir: string): TicketClaim | null;
16
+ export declare function isStaleClaim(claim: TicketClaim, now?: number, localHost?: string): boolean;
17
+ /** Acquire a visible claim only for a still-pending ticket. Malformed crash artifacts are stale claims. */
18
+ export declare function claimTicket(dir: string, opts?: ClaimOptions): TicketClaim | null;
19
+ export declare function heartbeatClaim(dir: string, token: string, now?: Date): boolean;
20
+ export declare function releaseClaimLocked(dir: string, token: string): boolean;
21
+ export declare function releaseClaim(dir: string, token: string): boolean;
22
+ export declare function claimExists(dir: string): boolean;
23
+ export declare function withTicketLock<T>(dir: string, operation: () => T): T;
@@ -0,0 +1,67 @@
1
+ import { existsSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { hostname } from 'node:os';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { claimPath, deckPath, readJson, responsePath, reviewPath, withExclusiveDirectoryLock } from './convention.js';
5
+ const REMOTE_STALE_MS = 30_000;
6
+ function parseClaim(raw) {
7
+ if (typeof raw !== 'object' || raw === null)
8
+ return null;
9
+ const claim = raw;
10
+ if (typeof claim.token !== 'string' || typeof claim.host !== 'string' || !Number.isInteger(claim.pid) || typeof claim.claimedAt !== 'string' || typeof claim.heartbeatAt !== 'string')
11
+ return null;
12
+ return { token: claim.token, host: claim.host, pid: claim.pid, claimedAt: claim.claimedAt, heartbeatAt: claim.heartbeatAt, ...(typeof claim.tmuxClient === 'string' ? { tmuxClient: claim.tmuxClient } : {}) };
13
+ }
14
+ export function readTicketClaim(dir) { return parseClaim(readJson(claimPath(dir))); }
15
+ function isLiveLocalPid(pid) { try {
16
+ process.kill(pid, 0);
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ } }
22
+ export function isStaleClaim(claim, now = Date.now(), localHost = hostname()) {
23
+ if (claim.host === localHost)
24
+ return !isLiveLocalPid(claim.pid);
25
+ const heartbeat = Date.parse(claim.heartbeatAt);
26
+ return !Number.isFinite(heartbeat) || now - heartbeat > REMOTE_STALE_MS;
27
+ }
28
+ /** Acquire a visible claim only for a still-pending ticket. Malformed crash artifacts are stale claims. */
29
+ export function claimTicket(dir, opts = {}) {
30
+ return withExclusiveDirectoryLock(`${dir}/.ticket-lock`, () => {
31
+ if (existsSync(responsePath(dir)) || (!existsSync(deckPath(dir)) && !existsSync(reviewPath(dir))))
32
+ return null;
33
+ const path = claimPath(dir);
34
+ const existing = readTicketClaim(dir);
35
+ if (existing !== null && !isStaleClaim(existing, Date.now(), opts.host ?? hostname()))
36
+ return null;
37
+ if (existsSync(path))
38
+ unlinkSync(path);
39
+ const host = opts.host ?? hostname();
40
+ const pid = opts.pid ?? process.pid;
41
+ const now = (opts.now ?? new Date()).toISOString();
42
+ const claim = { token: randomUUID(), host, pid, claimedAt: now, heartbeatAt: now, ...(opts.tmuxClient ? { tmuxClient: opts.tmuxClient } : {}) };
43
+ writeFileSync(path, `${JSON.stringify(claim)}\n`, { flag: 'wx', mode: 0o600 });
44
+ return claim;
45
+ });
46
+ }
47
+ export function heartbeatClaim(dir, token, now = new Date()) {
48
+ return withExclusiveDirectoryLock(`${dir}/.ticket-lock`, () => {
49
+ const claim = readTicketClaim(dir);
50
+ if (claim === null || claim.token !== token)
51
+ return false;
52
+ const temp = `${claimPath(dir)}.${token}.heartbeat`;
53
+ writeFileSync(temp, `${JSON.stringify({ ...claim, heartbeatAt: now.toISOString() })}\n`, { flag: 'wx', mode: 0o600 });
54
+ renameSync(temp, claimPath(dir));
55
+ return true;
56
+ });
57
+ }
58
+ export function releaseClaimLocked(dir, token) {
59
+ const claim = readTicketClaim(dir);
60
+ if (claim === null || claim.token !== token)
61
+ return false;
62
+ unlinkSync(claimPath(dir));
63
+ return true;
64
+ }
65
+ export function releaseClaim(dir, token) { return withExclusiveDirectoryLock(`${dir}/.ticket-lock`, () => releaseClaimLocked(dir, token)); }
66
+ export function claimExists(dir) { return existsSync(claimPath(dir)); }
67
+ export function withTicketLock(dir, operation) { return withExclusiveDirectoryLock(`${dir}/.ticket-lock`, operation); }
@@ -0,0 +1,4 @@
1
+ /** Deliver one canonical result. It is safe to call repeatedly after crashes. */
2
+ export declare function dispatchCompletion(root: string, dir: string): Promise<'delivered' | 'pending' | 'none'>;
3
+ /** Event-driven startup/root-change reconciliation for results lacking an ack. */
4
+ export declare function reconcileCompletions(root: string): Promise<void>;
@@ -0,0 +1,100 @@
1
+ import { readdirSync, statSync, unlinkSync } from 'node:fs';
2
+ import { spawn } from 'node:child_process';
3
+ import { basename, resolve } from 'node:path';
4
+ import { atomicWriteJson, deliveryErrorPath, deliveryPath, responsePath, readJson, reviewPath, withExclusiveDirectoryLockAsync } from './convention.js';
5
+ import { registeredInboxRoot } from './registry.js';
6
+ import { readTicketResult, ticketRoot } from './tickets.js';
7
+ import { validateReviewProjection } from './deck-schema.js';
8
+ function receiptMatches(dir) {
9
+ const receipt = readJson(deliveryPath(dir));
10
+ return receipt?.schema === 'humanloop.delivery/v1' && receipt.responsePath === responsePath(dir);
11
+ }
12
+ function eventFor(root, dir, result) {
13
+ return { schema: 'humanloop.completion/v1', root, dir, ticketId: basename(dir), kind: result.kind, outcome: result.kind === 'canceled' ? 'canceled' : 'resolved', responsePath: responsePath(dir) };
14
+ }
15
+ async function runHandler(command, args, event) {
16
+ await new Promise((resolvePromise, rejectPromise) => {
17
+ const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'ignore'] });
18
+ let timedOut = false;
19
+ const timeout = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 30_000);
20
+ child.once('error', (error) => { clearTimeout(timeout); rejectPromise(error); });
21
+ child.once('exit', (code, signal) => {
22
+ clearTimeout(timeout);
23
+ if (timedOut)
24
+ rejectPromise(new Error('completion handler timed out after 30 seconds'));
25
+ else if (code === 0)
26
+ resolvePromise();
27
+ else
28
+ rejectPromise(new Error(`completion handler failed (${signal ?? code ?? 'unknown'})`));
29
+ });
30
+ child.stdin.end(`${JSON.stringify(event)}\n`);
31
+ });
32
+ }
33
+ function projectReview(dir, result) {
34
+ if (result.kind !== 'review')
35
+ return;
36
+ const descriptor = validateReviewProjection(dir, readJson(reviewPath(dir)));
37
+ atomicWriteJson(descriptor.output, result.result);
38
+ }
39
+ /** Deliver one canonical result. It is safe to call repeatedly after crashes. */
40
+ export async function dispatchCompletion(root, dir) {
41
+ const registration = registeredInboxRoot(root);
42
+ const canonicalRoot = ticketRoot(dir);
43
+ if (registration === null || canonicalRoot !== registration.root)
44
+ throw new Error('ticket is not a canonical direct child of a registered root');
45
+ return withExclusiveDirectoryLockAsync(`${resolve(dir)}/.delivery-lock`, async () => {
46
+ const result = readTicketResult(dir);
47
+ if (result === null)
48
+ return 'none';
49
+ try {
50
+ projectReview(dir, result);
51
+ }
52
+ catch (error) {
53
+ atomicWriteJson(deliveryErrorPath(dir), { schema: 'humanloop.delivery-error/v1', responsePath: responsePath(dir), failedAt: new Date().toISOString(), error: error instanceof Error ? error.message : String(error) });
54
+ return 'pending';
55
+ }
56
+ if (registration.handler === undefined)
57
+ return 'none';
58
+ if (receiptMatches(dir))
59
+ return 'delivered';
60
+ const event = eventFor(registration.root, resolve(dir), result);
61
+ try {
62
+ await runHandler(registration.handler.command, registration.handler.args, event);
63
+ atomicWriteJson(deliveryPath(dir), { schema: 'humanloop.delivery/v1', responsePath: event.responsePath, deliveredAt: new Date().toISOString() });
64
+ try {
65
+ unlinkSync(deliveryErrorPath(dir));
66
+ }
67
+ catch { /* no prior error */ }
68
+ return 'delivered';
69
+ }
70
+ catch (error) {
71
+ atomicWriteJson(deliveryErrorPath(dir), { schema: 'humanloop.delivery-error/v1', responsePath: event.responsePath, failedAt: new Date().toISOString(), error: error instanceof Error ? error.message : String(error) });
72
+ return 'pending';
73
+ }
74
+ });
75
+ }
76
+ /** Event-driven startup/root-change reconciliation for results lacking an ack. */
77
+ export async function reconcileCompletions(root) {
78
+ const registration = registeredInboxRoot(root);
79
+ if (registration === null)
80
+ return;
81
+ let entries;
82
+ try {
83
+ entries = readdirSync(registration.root);
84
+ }
85
+ catch {
86
+ return;
87
+ }
88
+ for (const entry of entries) {
89
+ const dir = resolve(registration.root, entry);
90
+ try {
91
+ if (!statSync(dir).isDirectory())
92
+ continue;
93
+ }
94
+ catch {
95
+ continue;
96
+ }
97
+ if (ticketRoot(dir) === registration.root && readTicketResult(dir) !== null && !receiptMatches(dir))
98
+ await dispatchCompletion(registration.root, dir);
99
+ }
100
+ }