@kaydash9999/banker-plugins 0.16.3 → 0.17.0

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 (31) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +33 -0
  4. package/README.md +14 -11
  5. package/bin/banker.js +2 -2
  6. package/codex/manifest.json +2 -1
  7. package/codex/transform-matrix.md +2 -1
  8. package/commands/setup.md +2 -2
  9. package/hooks/progress-plan.mjs +408 -20
  10. package/hooks/progress.mjs +202 -50
  11. package/hooks/register.mjs +7 -1
  12. package/package.json +1 -1
  13. package/skills/3d-intro-build/references/assemble.mjs +10 -2
  14. package/skills/3d-intro-build/references/curate.mjs +10 -2
  15. package/skills/3d-intro-build/references/preview-lib.mjs +2 -0
  16. package/skills/3d-intro-build/references/serve.mjs +10 -2
  17. package/skills/3d-intro-build/references/verify-intro.mjs +10 -2
  18. package/skills/motion-graphic-make/references/curate.mjs +10 -2
  19. package/skills/motion-graphic-make/references/preview-lib.mjs +2 -0
  20. package/skills/motion-graphic-make/references/serve.mjs +10 -2
  21. package/skills/obsidizer/obsidize.mjs +10 -2
  22. package/skills/omc-patch/SKILL.md +96 -21
  23. package/skills/omc-patch/scripts/omc-patch.mjs +164 -62
  24. package/skills/omc-patch/scripts/probe-pm.mjs +192 -0
  25. package/skills/ultra-chain/SKILL.md +475 -0
  26. package/skills/ultra-chain/references/capture.mjs +232 -0
  27. package/skills/ultra-chain/references/chain-ledger.mjs +491 -0
  28. package/skills/ultra-chain/references/chain-report.mjs +257 -0
  29. package/skills/ultra-chain/references/preview-lib.mjs +258 -0
  30. package/skills/ultra-chain/references/serve.mjs +139 -0
  31. package/skills/vertical-pptx/verify_a4p.mjs +10 -2
@@ -10,8 +10,10 @@
10
10
  * calls.
11
11
  *
