@crouton-kit/humanloop 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { launchReview } from './editor/review.js';
9
9
  import { validateDeck } from './inbox/deck-schema.js';
10
10
  import { ask, inbox } from './api.js';
11
11
  import { display } from './surfaces/display.js';
12
+ import { renderMarkdown, checkMarkdown } from './render/termrender.js';
12
13
  import { scanInbox } from './inbox/scan.js';
13
14
  import { deckPath, atomicWriteJson, readJson, responsePath, } from './inbox/convention.js';
14
15
  // ── Version ───────────────────────────────────────────────────────────────────
@@ -297,6 +298,7 @@ program
297
298
  ' deck — structured set of interactions (questions) for the human\n' +
298
299
  ' review — freeform markdown document review with anchored comments\n' +
299
300
  ' view — passive live render of a file in a tmux pane\n' +
301
+ ' doc — render or validate directive-flavored markdown to stdout\n' +
300
302
  ' inbox — list/resolve all pending interactions across root dirs\n' +
301
303
  ' job — a running or completed kickoff (deck ask / review / inbox)\n' +
302
304
  ' schema — JSON Schema for deck, resolution, or feedback payloads\n' +
@@ -305,6 +307,7 @@ program
305
307
  ' hl deck — write questions, get answers | use when: material decisions\n' +
306
308
  ' hl review — markdown doc review | use when: doc feedback needed\n' +
307
309
  ' hl view — live render in pane | use when: displaying a file\n' +
310
+ ' hl doc — render/validate to stdout | use when: piping rendered markdown\n' +
308
311
  ' hl inbox — browse pending interactions | use when: clearing a backlog\n' +
309
312
  ' hl job — inspect/wait/cancel running jobs | use when: polling job output\n' +
310
313
  ' hl schema — print JSON Schemas | use when: validating inputs\n' +
@@ -626,6 +629,79 @@ viewCmd
626
629
  }
627
630
  process.exit(0);
628
631
  });
632
+ // ── doc ───────────────────────────────────────────────────────────────────────
633
+ const docCmd = program.command('doc').description('Render or validate directive-flavored markdown to stdout.\n' +
634
+ '\n' +
635
+ 'Children:\n' +
636
+ ' hl doc check — validate directive syntax, no output | use when: preflighting before write\n' +
637
+ ' hl doc render — render markdown to ANSI/plain stdout | use when: piping rendered text to a file or consumer\n' +
638
+ '\n' +
639
+ 'These wrap the pinned termrender binary that humanloop manages. Consumers\n' +
640
+ 'should never call `termrender` directly — go through hl/SDK so there is one\n' +
641
+ 'org-wide caller.\n');
642
+ docCmd
643
+ .command('check')
644
+ .description('Validate directive-flavored markdown without rendering.\n' +
645
+ '\n' +
646
+ 'stdin { source?: string, path?: string } exactly one required\n' +
647
+ 'stdout { ok: bool, error?: string }\n' +
648
+ 'exit 0 always (validation failures are not process errors)\n')
649
+ .helpOption('-h, --help', 'Show help')
650
+ .action(() => {
651
+ const input = parseStdinJson();
652
+ const src = resolveDocSource(input);
653
+ const res = checkMarkdown(src);
654
+ process.stdout.write(JSON.stringify(res) + '\n');
655
+ process.exit(0);
656
+ });
657
+ docCmd
658
+ .command('render')
659
+ .description('Render directive-flavored markdown to ANSI or plain text on stdout.\n' +
660
+ '\n' +
661
+ 'stdin { source?: string, path?: string, width?: int=process.stdout.columns||100, color?: bool=true }\n' +
662
+ 'stdout the rendered text (raw bytes; not JSON)\n' +
663
+ 'exit 0 on success, non-zero on bad input\n' +
664
+ '\n' +
665
+ 'When color=false, ANSI escape sequences are stripped from the output.\n' +
666
+ 'Use this for feeding rendered content to other agents that need plain\n' +
667
+ 'text without color codes.\n')
668
+ .helpOption('-h, --help', 'Show help')
669
+ .action(() => {
670
+ const input = parseStdinJson();
671
+ const src = resolveDocSource(input);
672
+ const width = typeof input.width === 'number' && input.width > 0
673
+ ? input.width
674
+ : (process.stdout.columns || 100);
675
+ const lines = renderMarkdown(src, width);
676
+ let out = lines.join('\n');
677
+ if (input.color === false) {
678
+ // Strip ANSI escape sequences for plain-text consumers.
679
+ // eslint-disable-next-line no-control-regex
680
+ out = out.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
681
+ }
682
+ process.stdout.write(out);
683
+ if (!out.endsWith('\n'))
684
+ process.stdout.write('\n');
685
+ process.exit(0);
686
+ });
687
+ function resolveDocSource(input) {
688
+ const hasSource = typeof input.source === 'string' && input.source.length > 0;
689
+ const hasPath = typeof input.path === 'string' && input.path.length > 0;
690
+ if (hasSource === hasPath) {
691
+ emitError({
692
+ error: 'bad_input',
693
+ message: 'provide exactly one of {source, path}',
694
+ next: 'stdin like {"source": "..."} or {"path": "/abs/file.md"}',
695
+ });
696
+ }
697
+ if (hasSource)
698
+ return input.source;
699
+ const abs = resolve(input.path);
700
+ if (!existsSync(abs)) {
701
+ emitError({ error: 'file_not_found', message: `path not found: ${abs}`, next: 'check the path' });
702
+ }
703
+ return readFileSync(abs, 'utf-8');
704
+ }
629
705
  // ── inbox ─────────────────────────────────────────────────────────────────────
