@polderlabs/bizar 10.17.1 → 10.17.3

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.
@@ -248,7 +248,7 @@ export function selectEventChain(eventKey, input = '') {
248
248
  if (eventKey === 'subagent-start') {
249
249
  return chain.filter((leaf) => {
250
250
  if (leaf === 'advisor-context') {
251
- return /^(linda|karen|carl|qa-reviewer|principal-engineer|debug-specialist)$/.test(agentType);
251
+ return /^(linda|carl)$/.test(agentType);
252
252
  }
253
253
  if (leaf === 'worktree-bootstrap') {
254
254
  return /^(brad|carl|pam|brenda|karen|todd|ria)$/.test(agentType);
@@ -21,6 +21,7 @@ import chalk from 'chalk';
21
21
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
22
22
  import { homedir } from 'node:os';
23
23
  import { dirname, isAbsolute, join, resolve } from 'node:path';
24
+ import readline from 'node:readline';
24
25
 
25
26
  import {
26
27
  rankUserSelectedForRole as rankUserSelectedForRoleMirror,
@@ -732,12 +733,13 @@ export function explainSelection({ routerPath, role, requirements = {} } = {}) {
732
733
  /**
733
734
  * Interactive multi-select picker. Pure function over streams — testable.
734
735
  *
735
- * The picker prints candidates with current picks marked, accepts a space-
736
- * separated list of indices (or `all` / `none` / `toggle <i>`), and returns
737
- * the chosen IDs in the user's most-recent selection order.
738
- *
739
- * Non-TTY input (pipes, tests, CI) is supported via a simple async line
740
- * iterator; TTY input uses `rl.question` so the prompt stays interactive.
736
+ * Branching: when stdin is a TTY with raw-mode support, the picker drives a
737
+ * keypress-driven checklist (arrow keys / j-k / space / a / n / enter / q /
738
+ * esc / ?). When stdin is not a TTY (pipes, CI, tests) the picker falls
739
+ * through to a line-mode loop that accepts a space-separated index list,
740
+ * `all`, `none`, `toggle <i>`, an empty line (confirm), or `q` (quit). Both
741
+ * branches return the chosen IDs in the user's most-recent selection order,
742
+ * so external callers and existing tests see a single `Promise<string[]>`.
741
743
  *
742
744
  * @param {{ candidates: Array<{ id: string }>, current?: string[], stdin?: NodeJS.ReadableStream, stdout?: NodeJS.WriteStream, prompt?: string }} opts
743
745
  * @returns {Promise<string[]>}
@@ -754,10 +756,36 @@ export async function pickModels({ candidates, current = [], stdin, stdout, prom
754
756
  const selected = new Set(current);
755
757
  let lastOrder = [...current];
756
758
 
757
- const lines = makeLineReader(in$);
759
+ const useTty = in$.isTTY === true && typeof in$.setRawMode === 'function';
760
+ if (!useTty) {
761
+ return pickModelsLineMode({ ordered, candidates, selected, lastOrder, stdin: in$, stdout: out$, prompt });
762
+ }
763
+
764
+ // Try to enable raw mode. If that fails (rare — redirected TTY, broken
765
+ // pseudo-terminal), fall back to the line-mode picker so the user never
766
+ // sees a silent no-op.
767
+ let rawOk = true;
768
+ try {
769
+ in$.setRawMode(true);
770
+ } catch {
771
+ rawOk = false;
772
+ }
773
+ if (!rawOk) {
774
+ return pickModelsLineMode({ ordered, candidates, selected, lastOrder, stdin: in$, stdout: out$, prompt });
775
+ }
776
+
777
+ try {
778
+ return await pickModelsInteractive({ ordered, candidates, selected, lastOrder, stdin: in$, stdout: out$, prompt });
779
+ } finally {
780
+ try { in$.setRawMode(false); } catch { /* swallow — terminal may already be gone */ }
781
+ }
782
+ }
783
+
784
+ async function pickModelsLineMode({ ordered, candidates, selected, lastOrder, stdin, stdout, prompt }) {
785
+ const lines = makeLineReader(stdin);
758
786
  for (;;) {
759
- renderPicker(out$, ordered, selected, prompt, candidates);
760
- const line = await readPrompt(lines, out$, in$.isTTY === true, '> ');
787
+ renderPicker(stdout, ordered, selected, prompt, candidates);
788
+ const line = await readPrompt(lines, stdout, stdin.isTTY === true, '> ');
761
789
  if (line === null) break; // EOF on non-TTY
762
790
  const cmd = String(line || '').trim();
763
791
  if (cmd === '') break;
@@ -775,7 +803,7 @@ export async function pickModels({ candidates, current = [], stdin, stdout, prom
775
803
  if (cmd.startsWith('toggle ')) {
776
804
  const idx = Number(cmd.slice('toggle '.length).trim());
777
805
  if (!Number.isInteger(idx) || idx < 1 || idx > ordered.length) {
778
- out$.write(chalk.red(` x index out of range\n`));
806
+ stdout.write(chalk.red(` x index out of range\n`));
779
807
  continue;
780
808
  }
781
809
  const id = ordered[idx - 1];
@@ -802,7 +830,7 @@ export async function pickModels({ candidates, current = [], stdin, stdout, prom
802
830
  }
803
831
  changed = true;
804
832
  }
805
- if (!changed) out$.write(chalk.yellow(` ! unrecognised input - try 'all', 'none', 'toggle <i>', or '1 3 5'\n`));
833
+ if (!changed) stdout.write(chalk.yellow(` ! unrecognised input - try 'all', 'none', 'toggle <i>', or '1 3 5'\n`));
806
834
  }
807
835
  const seen = new Set();
808
836
  return lastOrder.filter((id) => {
@@ -813,6 +841,145 @@ export async function pickModels({ candidates, current = [], stdin, stdout, prom
813
841
  });
814
842
  }
815
843
 
844
+ const VIEWPORT_SIZE = 20;
845
+ const HIDE_CURSOR = '\x1b[?25l';
846
+ const SHOW_CURSOR = '\x1b[?25h';
847
+ const ERASE_SCREEN = '\x1b[2J\x1b[H';
848
+
849
+ function cursorUp(n) {
850
+ return `\x1b[${n}A`;
851
+ }
852
+
853
+ function fitRow({ i, id, profile, width, isCursor, isSelected, columns }) {
854
+ const mark = isSelected ? chalk.green('[x]') : '[ ]';
855
+ const idx = String(i + 1).padStart(width, ' ');
856
+ const label = capabilityLabel(profile);
857
+ const budget = Math.max(40, (columns ?? 80) - 32);
858
+ const idText = id.length > budget ? `${id.slice(0, Math.max(1, budget - 1))}…` : id;
859
+ const cursorMark = isCursor ? chalk.inverse(' ▌ ') : ' ';
860
+ return ` ${cursorMark}${mark} ${chalk.dim(`${idx}.`)} ${idText}${chalk.dim(` [${label}]`)}`;
861
+ }
862
+
863
+ async function pickModelsInteractive({ ordered, candidates, selected, lastOrder, stdin, stdout, prompt }) {
864
+ let cursor = 0;
865
+ let scrollTop = 0;
866
+ let lastRenderHeight = 0;
867
+ let showHelp = false;
868
+
869
+ readline.emitKeypressEvents(stdin);
870
+ if (typeof stdin.resume === 'function') stdin.resume();
871
+ stdout.write(`${ERASE_SCREEN}${HIDE_CURSOR}`);
872
+
873
+ const exitState = await new Promise((resolve) => {
874
+ const onKey = (str, key) => {
875
+ if (!key) return;
876
+ // Resolve confirmation
877
+ if (key.name === 'return' || str === 'q' || key.name === 'escape') {
878
+ return finish();
879
+ }
880
+ if (key.ctrl && key.name === 'c') {
881
+ // SIGINT: discard selection and break out, but do not bubble.
882
+ selected.clear();
883
+ lastOrder.length = 0;
884
+ return finish();
885
+ }
886
+ if (key.name === 'up' || str === 'k') {
887
+ cursor = (cursor - 1 + ordered.length) % ordered.length;
888
+ } else if (key.name === 'down' || str === 'j') {
889
+ cursor = (cursor + 1) % ordered.length;
890
+ } else if (key.name === 'space' || str === 'x') {
891
+ const id = ordered[cursor];
892
+ if (selected.has(id)) {
893
+ selected.delete(id);
894
+ lastOrder = lastOrder.filter((x) => x !== id);
895
+ } else {
896
+ selected.add(id);
897
+ lastOrder.push(id);
898
+ }
899
+ } else if (str === 'a') {
900
+ for (const id of ordered) selected.add(id);
901
+ lastOrder = [...ordered];
902
+ } else if (str === 'n') {
903
+ selected.clear();
904
+ lastOrder = [];
905
+ } else if (str === '?') {
906
+ showHelp = !showHelp;
907
+ } else {
908
+ return;
909
+ }
910
+ adjustScroll();
911
+ render();
912
+ };
913
+
914
+ const finish = () => {
915
+ stdin.removeListener('keypress', onKey);
916
+ if (typeof stdin.pause === 'function') stdin.pause();
917
+ resolve();
918
+ };
919
+
920
+ stdin.on('keypress', onKey);
921
+ render();
922
+ });
923
+
924
+ stdout.write(SHOW_CURSOR);
925
+
926
+ const seen = new Set();
927
+ return lastOrder.filter((id) => {
928
+ if (!selected.has(id)) return false;
929
+ if (seen.has(id)) return false;
930
+ seen.add(id);
931
+ return true;
932
+ });
933
+
934
+ function adjustScroll() {
935
+ if (ordered.length <= VIEWPORT_SIZE) {
936
+ scrollTop = 0;
937
+ return;
938
+ }
939
+ if (cursor < scrollTop) scrollTop = cursor;
940
+ else if (cursor >= scrollTop + VIEWPORT_SIZE) scrollTop = cursor - VIEWPORT_SIZE + 1;
941
+ if (scrollTop < 0) scrollTop = 0;
942
+ if (scrollTop > Math.max(0, ordered.length - VIEWPORT_SIZE)) {
943
+ scrollTop = Math.max(0, ordered.length - VIEWPORT_SIZE);
944
+ }
945
+ }
946
+
947
+ function render() {
948
+ const width = String(ordered.length).length;
949
+ const columns = typeof stdout.columns === 'number' ? stdout.columns : 80;
950
+ const lines = [];
951
+ lines.push(`\x1b[K${chalk.bold(`-- ${prompt} --`)}`);
952
+ const start = scrollTop;
953
+ const end = Math.min(ordered.length, start + VIEWPORT_SIZE);
954
+ const above = start;
955
+ const below = ordered.length - end;
956
+ if (above > 0) lines.push(`\x1b[K${chalk.dim(` ⋮ ${above} more above`)}`);
957
+ for (let i = start; i < end; i++) {
958
+ const profile = candidates.find((candidate) => candidate.id === ordered[i])?.profile;
959
+ const row = fitRow({
960
+ i,
961
+ id: ordered[i],
962
+ profile,
963
+ width,
964
+ isCursor: i === cursor,
965
+ isSelected: selected.has(ordered[i]),
966
+ columns,
967
+ });
968
+ lines.push(`\x1b[K${row}`);
969
+ }
970
+ if (below > 0) lines.push(`\x1b[K${chalk.dim(` ⋮ ${below} more below`)}`);
971
+ lines.push(`\x1b[K${chalk.dim(` ${selected.size}/${ordered.length} selected. ↑/↓ move · space toggle · a all · n none · enter confirm.`)}`);
972
+ if (showHelp) lines.push(`\x1b[K${chalk.dim(` Extra: j/k · x toggle · q/esc confirm · ? help.`)}`);
973
+ lines.push(`\x1b[K`);
974
+
975
+ if (lastRenderHeight > 0) {
976
+ stdout.write(cursorUp(lastRenderHeight));
977
+ }
978
+ stdout.write(lines.join('\n'));
979
+ lastRenderHeight = lines.length;
980
+ }
981
+ }
982
+
816
983
  /**
817
984
  * Build a small async iterator over `stdin` lines.
818
985
  * Returns null from `next()` when the stream ends.
@@ -909,7 +1076,7 @@ function showHelp() {
909
1076
  bizar models - User-controlled model picker
910
1077
 
911
1078
  Usage:
912
- bizar models Interactive picker (lists + asks for selection)
1079
+ bizar models Interactive picker (TTY: arrow keys / space / enter; pipe: line-mode)
913
1080
  bizar models --list Print candidate IDs, one per line
914
1081
  bizar models --set a,b,c Persist the comma-separated IDs to userSelected
915
1082
  bizar models --clear Remove userSelected; orchestrator falls back to session-only
@@ -1,43 +1,125 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Inject the recent parent transcript into review-oriented subagents.
4
- * Reads newest-first with hard record and character caps.
3
+ * advisor-context.mjs Claude Code SubagentStart leaf hook.
4
+ *
5
+ * Inject a SHORT, FILTERED slice of the parent transcript into reviewer /
6
+ * debug-specialist subagents (`@linda`, `@carl`). The previous version
7
+ * dumped up to 30kB of raw parent transcript on every dispatch of
8
+ * `linda|karen|carl|qa-reviewer|principal-engineer|debug-specialist`. Two
9
+ * failure modes followed:
10
+ *
11
+ * 1. The dump was too noisy — reviewers only need the recent intent
12
+ * and the changed files, not arbitrary chitchat from earlier in
13
+ * the session.
14
+ * 2. The JSONL transcript persists across sessions on the same
15
+ * project (Claude Code appends to `~/.claude/projects/.../<id>.jsonl`
16
+ * rather than rotating per session). Reading the "tail" of that
17
+ * file can surface content from a previous, unrelated session —
18
+ * including prior user prompts, spinner/status text, and stuff the
19
+ * current session has no business knowing.
20
+ *
21
+ * The fix below:
22
+ * - Take only the last 8 user/assistant records (was 60).
23
+ * - Skip non-substantive records entirely: `isSidechain`, `isMeta`,
24
+ * `type: 'attachment'`, `type: 'system'`, `type: 'last-prompt'`,
25
+ * `type: 'ai-title'`, `type: 'agent-name'`,
26
+ * `type: 'stop_hook_summary'`.
27
+ * - Per-record cap: 800 chars (was 3000).
28
+ * - Hard total cap: 6kB (was 30kB).
29
+ * - Strip `<system-reminder>` and `<total_tokens>` blocks inside text
30
+ * content.
31
+ * - Fall back to "could not be reconstructed" when the dump is too
32
+ * short to be useful (< 100 chars).
33
+ *
34
+ * Output envelope (unchanged from the previous version):
35
+ * { hookSpecificOutput: { hookEventName: 'SubagentStart', additionalContext } }
5
36
  */
6
37
 
7
38
  import { readFileSync } from 'node:fs';
8
39
 
9
- const clip = (value, limit) => value.length > limit ? `${value.slice(0, limit)} …[truncated]` : value;
40
+ const MAX_RECORDS = 8;
41
+ const PER_RECORD_CAP = 800;
42
+ const TOTAL_CAP = 6_000;
43
+ const MIN_USEFUL_LENGTH = 100;
44
+
45
+ // Records that should never be replayed as "parent context" — they are
46
+ // bookkeeping, meta, or status output rather than substantive session
47
+ // content.
48
+ const SKIP_TYPES = new Set([
49
+ 'attachment',
50
+ 'system',
51
+ 'last-prompt',
52
+ 'ai-title',
53
+ 'agent-name',
54
+ 'stop_hook_summary',
55
+ 'queue-operation',
56
+ ]);
57
+
58
+ const SYSTEM_REMINDER_RE = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
59
+ const TOTAL_TOKENS_RE = /<total_tokens>[\s\S]*?<\/total_tokens>/g;
60
+ const CCR_RE = /\[\s*CCR\s+retrieve[^\]]*\]/g;
61
+
62
+ function clip(value, limit) {
63
+ return value.length > limit ? `${value.slice(0, limit)} …[truncated]` : value;
64
+ }
65
+
66
+ function cleanText(text) {
67
+ if (typeof text !== 'string' || text.length === 0) return '';
68
+ return text
69
+ .replace(SYSTEM_REMINDER_RE, '')
70
+ .replace(TOTAL_TOKENS_RE, '')
71
+ .replace(CCR_RE, '[compacted context omitted]')
72
+ .replace(/[ \t]+\n/g, '\n')
73
+ .trim();
74
+ }
75
+
76
+ function isSubstantiveRecord(record) {
77
+ if (!record || typeof record !== 'object') return false;
78
+ if (record.isSidechain === true || record.isMeta === true) return false;
79
+ if (SKIP_TYPES.has(record.type)) return false;
80
+ return record.type === 'user' || record.type === 'assistant';
81
+ }
82
+
83
+ function extractText(content) {
84
+ if (typeof content === 'string') return content;
85
+ if (!Array.isArray(content)) return '';
86
+ return content
87
+ .filter((block) => block && block.type === 'text')
88
+ .map((block) => block.text || '')
89
+ .join('\n');
90
+ }
91
+
92
+ function readTranscriptLines(transcriptPath) {
93
+ if (!transcriptPath) return [];
94
+ let text;
95
+ try {
96
+ text = readFileSync(transcriptPath, 'utf8');
97
+ } catch {
98
+ return [];
99
+ }
100
+ return text.split('\n').filter(Boolean);
101
+ }
10
102
 
11
103
  let input = {};
12
104
  try { input = JSON.parse(readFileSync(0, 'utf8') || '{}'); } catch { input = {}; }
13
105
 
14
- let rendered = [];
15
- try {
16
- const lines = readFileSync(input.transcript_path, 'utf8').split('\n');
17
- for (let index = lines.length - 1; index >= 0 && rendered.length < 60; index--) {
18
- let record;
19
- try { record = JSON.parse(lines[index]); } catch { continue; }
20
- if (record.isSidechain || record.isMeta || !['user', 'assistant'].includes(record.type)) continue;
21
- const content = record.message?.content;
22
- if (typeof content === 'string') rendered.push(`${record.type}: ${clip(content, 3_000)}`);
23
- else if (Array.isArray(content)) {
24
- const text = content
25
- .filter((block) => block?.type === 'text')
26
- .map((block) => block.text || '')
27
- .join('\n');
28
- if (text) rendered.push(`${record.type}: ${clip(text, 3_000)}`);
29
- }
30
- }
31
- } catch { rendered = []; }
32
-
33
- rendered.reverse();
34
- let recent = rendered.join('\n\n---\n\n');
35
- if (recent.length > 30_000) recent = `…[older turns truncated]\n${recent.slice(-30_000)}`;
36
- // Strip CCR (context-compaction-retrieval) compression markers so subagents
37
- // do not interpret them as user content. Replace with a brief note.
38
- recent = recent.replace(/\[\s*CCR\s+retrieve[^\]]*\]/g, '[compacted context omitted]');
39
- const additionalContext = recent
40
- ? `Review the caller's request against this recent parent-session evidence, not only its summary:\n\n<recent-conversation>\n${recent}\n</recent-conversation>`
106
+ const lines = readTranscriptLines(input.transcript_path);
107
+ const records = [];
108
+ for (let index = lines.length - 1; index >= 0 && records.length < MAX_RECORDS; index--) {
109
+ let record;
110
+ try { record = JSON.parse(lines[index]); } catch { continue; }
111
+ if (!isSubstantiveRecord(record)) continue;
112
+ const text = cleanText(extractText(record.message?.content));
113
+ if (!text) continue;
114
+ records.push(`${record.type}: ${clip(text, PER_RECORD_CAP)}`);
115
+ }
116
+
117
+ records.reverse();
118
+ let recent = records.join('\n\n---\n\n');
119
+ if (recent.length > TOTAL_CAP) recent = `…[older turns truncated]\n${recent.slice(-TOTAL_CAP)}`;
120
+
121
+ const additionalContext = recent.length >= MIN_USEFUL_LENGTH
122
+ ? `Recent parent transcript (last ${records.length} records, capped at ${TOTAL_CAP} chars):\n\n<recent-conversation>\n${recent}\n</recent-conversation>`
41
123
  : 'The parent transcript could not be reconstructed. State any context needed before making a strong claim.';
42
124
 
43
125
  process.stdout.write(JSON.stringify({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar",
3
- "version": "10.17.1",
3
+ "version": "10.17.3",
4
4
  "description": "Autonomous, human-in-the-loop multi-agent harness for Claude Code with guarded workflows, typed SDK primitives, and MCP tools.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
1
  /**
2
2
  * SDK version constant. Keep synchronized with the workspace package versions.
3
3
  */
4
- export declare const SDK_VERSION: "10.17.1";
4
+ export declare const SDK_VERSION: "10.17.3";
5
5
  //# sourceMappingURL=version.d.ts.map
@@ -1,5 +1,5 @@
1
1
  /**
2
2
  * SDK version constant. Keep synchronized with the workspace package versions.
3
3
  */
4
- export const SDK_VERSION = "10.17.1";
4
+ export const SDK_VERSION = "10.17.3";
5
5
  //# sourceMappingURL=version.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar-sdk",
3
- "version": "10.17.1",
3
+ "version": "10.17.3",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",