12
12
  * Claude 5 models get no task list tool in Claude Code 2.1.296 (only older models, a background
13
- * job, or CLAUDE_CODE_ENABLE_TODO_TOOLS do), so there prompts are the steps. Above them the pane
14
- * shows the plan file the session touched last (progress-plan.mjs reads it), as steps of its own.
13
+ * job, or CLAUDE_CODE_ENABLE_TODO_TOOLS do). Opening the pane offers the model one of its own
14
+ * (`mcp__banker__todo`, TodoWrite's shape) where it has none, and the list it sends is the steps.
15
+ * Above them the pane shows the plan file the session touched last (progress-plan.mjs reads it):
16
+ * the whole plan as a tree that folds like a toggle list, its sections folded at first.
15
17
  * A prompt's harness tags leave its title, and a background task's notice (or a teammate's message)
16
18
  * goes on with the last prompt's step instead of becoming one: the step runs again while it does,
17
19
  * and keeps its own end and time.
@@ -31,13 +33,53 @@ const HINT = '단계나 하위 항목을 누르면 설명이 여기에 나옵니
31
33
  const MAX_STEPS = 50;
32
34
  const MAX_KEPT_CALLS = 200;
33
35
  const MAX_SHOWN_CALLS = 30;
34
- const MAX_SHOWN_PLAN = 20;
36
+ // Rows of the plan's tree shown at most, and the characters they hold: the engine draws 100,000
37
+ // characters a drawing and blanks the rest, so a long plan must leave room for the steps and the
38
+ // description below it. One line past them says how many more rows there are.
39
+ const MAX_TREE_ROWS = 300;
40
+ const MAX_TREE_TEXT = 40000;
35
41
  // The calls whose file, when it is a plan file, becomes the plan the pane shows.
36
42
  const PLAN_TOUCH = new Set(['Write', 'Edit', 'MultiEdit', 'Read', 'NotebookEdit']);
37
43
  // The id of the step made for work already under way when the module loaded mid-turn.
38
44
  const AT_HAND = 'now';
45
+ // The pane's own to-do tool. The model calls it as `mcp__<plugin>__todo`; it sends the whole list
46
+ // each time, as TodoWrite does. Claude Code's own list tools, where the model has one, take its place.
47
+ const TODO = 'todo';
48
+ export const TODO_TOOL = `mcp__banker__${TODO}`;
49
+ const LIST_TOOLS = new Set(['TodoWrite', 'TaskCreate']);
50
+ const TODO_STATUS = new Set(['pending', 'in_progress', 'completed']);
51
+ const TODO_SPEC = {
52
+ name: TODO,
53
+ description: '진행 상황 패널(/progress)이 켜져 있고, 사람이 이 패널로 지금 하는 일과 남은 일을 본다. '
54
+ + '다음 경우에는 일을 시작하기 전에 이 도구로 할 일 목록 전체를 보낸다: 3단계 이상인 작업, 파일 여러 개를 '
55
+ + '만들거나 고치는 작업, 사용자가 여러 가지를 한꺼번에 시킨 경우. 항목을 시작할 때 in_progress 로, 끝나면 '
56
+ + '바로 completed 로 바꿔 목록 전체를 다시 보낸다. 상태가 바뀔 때만 보내고 같은 목록을 다시 보내지 않는다. '
57
+ + 'in_progress 는 하나만 둔다. 한 단계로 끝나는 일, 질문에 답하기, 대화에는 쓰지 않는다. '
58
+ + '패널이 꺼져 있다고 답하면 다시 부르지 않는다.',
59
+ inputSchema: {
60
+ type: 'object',
61
+ properties: {
62
+ todos: {
63
+ type: 'array',
64
+ description: '할 일 목록 전체, 하는 순서대로',
65
+ items: {
66
+ type: 'object',
67
+ properties: {
68
+ content: { type: 'string', description: '할 일 한 줄' },
69
+ status: { type: 'string', enum: [...TODO_STATUS] },
70
+ activeForm: { type: 'string', description: '진행 중일 때 보일 문구(선택)' },
71
+ },
72
+ required: ['content', 'status'],
73
+ additionalProperties: false,
74
+ },
75
+ },
76
+ },
77
+ required: ['todos'],
78
+ additionalProperties: false,
79
+ },
80
+ };
39
81
  // Bookkeeping calls: they become steps, not calls under a step.
40
- const TASK_TOOLS = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite']);
82
+ const TASK_TOOLS = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite', TODO_TOOL]);
41
83
  const STATUS = {
42
84
  running: '진행 중', in_progress: '진행 중', pending: '대기', done: '완료', completed: '완료',
43
85
  stopped: '중단됨', refused: '거절됨', failed: '오류로 끝남',
@@ -67,11 +109,11 @@ const tally = () => ({ calls: [], count: 0, failed: 0 });
67
109
  const looseStep = () => ({ key: 'loose', title: '작업 목록 밖의 작업', ...tally() });
68
110
 
69
111
  // `rev` counts the task changes (TaskCreate, TaskUpdate) taken in, to tell a stale TaskList or TaskGet.
70
- // `plan` is the plan last read from `planPath`, `planShut` whether its steps are folded. `touch` counts
71
- // the calls that touched a plan file and `taken` is the touch the plan came from, to tell a read that
72
- // a newer one overtook.
112
+ // `plan` is the plan last read from `planPath`, `planShut` whether the whole plan is folded and
113
+ // `planOpen` the ids of its nodes unfolded. `touch` counts the calls that touched a plan file and
114
+ // `taken` is the touch the plan came from, to tell a read that a newer one overtook.
73
115
  export const freshState = () => ({ turns: [], tasks: new Map(), todos: null, loose: looseStep(), open: new Set(), selected: null, seq: 0, rev: 0,
74
- plan: null, planPath: null, planShut: false, touch: 0, taken: 0 });
116
+ plan: null, planPath: null, planShut: false, planOpen: new Set(), touch: 0, taken: 0 });
75
117
 
76
118
  const tail = (path) => String(path).split(/[\\/]/).filter(Boolean).slice(-2).join('/');
77
119
  const seconds = (ms) => {
@@ -298,13 +340,41 @@ export function noteTaskTool(s, e, r, now, since) {
298
340
  if (CHANGES.has(e.tool)) s.rev += 1;
299
341
  }
300
342
 
343
+ // The list a to-do call sent, each item's words cleaned, or what is wrong with it.
344
+ export function readTodos(todos) {
345
+ if (!Array.isArray(todos)) return 'todos 는 할 일의 배열이어야 합니다.';
346
+ if (todos.length > MAX_STEPS) return `할 일은 ${MAX_STEPS}개까지 보낼 수 있습니다.`;
347
+ const out = [];
348
+ for (const t of todos) {
349
+ const content = typeof t?.content === 'string' ? clip(t.content, 200) : '';
350
+ if (!content || !TODO_STATUS.has(t.status)) return '항목마다 content(할 일)와 status(pending, in_progress, completed 중 하나)가 있어야 합니다.';
351
+ out.push({ content, status: t.status, activeForm: typeof t.activeForm === 'string' ? clip(t.activeForm, 80) : undefined });
352
+ }
353
+ return out;
354
+ }
355
+
356
+ // What the to-do tool answers, in words that say what happened. The list goes to the pane as
357
+ // TodoWrite's would, kept while the pane is closed too, so it shows when the pane opens again; the
358
+ // model is told it need not send more meanwhile. A subagent's list is its own, as with TodoWrite, and
359
+ // leaves the session's as it is. A list that is not one is refused, so the model reads why.
360
+ export function answerTodo(s, e, now, paneOpen = true) {
361
+ const todos = readTodos(e.todos);
362
+ if (typeof todos === 'string') return { deny: todos };
363
+ if (e.agentId) return { result: '하위 에이전트의 할 일 목록은 진행 상황 패널에 보이지 않습니다. 이 도구를 부르지 않아도 됩니다.' };
364
+ writeTodos(s, { todos }, now);
365
+ if (!paneOpen) return { result: '진행 상황 패널이 꺼져 있습니다. 목록은 받아 두었고, 패널이 다시 켜질 때까지 이 도구를 부르지 않아도 됩니다.' };
366
+ const going = todos.filter((t) => t.status === 'in_progress').length;
367
+ return { result: `진행 상황 패널에 할 일 ${todos.length}개를 보였습니다(진행 중 ${going}개).` };
368
+ }
369
+
301
370
  // The plan file a call touched, with the touch's count, when it is one and the call went through:
302
371
  // a write by anyone, a read by the session itself (an agent reviewing a plan does not make it the plan).
372
+ // `own` is a write by the session itself.
303
373
  export function planTouched(s, e, call) {
304
374
  const path = e.file_path ?? e.notebook_path;
305
375
  if (!call?.ok || !PLAN_TOUCH.has(e.tool) || !isPlanPath(path) || (e.tool === 'Read' && e.agentId)) return null;
306
376
  s.touch += 1;
307
- return { path, touch: s.touch };
377
+ return { path, touch: s.touch, own: !e.agentId && e.tool !== 'Read' };
308
378
  }
309
379
 
310
380
  function parsed(path, text) {
@@ -323,23 +393,34 @@ function current(s, path, { touch, since }) {
323
393
  return path === s.planPath && s.taken <= since;
324
394
  }
325
395
 
326
- const isPlanKey = (key) => key === 'plan' || /^p-\d+$/.test(key ?? '');
327
-
328
396
  // Whether what was read stays off the pane: no plan, or a file with no steps in place of a plan that
329
- // has some (a review written beside it).
330
- const keepsOff = (s, path, plan) => !plan || (path !== s.planPath && !plan.total && Boolean(s.plan?.total));
397
+ // has some, unless the session wrote it itself (an agent's review written beside a plan, or a note the
398
+ // session only read, does not push the plan away; a plan the session writes is the plan, steps or not).
399
+ const keepsOff = (s, path, plan, read) => !plan || (path !== s.planPath && !plan.total && Boolean(s.plan?.total) && !read.own);
400
+
401
+ // The ids in a tree, to keep the unfolded ones that are still there when a plan is read again.
402
+ function treeIds(nodes, out = new Set()) {
403
+ for (const n of nodes) {
404
+ out.add(n.id);
405
+ treeIds(n.children, out);
406
+ }
407
+ return out;
408
+ }
331
409
 
332
410
  // What a read of a plan file found (`read` is `{ touch }` for a touch's read, `{ since }` for a read
333
- // again). A file that cannot be read or is no plan leaves the plan as it was.
411
+ // again). A file that cannot be read or is no plan leaves the plan as it was. Another plan starts
412
+ // folded; the same plan read again keeps the nodes unfolded that it still holds.
334
413
  export function takePlan(s, path, text, read = {}) {
335
414
  if (!current(s, path, read)) return;
336
415
  const plan = parsed(path, text);
337
- if (keepsOff(s, path, plan)) return;
416
+ if (keepsOff(s, path, plan, read)) return;
338
417
  const moving = path !== s.planPath;
339
418
  if (read.touch !== undefined) s.taken = read.touch;
340
- if (moving && isPlanKey(s.selected)) s.selected = null;
419
+ if (moving && s.selected === 'plan') s.selected = null;
420
+ const ids = treeIds(plan.tree);
421
+ s.planOpen = moving ? new Set() : new Set([...s.planOpen].filter((id) => ids.has(id)));
341
422
  s.planPath = path;
342
- s.plan = { ...plan, path, steps: plan.steps.map((st, i) => ({ ...st, key: `p-${i}`, planStep: true })) };
423
+ s.plan = { ...plan, path };
343
424
  }
344
425
 
345
426
  // ── what the pane shows ──────────────────────────────────────────────────────
@@ -388,30 +469,23 @@ function callWords(call, now) {
388
469
 
389
470
  function planWords(plan) {
390
471
  const where = `계획 파일: ${clip(plan.path.split(/[\\/]/).filter(Boolean).slice(-3).join('/'), 120)}`;
391
- const cut = plan.cut ? ' 파일이 길어 앞부분만 읽었습니다.' : '';
392
- if (!plan.total) return `${where}. 단계를 찾지 못했습니다.${cut}`;
393
472
  const done = plan.hasStates ? `, 완료 ${plan.done}개` : '';
394
- return `${where}. 단계 ${plan.total}개${done}.${cut}`;
395
- }
396
-
397
- function planStepWords(st) {
398
- const what = st.text.replace(/[.!?。]+$/, '');
399
- return st.status ? `${what}. ${PLAN_STATUS[st.status]}.` : `${what}.`;
473
+ const count = plan.total ? ` 단계 ${plan.total}개${done}.` : '';
474
+ const cut = plan.cut ? ' 파일이 길어 앞부분만 읽었습니다.' : '';
475
+ const many = plan.treeCut ? ' 항목이 많아 앞부분만 보입니다.' : '';
476
+ return `${where}.${count}${cut}${many}`;
400
477
  }
401
478
 
402
- // What the person pressed last, while it is still on the pane: the plan or one of its steps, a
403
- // step, or a call under one.
479
+ // What the person pressed last, while it is still on the pane: the plan, a step, or a call under one.
480
+ // A plan's sections fold where they are pressed, and say nothing more below.
404
481
  function pressed(s) {
405
482
  if (s.plan && s.selected === 'plan') return { key: 'plan', isPlan: true };
406
- const plan = s.plan?.steps.find((st) => st.key === s.selected);
407
- if (plan) return plan;
408
483
  const all = steps(s);
409
484
  return all.find((x) => x.key === s.selected) ?? all.flatMap((x) => x.calls).find((c) => c.key === s.selected);
410
485
  }
411
486
 
412
487
  function itemWords(s, item, now) {
413
488
  if (item.isPlan) return planWords(s.plan);
414
- if (item.planStep) return planStepWords(item);
415
489
  return item.tool !== undefined ? callWords(item, now) : stepWords(item, now);
416
490
  }
417
491
 
@@ -460,39 +534,74 @@ function stepRow(ui, s, step, act) {
460
534
  ] });
461
535
  }
462
536
 
463
- // The plan's row: its title in bold (a Button draws its Text children in place of its label) and
464
- // how far along it is.
537
+ // The plan's row: its title in bold (a Button draws its Text children in place of its label) and,
538
+ // where its steps say, how far along it is.
465
539
  function planHead(ui, s, act) {
466
540
  const { Box, Text, Button } = ui;
467
541
  const { plan } = s;
468
- const count = !plan.total ? '단계 없음' : plan.hasStates ? `${plan.done}/${plan.total} 완료` : `${plan.total}단계`;
469
- const arrow = plan.steps.length
542
+ const count = !plan.total ? '' : plan.hasStates ? `${plan.done}/${plan.total} 완료` : `${plan.total}단계`;
543
+ const arrow = plan.tree.length
470
544
  ? Button({ key: 'arrow-plan', label: s.planShut ? '▸' : '▾', plain: true, onPress: () => act.foldPlan() })
471
545
  : Text({ children: [' '] });
472
546
  const label = room(`계획: ${plan.title}`, act, 2, count);
473
547
  return Box({ key: 'row-plan', flexDirection: 'row', columnGap: 1, children: [
474
548
  arrow,
475
549
  Button({ key: 'plan', label, plain: true, onPress: () => act.select('plan'), children: [Text({ bold: true, children: [label] })] }),
476
- Text({ children: [count], dimColor: true }),
550
+ ...(count ? [Text({ children: [count], dimColor: true })] : []),
477
551
  ] });
478
552
  }
479
553
 
480
- // The plan's rows: its title, then its first steps unless folded, then a line for the plan's rest.
481
- function planRows(ui, s, act) {
554
+ // A node's state on the pane: its checkbox's word, or how many of the checkboxes under it are done.
555
+ const nodeState = (n) => n.count ?? PLAN_STATUS[n.status] ?? '';
556
+ const NODE_STYLE = { head: { bold: true }, story: { bold: true }, 'code-line': { dimColor: true } };
557
+ // A part of a row that keeps its width while the words beside it wrap.
558
+ const fixed = (ui, children) => ui.Box({ flexShrink: 0, children });
559
+
560
+ // A node's row: an arrow where it holds nodes, its number or dash, its words (wrapping on the pane's
561
+ // width; a press on them folds it too) and its state.
562
+ function nodeRow(ui, s, n, depth, act) {
482
563
  const { Box, Text, Button } = ui;
564
+ const holds = n.children.length > 0;
565
+ const fold = () => act.foldNode(n.id);
566
+ const words = Text({ children: [n.text], wrap: 'wrap', ...(pick(NODE_STYLE, n.kind) ?? {}) });
567
+ const state = nodeState(n);
568
+ return Box({ key: `row-${n.id}`, flexDirection: 'row', columnGap: 1, paddingLeft: depth * 2, children: [
569
+ holds ? Button({ key: `arrow-${n.id}`, label: s.planOpen.has(n.id) ? '▾' : '▸', plain: true, onPress: fold }) : Text({ children: [' '] }),
570
+ ...(n.marker ? [fixed(ui, [Text({ children: [n.marker] })])] : []),
571
+ holds ? Button({ key: n.id, label: n.text, plain: true, onPress: fold, children: [words] }) : words,
572
+ ...(state ? [fixed(ui, stateWord(ui, state, n.status === 'in_progress'))] : []),
573
+ ] });
574
+ }
575
+
576
+ // The plan's nodes as rows, what a folded node holds left out: rows while they stay within
577
+ // MAX_TREE_ROWS and MAX_TREE_TEXT characters, then how many more would show.
578
+ function treeRows(ui, s, act) {
579
+ const rows = [];
580
+ let more = 0;
581
+ let used = 0;
582
+ const visit = (nodes, depth) => {
583
+ for (const n of nodes) {
584
+ used += n.text.length + (n.marker?.length ?? 0);
585
+ if (!more && rows.length < MAX_TREE_ROWS && used <= MAX_TREE_TEXT) rows.push(nodeRow(ui, s, n, depth, act));
586
+ else more += 1;
587
+ if (n.children.length && s.planOpen.has(n.id)) visit(n.children, depth + 1);
588
+ }
589
+ };
590
+ visit(s.plan.tree, 1);
591
+ return { rows, more };
592
+ }
593
+
594
+ // The plan's rows: its title, then its tree unless folded, then a line for the rows past the first 300.
595
+ function planRows(ui, s, act) {
596
+ const { Box, Text } = ui;
483
597
  if (!s.plan) return [];
484
598
  const rows = [planHead(ui, s, act)];
485
- if (s.planShut) return [...rows, Box({ key: 'plan-gap', marginBottom: 1 })];
486
- const shown = s.plan.steps.slice(0, MAX_SHOWN_PLAN);
487
- for (const st of shown) {
488
- const state = PLAN_STATUS[st.status] ?? '';
489
- rows.push(Box({ key: `row-${st.key}`, flexDirection: 'row', columnGap: 1, paddingLeft: 2, children: [
490
- Button({ key: st.key, label: room(st.title, act, 2, state), plain: true, onPress: () => act.select(st.key) }),
491
- ...stateWord(ui, state, st.status === 'in_progress'),
492
- ] }));
599
+ if (!s.planShut) {
600
+ const tree = treeRows(ui, s, act);
601
+ rows.push(...tree.rows);
602
+ if (tree.more) rows.push(Box({ key: 'plan-more', paddingLeft: 2, children: [Text({ children: [`외 ${tree.more}줄`], dimColor: true })] }));
603
+ if (s.plan.cut || s.plan.treeCut) rows.push(Box({ key: 'plan-cut', paddingLeft: 2, children: [Text({ children: ['계획이 길어 앞부분만 보입니다'], dimColor: true })] }));
493
604
  }
494
- const rest = s.plan.total - shown.length;
495
- if (rest > 0) rows.push(Box({ key: 'plan-more', paddingLeft: 2, children: [Text({ children: [`외 ${rest}개`], dimColor: true })] }));
496
605
  return [...rows, Box({ key: 'plan-gap', marginBottom: 1 })];
497
606
  }
498
607
 
@@ -518,14 +627,36 @@ async function showPane($, pane) {
518
627
  else if (!pane.isShown) await $.ui.open({ id: PANE, title: TITLE, focus: true });
519
628
  }
520
629
 
630
+ // Offers the model the to-do tool where it has no task list tool of Claude Code's and the tool is not
631
+ // there yet. An engine without a tool list offers nothing; a registration the engine refuses (`--bare`,
632
+ // a user MCP server of the same name) is said in one line, and the pane goes on with prompts as steps.
633
+ async function offerTodo($) {
634
+ let tools;
635
+ try {
636
+ tools = await $.tool.list();
637
+ } catch {
638
+ return;
639
+ }
640
+ if (tools.some((t) => LIST_TOOLS.has(t.name) || t.name === TODO_TOOL)) return;
641
+ try {
642
+ await $.tool.register(TODO_SPEC);
643
+ } catch (e) {
644
+ $.ui.log(`할 일 도구를 등록하지 못했습니다(${clip(e?.message ?? e, 120)}). 진행 상황 패널은 요청을 단계로 보입니다.`);
645
+ }
646
+ }
647
+
521
648
  // `show` (the default) toggles what the person sees; `on` and `off` set the pane whatever it was.
649
+ // Opening it offers the to-do tool, from the next prompt on.
522
650
  export async function runProgress($, args) {
523
651
  const want = String(args ?? '').trim().toLowerCase() || 'show';
524
652
  if (!['show', 'on', 'off'].includes(want)) return { text: USAGE };
525
653
  const pane = (await $.ui.panes()).find((p) => p.id === PANE);
526
654
  if (want === 'off' || (want === 'show' && pane?.isShown)) {
527
655
  if (pane) await $.ui.close({ id: PANE });
528
- } else await showPane($, pane);
656
+ return {};
657
+ }
658
+ await showPane($, pane);
659
+ await offerTodo($);
529
660
  return {};
530
661
  }
531
662
 
@@ -554,13 +685,19 @@ async function watchCall($, e, next) {
554
685
  endCall(call, r, Date.now());
555
686
  if (TASK_TOOLS.has(e.tool)) noteTaskTool(s, e, r, Date.now(), since);
556
687
  const touched = planTouched(s, e, call);
557
- if (touched) takePlan(s, touched.path, await readText($, touched.path), { touch: touched.touch });
688
+ if (touched) takePlan(s, touched.path, await readText($, touched.path), { touch: touched.touch, own: touched.own });
558
689
  $.ui.invalidate('ui.render');
559
690
  }
560
691
  }
561
692
 
562
- // A file's text, or null where it cannot be read (missing, over the engine's 4 MiB, refused).
693
+ // A file's text, or null where it cannot be read (missing, over the engine's 4 MiB, refused) or is no
694
+ // plain file: a plan path that leads to a pipe or a device would hold the read, and the hooks worker.
563
695
  async function readText($, path) {
696
+ try {
697
+ if ((await $.fs.stat(path)).kind !== 'file') return null;
698
+ } catch {
699
+ /* an engine without stat, or nothing there: the read below tells */
700
+ }
564
701
  try {
565
702
  return await $.fs.read(path);
566
703
  } catch {
@@ -581,6 +718,20 @@ export function registerProgress(on) {
581
718
  on('command.run', { command: 'banker:progress' }, async ($, e) => runProgress($, e.args)).catch(commandFailed);
582
719
  // Every tool call passes here: a fault in this hook leaves the call as if the hook were absent.
583
720
  on('tool.call', async ($, e, next) => watchCall($, e, next)).catch(() => undefined);
721
+ // The to-do tool: this hook answers it, and its schema is listed up front rather than behind
722
+ // ToolSearch, so the model sees it without asking.
723
+ on('tool.call', { tool: TODO_TOOL }, async ($, e) => {
724
+ let open = true;
725
+ try {
726
+ open = (await $.ui.panes()).some((p) => p.id === PANE);
727
+ } catch {
728
+ /* the pane list failed: take the list as for an open pane rather than refuse it */
729
+ }
730
+ const answer = answerTodo(ref.s, e, Date.now(), open);
731
+ $.ui.invalidate('ui.render');
732
+ return answer;
733
+ }).catch(() => ({ deny: '진행 상황 패널이 할 일 목록을 받지 못했습니다.' }));
734
+ on('tool.describe', { tool: TODO_TOOL }, async (_$, e, next) => ({ ...(await next(e)), isDeferred: false })).catch(() => undefined);
584
735
  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
585
736
  if (e.requestId !== PANE) return next(e);
586
737
  const redraw = () => $.ui.invalidate('ui.render');
@@ -590,6 +741,7 @@ export function registerProgress(on) {
590
741
  select: (key) => { ref.s.selected = ref.s.selected === key ? null : key; redraw(); },
591
742
  fold: (key) => { if (!ref.s.open.delete(key)) ref.s.open.add(key); redraw(); },
592
743
  foldPlan: () => { ref.s.planShut = !ref.s.planShut; redraw(); },
744
+ foldNode: (id) => { if (!ref.s.planOpen.delete(id)) ref.s.planOpen.add(id); redraw(); },
593
745
  };
594
746
  return draw($.ui.resolve(e), ref.s, Date.now(), act);
595
747
  });
@@ -47,10 +47,16 @@ async function registerCommands($) {
47
47
  }
48
48
 
49
49
  // /progress's plan file, read again at a turn's start and end: a ralph PRD or a plan's checkboxes
50
- // change without a call this module sees. A read that fails keeps the plan as it was.
50
+ // change without a call this module sees. A read that fails keeps the plan as it was, and a path that
51
+ // now leads to no plain file (a pipe would hold the read) is not read.
51
52
  async function rereadPlan($) {
52
53
  const target = progressPlanTarget();
53
54
  if (!target) return;
55
+ try {
56
+ if ((await $.fs.stat(target.path)).kind !== 'file') return;
57
+ } catch {
58
+ /* an engine without stat, or nothing there: the read below tells */
59
+ }
54
60
  let text = null;
55
61
  try {
56
62
  text = await $.fs.read(target.path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaydash9999/banker-plugins",
3
- "version": "0.16.3",
3
+ "version": "0.17.0",
4
4
  "description": "banker — Claude Code + Codex CLI plugin (QA, audit, docs, architecture, wiki skills). Install via Claude Code marketplace, or `npm i -g @kaydash9999/banker-plugins` then `banker setup`.",
5
5
  "bin": {
6
6
  "banker": "bin/banker.js"
@@ -280,8 +280,16 @@ export function assemble({ projectDir, outDir, manifestPath } = {}) {
280
280
  // ---- CLI --------------------------------------------------------------------
281
281
 
282
282
  // Run only when invoked directly (not when imported by a test/other module).
283
- const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
284
- if (invokedDirectly) {
283
+ // Node resolves links for import.meta.url but not for process.argv[1], so both go through realpath:
284
+ // compared as given, a run through a linked folder would exit 0 without doing anything.
285
+ const invokedDirectly = () => {
286
+ try {
287
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
288
+ } catch {
289
+ return false;
290
+ }
291
+ };
292
+ if (invokedDirectly()) {
285
293
  const argv = process.argv.slice(2);
286
294
  let projectDir = null, outDir = null, manifestPath = null;
287
295
  for (let i = 0; i < argv.length; i++) {
@@ -601,7 +601,15 @@ async function main() {
601
601
  return serveMain(dir, o);
602
602
  }
603
603
 
604
- const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
605
- if (invokedDirectly) {
604
+ // Node resolves links for import.meta.url but not for process.argv[1], so both go through realpath:
605
+ // compared as given, a run through a linked folder would exit 0 without doing anything.
606
+ const invokedDirectly = () => {
607
+ try {
608
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
609
+ } catch {
610
+ return false;
611
+ }
612
+ };
613
+ if (invokedDirectly()) {
606
614
  main().catch((e) => { console.error(String(e?.message || e)); process.exit(1); });
607
615
  }
@@ -38,6 +38,8 @@ export const MIME = {
38
38
  '.mjs': 'text/javascript;charset=utf-8', '.json': 'application/json;charset=utf-8', '.png': 'image/png',
39
39
  '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml',
40
40
  '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.woff2': 'font/woff2',
41
+ // Notes and logs a report links to open as text in the browser instead of downloading.
42
+ '.md': 'text/plain;charset=utf-8', '.txt': 'text/plain;charset=utf-8', '.log': 'text/plain;charset=utf-8', '.jsonl': 'text/plain;charset=utf-8',
41
43
  };
42
44
 
43
45
  // `strict` is the policy above; any other text is a policy as written; nothing means no header.
@@ -125,7 +125,15 @@ async function main() {
125
125
  process.on('SIGTERM', quit);
126
126
  }
127
127
 
128
- const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
129
- if (invokedDirectly) {
128
+ // Node resolves links for import.meta.url but not for process.argv[1], so both go through realpath:
129
+ // compared as given, a run through a linked folder would exit 0 without doing anything.
130
+ const invokedDirectly = () => {
131
+ try {
132
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
133
+ } catch {
134
+ return false;
135
+ }
136
+ };
137
+ if (invokedDirectly()) {
130
138
  main().catch((e) => { console.error(String(e?.message || e)); process.exit(1); });
131
139
  }
@@ -468,8 +468,16 @@ export function parseArgs(argv) {
468
468
  return o;
469
469
  }
470
470
 
471
- const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
472
- if (invokedDirectly) {
471
+ // Node resolves links for import.meta.url but not for process.argv[1], so both go through realpath:
472
+ // compared as given, a run through a linked folder would exit 0 without doing anything.
473
+ const invokedDirectly = () => {
474
+ try {
475
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
476
+ } catch {
477
+ return false;
478
+ }
479
+ };
480
+ if (invokedDirectly()) {
473
481
  let o;
474
482
  try { o = parseArgs(process.argv.slice(2)); } catch (e) { console.error(e.message); process.exit(2); }
475
483
  if (!o.url) {
@@ -601,7 +601,15 @@ async function main() {
601
601
  return serveMain(dir, o);
602
602
  }
603
603
 
604
- const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
605
- if (invokedDirectly) {
604
+ // Node resolves links for import.meta.url but not for process.argv[1], so both go through realpath:
605
+ // compared as given, a run through a linked folder would exit 0 without doing anything.
606
+ const invokedDirectly = () => {
607
+ try {
608
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
609
+ } catch {
610
+ return false;
611
+ }
612
+ };
613
+ if (invokedDirectly()) {
606
614
  main().catch((e) => { console.error(String(e?.message || e)); process.exit(1); });
607
615
  }
@@ -38,6 +38,8 @@ export const MIME = {
38
38
  '.mjs': 'text/javascript;charset=utf-8', '.json': 'application/json;charset=utf-8', '.png': 'image/png',
39
39
  '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml',
40
40
  '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.woff2': 'font/woff2',
41
+ // Notes and logs a report links to open as text in the browser instead of downloading.
42
+ '.md': 'text/plain;charset=utf-8', '.txt': 'text/plain;charset=utf-8', '.log': 'text/plain;charset=utf-8', '.jsonl': 'text/plain;charset=utf-8',
41
43
  };
42
44
 
43
45
  // `strict` is the policy above; any other text is a policy as written; nothing means no header.
@@ -125,7 +125,15 @@ async function main() {
125
125
  process.on('SIGTERM', quit);
126
126
  }
127
127
 
128
- const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
129
- if (invokedDirectly) {
128
+ // Node resolves links for import.meta.url but not for process.argv[1], so both go through realpath:
129
+ // compared as given, a run through a linked folder would exit 0 without doing anything.
130
+ const invokedDirectly = () => {
131
+ try {
132
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
133
+ } catch {
134
+ return false;
135
+ }
136
+ };
137
+ if (invokedDirectly()) {
130
138
  main().catch((e) => { console.error(String(e?.message || e)); process.exit(1); });
131
139
  }
@@ -542,9 +542,17 @@ function main(argv) {
542
542
  return 0;
543
543
  }
544
544
 
545
+ // Node resolves links for import.meta.url but not for process.argv[1], so both go through realpath:
546
+ // compared as given, a run through a linked folder would exit 0 without doing anything.
545
547
  // fileURLToPath, not URL.pathname: the latter yields "/C:/..." on Windows and never matches argv[1].
546
- const invoked = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
547
- if (invoked) {
548
+ const invoked = () => {
549
+ try {
550
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
551
+ } catch {
552
+ return false;
553
+ }
554
+ };
555
+ if (invoked()) {
548
556
  try {
549
557
  process.exit(main(process.argv.slice(2)));
550
558
  } catch (err) {