630
706
  const inboxCmd = program.command('inbox').description('Browse and resolve pending interactions across root dirs.');
631
707
  inboxCmd
@@ -6,6 +6,12 @@ export declare const interactionOptionSchema: z.ZodObject<{
6
6
  description: z.ZodOptional<z.ZodString>;
7
7
  shortcut: z.ZodOptional<z.ZodString>;
8
8
  }, z.core.$strip>;
9
+ export declare const preAnswerSchema: z.ZodObject<{
10
+ selectedOptionId: z.ZodOptional<z.ZodString>;
11
+ selectedOptionIds: z.ZodOptional<z.ZodArray<z.ZodString>>;
12
+ freetext: z.ZodOptional<z.ZodString>;
13
+ label: z.ZodOptional<z.ZodString>;
14
+ }, z.core.$strip>;
9
15
  export declare const deckSchema: z.ZodObject<{
10
16
  title: z.ZodOptional<z.ZodString>;
11
17
  source: z.ZodOptional<z.ZodObject<{
@@ -35,6 +41,12 @@ export declare const deckSchema: z.ZodObject<{
35
41
  context: "context";
36
42
  error: "error";
37
43
  }>>;
44
+ preAnswered: z.ZodOptional<z.ZodObject<{
45
+ selectedOptionId: z.ZodOptional<z.ZodString>;
46
+ selectedOptionIds: z.ZodOptional<z.ZodArray<z.ZodString>>;
47
+ freetext: z.ZodOptional<z.ZodString>;
48
+ label: z.ZodOptional<z.ZodString>;
49
+ }, z.core.$strip>>;
38
50
  }, z.core.$strip>>;
39
51
  }, z.core.$strip>;
40
52
  export declare function inlineBodyPath(deckPath: string, bodyPath: string): string;
@@ -10,6 +10,12 @@ export const interactionOptionSchema = z.object({
10
10
  description: z.string().optional(),
11
11
  shortcut: z.string().optional(),
12
12
  });
13
+ export const preAnswerSchema = z.object({
14
+ selectedOptionId: z.string().optional(),
15
+ selectedOptionIds: z.array(z.string()).optional(),
16
+ freetext: z.string().optional(),
17
+ label: z.string().optional(),
18
+ });
13
19
  const interactionSchema = z.object({
14
20
  id: z.string().regex(/^[A-Za-z0-9_-]+$/, { error: 'interaction id must match /^[A-Za-z0-9_-]+$/' }).min(1).max(64),
15
21
  title: z.string().min(1, { error: 'title must be non-empty' }),
@@ -21,6 +27,7 @@ const interactionSchema = z.object({
21
27
  allowFreetext: z.boolean().optional(),
22
28
  freetextLabel: z.string().optional(),
23
29
  kind: z.enum(['notify', 'validation', 'decision', 'context', 'error']).optional(),
30
+ preAnswered: preAnswerSchema.optional(),
24
31
  });
25
32
  const deckSourceSchema = z.object({
26
33
  sessionName: z.string().optional(),
package/dist/tui/app.js CHANGED
@@ -18,12 +18,36 @@ function buildInitialState(deck) {
18
18
  // Single-question decks skip the overview list — there's nothing to overview,
19
19
  // and overview hides the option hotkeys so users press 'y' and nothing happens.
20
20
  const initialPhase = deck.interactions.length === 1 ? 'item-review' : 'overview';
21
+ const responses = new Map();
22
+ const preAnsweredIds = new Set();
23
+ // Seed responses + preAnsweredIds from any `preAnswered` field. The seeded
24
+ // response counts as answered for navigation/auto-advance, but is rendered
25
+ // distinctly so the human knows it carried over. `tryResume` runs after and
26
+ // takes priority — mid-deck progress should not be overwritten by defaults.
27
+ for (const interaction of deck.interactions) {
28
+ const pa = interaction.preAnswered;
29
+ if (pa === undefined)
30
+ continue;
31
+ const response = { id: interaction.id };
32
+ if (pa.selectedOptionId !== undefined)
33
+ response.selectedOptionId = pa.selectedOptionId;
34
+ if (pa.selectedOptionIds !== undefined)
35
+ response.selectedOptionIds = [...pa.selectedOptionIds];
36
+ if (pa.freetext !== undefined)
37
+ response.freetext = pa.freetext;
38
+ responses.set(interaction.id, response);
39
+ preAnsweredIds.add(interaction.id);
40
+ }
41
+ // Start cursor on the first unanswered interaction — humans land where they
42
+ // need to act. If every interaction is pre-answered, fall back to index 0.
43
+ const firstUnanswered = deck.interactions.findIndex((i) => !responses.has(i.id));
21
44
  return {
22
45
  phase: initialPhase,
23
- currentIndex: 0,
46
+ currentIndex: firstUnanswered >= 0 ? firstUnanswered : 0,
24
47
  interactions: deck.interactions,
25
- responses: new Map(),
48
+ responses,
26
49
  visuals: new Map(),
50
+ preAnsweredIds,
27
51
  inputMode: null,
28
52
  selectedAction: 0,
29
53
  detailExpanded: false,
package/dist/tui/input.js CHANGED
@@ -173,7 +173,7 @@ function handleInteractionAction(input, key, state, interaction, render) {
173
173
  return;
174
174
  }
175
175
  submitOption(state, interaction, matched.id, undefined);
176
- advanceItem(state, 1);
176
+ advanceToNextUnanswered(state);
177
177
  render();
178
178
  return;
179
179
  }
@@ -204,13 +204,13 @@ function handleInteractionAction(input, key, state, interaction, render) {
204
204
  if (key.return && state.selectedAction < opts.length) {
205
205
  if (interaction.multiSelect) {
206
206
  commitMulti(state, interaction);
207
- advanceItem(state, 1);
207
+ advanceToNextUnanswered(state);
208
208
  render();
209
209
  return;
210
210
  }
211
211
  const o = opts[state.selectedAction];
212
212
  submitOption(state, interaction, o.id, undefined);
213
- advanceItem(state, 1);
213
+ advanceToNextUnanswered(state);
214
214
  render();
215
215
  return;
216
216
  }
@@ -258,7 +258,7 @@ function handleInputMode(input, key, state, render) {
258
258
  submitOption(state, interaction, attached, mode.buffer);
259
259
  }
260
260
  state.inputMode = null;
261
- advanceItem(state, 1);
261
+ advanceToNextUnanswered(state);
262
262
  render();
263
263
  return;
264
264
  }
@@ -320,6 +320,27 @@ function advanceItem(state, direction) {
320
320
  state.detailExpanded = false;
321
321
  state.scrollOffset = 0;
322
322
  }
323
+ /**
324
+ * Move to the next interaction WITHOUT a response, falling through to the
325
+ * final phase if every following interaction is already answered (whether
326
+ * user-answered or `preAnswered`-seeded). Used by all post-submit advance
327
+ * sites so the human flies through pre-approved items by hitting Enter; raw
328
+ * `n`/`p` still step one at a time via `advanceItem`.
329
+ */
330
+ function advanceToNextUnanswered(state) {
331
+ let next = state.currentIndex + 1;
332
+ while (next < state.interactions.length && state.responses.has(state.interactions[next].id)) {
333
+ next++;
334
+ }
335
+ if (next >= state.interactions.length) {
336
+ state.phase = 'final';
337
+ return;
338
+ }
339
+ state.currentIndex = next;
340
+ state.selectedAction = 0;
341
+ state.detailExpanded = false;
342
+ state.scrollOffset = 0;
343
+ }
323
344
  function actionCount(interaction) {
324
345
  return interaction.options.length + (interaction.allowFreetext && interaction.options.length > 0 ? 1 : 0);
325
346
  }
@@ -330,6 +351,9 @@ function submitOption(state, interaction, selectedOptionId, freetext) {
330
351
  if (freetext !== undefined)
331
352
  response.freetext = freetext;
332
353
  state.responses.set(interaction.id, response);
354
+ // Explicit user submission overrides any preAnswered seed — flip the icon
355
+ // from "previously answered" to user-answered.
356
+ state.preAnsweredIds.delete(interaction.id);
333
357
  state.persist?.();
334
358
  }
335
359
  // ── Multi-select ─────────────────────────────────────────────────────────────
@@ -346,6 +370,8 @@ function toggleMulti(state, interaction, optionId) {
346
370
  if (existing?.freetext !== undefined)
347
371
  response.freetext = existing.freetext;
348
372
  state.responses.set(interaction.id, response);
373
+ // User edited the checked set — no longer a passive carry-over.
374
+ state.preAnsweredIds.delete(interaction.id);
349
375
  state.persist?.();
350
376
  }
351
377
  /** Ensure a (possibly empty) response exists so the interaction counts as
@@ -360,5 +386,7 @@ function commitMulti(state, interaction, freetext) {
360
386
  if (ft !== undefined)
361
387
  response.freetext = ft;
362
388
  state.responses.set(interaction.id, response);
389
+ // Explicit confirm overrides any preAnswered seed.
390
+ state.preAnsweredIds.delete(interaction.id);
363
391
  state.persist?.();
364
392
  }
@@ -166,7 +166,10 @@ export function renderOverview(state, cols, rows) {
166
166
  for (let i = 0; i < state.interactions.length; i++) {
167
167
  const interaction = state.interactions[i];
168
168
  const response = state.responses.get(interaction.id);
169
- const icon = response ? `${GREEN}✓${RESET}` : `${DIM}○${RESET}`;
169
+ const preAnswered = state.preAnsweredIds.has(interaction.id);
170
+ const icon = response
171
+ ? (preAnswered ? `${DIM}◆${RESET}` : `${GREEN}✓${RESET}`)
172
+ : `${DIM}○${RESET}`;
170
173
  const label = singleLine(interaction.title);
171
174
  const cursor = i === state.currentIndex ? `${CYAN}▸${RESET} ` : ' ';
172
175
  const labelMax = Math.max(10, cols - 16);
@@ -238,6 +241,16 @@ export function renderItemReview(state, cols, rows) {
238
241
  preLines.push(` ${DIM}${line}${RESET}`);
239
242
  }
240
243
  }
244
+ // "Previously answered" marker — shown only while the seed is intact (no user
245
+ // override yet). The label comes from preAnswered.label so callers can be
246
+ // domain-specific ("Previously approved", "Carried over from prior pass").
247
+ if (state.preAnsweredIds.has(interaction.id)) {
248
+ const customLabel = interaction.preAnswered !== undefined ? interaction.preAnswered.label : undefined;
249
+ const label = typeof customLabel === 'string' && customLabel.length > 0
250
+ ? customLabel
251
+ : 'Previously answered';
252
+ preLines.push(` ${DIM}${ITALIC}◆ ${sanitize(label)} — press n/p to review, or any option to override${RESET}`);
253
+ }
241
254
  // Body: rendered question body + expanded visual block (scrollable)
242
255
  const bodyLines = [];
243
256
  if (interaction.body) {
@@ -425,7 +438,10 @@ export function renderFinal(state, cols, rows) {
425
438
  const questionRows = [];
426
439
  for (const interaction of state.interactions) {
427
440
  const response = state.responses.get(interaction.id);
428
- const icon = response ? `${GREEN}✓${RESET}` : `${YELLOW}○${RESET}`;
441
+ const preAnswered = state.preAnsweredIds.has(interaction.id);
442
+ const icon = response
443
+ ? (preAnswered ? `${DIM}◆${RESET}` : `${GREEN}✓${RESET}`)
444
+ : `${YELLOW}○${RESET}`;
429
445
  const label = singleLine(interaction.title);
430
446
  questionRows.push(` ${icon} ${truncate(label, Math.max(10, maxW - 4))}`);
431
447
  if (response) {
package/dist/types.d.ts CHANGED
@@ -6,6 +6,23 @@ export interface InteractionOption {
6
6
  description?: string;
7
7
  shortcut?: string;
8
8
  }
9
+ /**
10
+ * Seed an interaction with an answer the caller already has on hand — e.g. a
11
+ * prior approval the human shouldn't have to re-confirm. When present, the
12
+ * panel: (a) populates `responses[id]` from these fields at mount, (b) renders
13
+ * a distinct "previously answered" marker in overview/final and labels it in
14
+ * item-review, and (c) skips past the interaction on post-submit auto-advance.
15
+ * The human can still navigate to it with `n`/`p` and override the seeded
16
+ * answer — once they do, it renders as user-answered.
17
+ */
18
+ export interface InteractionPreAnswer {
19
+ selectedOptionId?: string;
20
+ selectedOptionIds?: string[];
21
+ freetext?: string;
22
+ /** One-line marker shown in the answered chrome (e.g. "Previously approved").
23
+ * Defaults to "Previously answered" when omitted. */
24
+ label?: string;
25
+ }
9
26
  export interface Interaction {
10
27
  id: string;
11
28
  title: string;
@@ -19,6 +36,7 @@ export interface Interaction {
19
36
  allowFreetext?: boolean;
20
37
  freetextLabel?: string;
21
38
  kind?: InteractionKind;
39
+ preAnswered?: InteractionPreAnswer;
22
40
  }
23
41
  export interface InteractionResponse {
24
42
  id: string;
@@ -84,6 +102,10 @@ export interface TuiState {
84
102
  interactions: Interaction[];
85
103
  responses: Map<string, InteractionResponse>;
86
104
  visuals: Map<string, VisualBlock>;
105
+ /** Ids of interactions whose response was seeded from `Interaction.preAnswered`
106
+ * and which the human has not yet overridden. Drives the distinct
107
+ * "previously answered" rendering and the skip-on-advance behavior. */
108
+ preAnsweredIds: Set<string>;
87
109
  inputMode: InputMode;
88
110
  selectedAction: number;
89
111
  detailExpanded: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/humanloop",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Human-in-the-loop decision TUI — agents write questions, humans answer them",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",