@kaydash9999/banker-plugins 0.16.2 → 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 +44 -0
  4. package/README.md +15 -10
  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 +716 -0
  10. package/hooks/progress.mjs +378 -34
  11. package/hooks/register.mjs +24 -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
@@ -9,11 +9,22 @@
9
9
  * a call shows a short description under the list; a press on a step's arrow folds or unfolds its
10
10
  * calls.
11
11
  *
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). 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.
17
+ * A prompt's harness tags leave its title, and a background task's notice (or a teammate's message)
18
+ * goes on with the last prompt's step instead of becoming one: the step runs again while it does,
19
+ * and keeps its own end and time.
20
+ *
12
21
  * `/progress` and `/progress show` toggle the pane, `on` and `off` set it. The command is
13
22
  * registered `immediate`, so it answers while a turn runs. An engine without mods never loads
14
23
  * this file: commands/progress.md and hooks/progress-fallback.mjs answer there instead.
15
24
  */
16
25
 
26
+ import { cells, clip, fit, isPlanPath, parsePlan, pick, readPrompt } from './progress-plan.mjs';
27
+
17
28
  const COMMAND = 'progress';
18
29
  const PANE = 'banker-progress';
19
30
  const TITLE = '진행 상황';
@@ -22,14 +33,64 @@ const HINT = '단계나 하위 항목을 누르면 설명이 여기에 나옵니
22
33
  const MAX_STEPS = 50;
23
34
  const MAX_KEPT_CALLS = 200;
24
35
  const MAX_SHOWN_CALLS = 30;
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;
41
+ // The calls whose file, when it is a plan file, becomes the plan the pane shows.
42
+ const PLAN_TOUCH = new Set(['Write', 'Edit', 'MultiEdit', 'Read', 'NotebookEdit']);
25
43
  // The id of the step made for work already under way when the module loaded mid-turn.
26
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
+ };
27
81
  // Bookkeeping calls: they become steps, not calls under a step.
28
- const TASK_TOOLS = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite']);
82
+ const TASK_TOOLS = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite', TODO_TOOL]);
29
83
  const STATUS = {
30
84
  running: '진행 중', in_progress: '진행 중', pending: '대기', done: '완료', completed: '완료',
31
85
  stopped: '중단됨', refused: '거절됨', failed: '오류로 끝남',
32
86
  };
87
+ // A plan step's state, as progress-plan.mjs names it.
88
+ const PLAN_STATUS = { completed: '완료', in_progress: '진행 중', pending: '대기', failed: '실패', blocked: '막힘' };
89
+ // A notice's row, by the status it came with.
90
+ const NOTICE = {
91
+ completed: '백그라운드 작업 끝남', failed: '백그라운드 작업 실패', stopped: '백그라운드 작업 중지됨',
92
+ killed: '백그라운드 작업 중지됨', message: '팀원 메시지',
93
+ };
33
94
  // How a prompt's turn ended (turn.complete's reason), as its step's state.
34
95
  const ENDED = { answer: 'done', aborted: 'stopped', refusal: 'refused', error: 'failed' };
35
96
  // Why a hook failed (the kind of the engine's HookFailure), for a failure that brings no message.
@@ -48,15 +109,12 @@ const tally = () => ({ calls: [], count: 0, failed: 0 });
48
109
  const looseStep = () => ({ key: 'loose', title: '작업 목록 밖의 작업', ...tally() });
49
110
 
50
111
  // `rev` counts the task changes (TaskCreate, TaskUpdate) taken in, to tell a stale TaskList or TaskGet.
51
- export const freshState = () => ({ turns: [], tasks: new Map(), todos: null, loose: looseStep(), open: new Set(), selected: null, seq: 0, rev: 0 });
52
-
53
- // One line of plain text, cut to `n` characters. Control characters go too: a pasted escape
54
- // sequence is not text to show.
55
- const clip = (text, n) => {
56
- const s = String(text ?? '').replace(/\p{Cc}+/gu, ' ').replace(/\s+/g, ' ').trim();
57
- const chars = [...s];
58
- return chars.length > n ? `${chars.slice(0, n - 1).join('')}…` : s;
59
- };
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.
115
+ export const freshState = () => ({ turns: [], tasks: new Map(), todos: null, loose: looseStep(), open: new Set(), selected: null, seq: 0, rev: 0,
116
+ plan: null, planPath: null, planShut: false, planOpen: new Set(), touch: 0, taken: 0 });
117
+
60
118
  const tail = (path) => String(path).split(/[\\/]/).filter(Boolean).slice(-2).join('/');
61
119
  const seconds = (ms) => {
62
120
  const all = Math.max(0, Math.round(ms / 1000));
@@ -72,14 +130,39 @@ export function callLabel(e) {
72
130
 
73
131
  // ── what the module learns from the session ──────────────────────────────────
74
132
 
75
- // A prompt as a step. A prompt whose end never came is done where the next one starts.
76
- function addTurn(s, e, now) {
77
- for (const t of s.turns) if (t.status === 'running') Object.assign(t, { status: 'done', endedAt: now });
78
- s.turns.push({ key: `t-${e.turnId}`, id: e.turnId, title: clip(e.text, 60) || '(이어서 진행)', text: String(e.text ?? ''),
79
- startedAt: now, endedAt: null, status: 'running', ...tally() });
133
+ // A prompt as a step. A prompt whose end never came is done where the next one starts, and so are
134
+ // the notices still running on a step. `notices` holds the turns of the notices running on it.
135
+ function addTurn(s, turnId, text, now, title) {
136
+ for (const t of s.turns) {
137
+ if (t.status === 'running') Object.assign(t, { status: 'done', endedAt: now });
138
+ t.notices.clear();
139
+ }
140
+ s.turns.push({ key: `t-${turnId}`, id: turnId, title: title ?? (clip(text, 60) || '(이어서 진행)'), text,
141
+ startedAt: now, endedAt: null, status: 'running', notices: new Set(), ...tally() });
80
142
  if (s.turns.length > MAX_STEPS) s.turns.shift();
81
143
  }
82
144
 
145
+ // A step's state on the pane: a prompt's step runs again while a notice runs on it.
146
+ const statusOf = (step) => (step.notices?.size ? 'running' : step.status);
147
+
148
+ // A row under a step that is no tool call: it counts as none. `ok` false shows it failed.
149
+ function note(s, step, label, ok, now) {
150
+ step.calls.push({ key: `c-${++s.seq}`, tool: '', label: clip(label, 60), detail: clip(label, 240), isNote: true,
151
+ startedAt: now, endedAt: now, ok, steps: [step] });
152
+ if (step.calls.length > MAX_KEPT_CALLS) step.calls.shift();
153
+ }
154
+
155
+ // A background task's end (or a teammate's message) opens a turn of its own: it runs on the last
156
+ // prompt's step, under which a row says so, and the list stays as it is. Before any prompt it is a
157
+ // step of its own.
158
+ function noticeTurn(s, e, said, now) {
159
+ const word = pick(NOTICE, said.status) ?? '백그라운드 작업 알림';
160
+ if (!s.turns.length) addTurn(s, e.turnId, '', now, said.status === 'message' ? word : '백그라운드 작업 알림');
161
+ const t = s.turns.at(-1);
162
+ if (t.id !== e.turnId) t.notices.add(e.turnId);
163
+ if (said.text) note(s, t, `${word}: ${said.text}`, said.status !== 'failed', now);
164
+ }
165
+
83
166
  // The task list's items, which are the steps while there are any: TodoWrite's, else the Tasks list's.
84
167
  function listItems(s) {
85
168
  return s.todos?.length ? s.todos : [...s.tasks.values()];
@@ -97,14 +180,22 @@ function dropFinished(s) {
97
180
  }
98
181
 
99
182
  export function startTurn(s, e, now) {
183
+ const said = readPrompt(e.text);
184
+ if (said.kind === 'notice') return noticeTurn(s, e, said, now);
100
185
  dropFinished(s);
101
- addTurn(s, e, now);
186
+ addTurn(s, e.turnId, said.text, now);
102
187
  }
103
188
 
104
- // A main turn's end; a subagent's (it carries an agentId) ends no step. A turn the module never
105
- // saw start (it loaded mid-turn) ends the step for the work at hand.
189
+ // A main turn's end; a subagent's (it carries an agentId) ends no step, and a notice's leaves its
190
+ // step's own end as it was. A turn the module never saw start (it loaded mid-turn) ends the step for
191
+ // the work at hand.
106
192
  export function endTurn(s, e, now) {
107
193
  if (e.agentId) return;
194
+ const host = s.turns.find((x) => x.notices.has(e.turnId));
195
+ if (host) {
196
+ host.notices.delete(e.turnId);
197
+ return;
198
+ }
108
199
  const t = s.turns.find((x) => x.id === e.turnId) ?? s.turns.find((x) => x.id === AT_HAND && x.status === 'running');
109
200
  if (!t) return;
110
201
  t.status = ENDED[e.reason] ?? 'failed';
@@ -118,7 +209,7 @@ function owners(s, now) {
118
209
  const items = listItems(s);
119
210
  const task = [...items].reverse().find((t) => t.status === 'in_progress');
120
211
  const first = task ?? (items.length ? s.loose : null);
121
- if (!s.turns.length) addTurn(s, { turnId: AT_HAND, text: '지금 하는 작업' }, now);
212
+ if (!s.turns.length) addTurn(s, AT_HAND, '지금 하는 작업', now);
122
213
  return first ? [first, s.turns.at(-1)] : [s.turns.at(-1)];
123
214
  }
124
215
 
@@ -249,6 +340,89 @@ export function noteTaskTool(s, e, r, now, since) {
249
340
  if (CHANGES.has(e.tool)) s.rev += 1;
250
341
  }
251
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
+
370
+ // The plan file a call touched, with the touch's count, when it is one and the call went through:
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.
373
+ export function planTouched(s, e, call) {
374
+ const path = e.file_path ?? e.notebook_path;
375
+ if (!call?.ok || !PLAN_TOUCH.has(e.tool) || !isPlanPath(path) || (e.tool === 'Read' && e.agentId)) return null;
376
+ s.touch += 1;
377
+ return { path, touch: s.touch, own: !e.agentId && e.tool !== 'Read' };
378
+ }
379
+
380
+ function parsed(path, text) {
381
+ try {
382
+ return typeof text === 'string' ? parsePlan(path, text) : null;
383
+ } catch {
384
+ return null; /* a plan the parser trips on leaves the pane as it was */
385
+ }
386
+ }
387
+
388
+ // Whether a read may change the plan: a read for a touch newer than the one the plan came from, or a
389
+ // read again of the plan's own file (begun when `since` touches were counted) that no touch taken
390
+ // in the meantime overtook.
391
+ function current(s, path, { touch, since }) {
392
+ if (touch !== undefined) return touch > s.taken;
393
+ return path === s.planPath && s.taken <= since;
394
+ }
395
+
396
+ // Whether what was read stays off the pane: no plan, or a file with no steps in place of a plan that
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
+ }
409
+
410
+ // What a read of a plan file found (`read` is `{ touch }` for a touch's read, `{ since }` for a read
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.
413
+ export function takePlan(s, path, text, read = {}) {
414
+ if (!current(s, path, read)) return;
415
+ const plan = parsed(path, text);
416
+ if (keepsOff(s, path, plan, read)) return;
417
+ const moving = path !== s.planPath;
418
+ if (read.touch !== undefined) s.taken = read.touch;
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)));
422
+ s.planPath = path;
423
+ s.plan = { ...plan, path };
424
+ }
425
+
252
426
  // ── what the pane shows ──────────────────────────────────────────────────────
253
427
 
254
428
  // Nothing seen yet: one step that says so, with no state word beside it.
@@ -257,7 +431,8 @@ const IDLE = { key: 'idle', title: '진행 중인 작업 없음', status: null,
257
431
  // The calls outside the list are in progress while calls still go there: no task is in progress
258
432
  // and the prompt runs.
259
433
  function looseStatus(s, items) {
260
- const going = !items.some((t) => t.status === 'in_progress') && s.turns.at(-1)?.status === 'running';
434
+ const last = s.turns.at(-1);
435
+ const going = !items.some((t) => t.status === 'in_progress') && last !== undefined && statusOf(last) === 'running';
261
436
  return going ? 'running' : 'done';
262
437
  }
263
438
 
@@ -272,9 +447,10 @@ function callCount(step) {
272
447
  }
273
448
 
274
449
  function stepState(step, now) {
275
- const doing = step.status === 'in_progress' && step.activeForm ? `(${clip(step.activeForm, 40)})` : '';
450
+ const status = statusOf(step);
451
+ const doing = status === 'in_progress' && step.activeForm ? `(${clip(step.activeForm, 40)})` : '';
276
452
  const time = Number.isFinite(step.startedAt) ? `, ${seconds((step.endedAt ?? now) - step.startedAt)}` : '';
277
- return `${STATUS[step.status] ?? step.status}${doing}${time}`;
453
+ return `${STATUS[status] ?? status}${doing}${time}`;
278
454
  }
279
455
 
280
456
  function stepWords(step, now) {
@@ -285,54 +461,155 @@ function stepWords(step, now) {
285
461
  }
286
462
 
287
463
  function callWords(call, now) {
464
+ if (call.isNote) return `${call.detail.replace(/[.!?。]+$/, '')}.`;
288
465
  const state = call.ok === null ? '실행 중' : call.ok ? '성공' : '실패';
289
466
  const who = call.isAgent ? '하위 에이전트의 ' : '';
290
467
  return `${who}${call.tool}${call.detail ? `: ${call.detail}` : ''}. ${state}, ${seconds((call.endedAt ?? now) - call.startedAt)}.`;
291
468
  }
292
469
 
293
- // What the person pressed last, while it is still on the pane: a step, or a call under one.
470
+ function planWords(plan) {
471
+ const where = `계획 파일: ${clip(plan.path.split(/[\\/]/).filter(Boolean).slice(-3).join('/'), 120)}`;
472
+ const done = plan.hasStates ? `, 완료 ${plan.done}개` : '';
473
+ const count = plan.total ? ` 단계 ${plan.total}개${done}.` : '';
474
+ const cut = plan.cut ? ' 파일이 길어 앞부분만 읽었습니다.' : '';
475
+ const many = plan.treeCut ? ' 항목이 많아 앞부분만 보입니다.' : '';
476
+ return `${where}.${count}${cut}${many}`;
477
+ }
478
+
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.
294
481
  function pressed(s) {
482
+ if (s.plan && s.selected === 'plan') return { key: 'plan', isPlan: true };
295
483
  const all = steps(s);
296
484
  return all.find((x) => x.key === s.selected) ?? all.flatMap((x) => x.calls).find((c) => c.key === s.selected);
297
485
  }
298
486
 
487
+ function itemWords(s, item, now) {
488
+ if (item.isPlan) return planWords(s.plan);
489
+ return item.tool !== undefined ? callWords(item, now) : stepWords(item, now);
490
+ }
491
+
299
492
  // The description under the list: the pressed step's or call's, or how to get one.
300
493
  export function describe(s, now) {
301
494
  const item = pressed(s);
302
- if (item) return item.tool ? callWords(item, now) : stepWords(item, now);
495
+ if (item) return itemWords(s, item, now);
303
496
  const all = steps(s);
304
497
  return all.length === 1 && all[0].key === 'idle' ? stepWords(IDLE, now) : HINT;
305
498
  }
306
499
 
500
+ // A label cut so that it, the cells before it and the words after it fit on one row of the pane.
501
+ const room = (label, act, before, after) => fit(label, (act.cols ?? Infinity) - before - (after ? cells(after) + 1 : 0));
502
+
307
503
  function callRows(ui, step, act) {
308
504
  const { Box, Text, Button } = ui;
309
505
  const shown = step.calls.slice(-MAX_SHOWN_CALLS);
310
- const rows = shown.map((c) => Box({ key: `row-${c.key}`, flexDirection: 'row', columnGap: 1, paddingLeft: 4, children: [
311
- Button({ key: c.key, label: c.label, plain: true, dimColor: c.ok !== false, onPress: () => act.select(c.key) }),
312
- Text({ children: [c.ok === null ? '실행 중' : c.ok ? '' : '실패'], dimColor: true }),
313
- ] }));
506
+ const rows = shown.map((c) => {
507
+ const state = c.ok === null ? '실행 중' : c.ok ? '' : '실패';
508
+ return Box({ key: `row-${c.key}`, flexDirection: 'row', columnGap: 1, paddingLeft: 4, children: [
509
+ Button({ key: c.key, label: room(c.label, act, 4, state), plain: true, dimColor: c.ok !== false, onPress: () => act.select(c.key) }),
510
+ ...stateWord(ui, state, false),
511
+ ] });
512
+ });
314
513
  const hidden = step.calls.length - shown.length;
315
514
  return hidden ? [Box({ paddingLeft: 4, children: [Text({ children: [`앞의 ${hidden}개는 줄였습니다`], dimColor: true })] }), ...rows] : rows;
316
515
  }
317
516
 
517
+ // A row's state word, bold while it runs; none where the state is empty, so no gap is kept for it.
518
+ function stateWord(ui, state, running) {
519
+ return state ? [ui.Text({ children: [state], dimColor: !running, bold: running })] : [];
520
+ }
521
+
318
522
  function stepRow(ui, s, step, act) {
319
523
  const { Box, Text, Button } = ui;
320
524
  const isOpen = s.open.has(step.key);
321
525
  const arrow = step.calls.length
322
526
  ? Button({ key: `arrow-${step.key}`, label: isOpen ? '▾' : '▸', plain: true, onPress: () => act.fold(step.key) })
323
527
  : Text({ children: [' '] });
324
- const running = step.status === 'running' || step.status === 'in_progress';
528
+ const status = statusOf(step);
529
+ const state = STATUS[status] ?? status ?? '';
325
530
  return Box({ key: `row-${step.key}`, flexDirection: 'row', columnGap: 1, children: [
326
531
  arrow,
327
- Button({ key: step.key, label: step.title, plain: true, onPress: () => act.select(step.key) }),
328
- Text({ children: [STATUS[step.status] ?? step.status ?? ''], dimColor: !running, bold: running }),
532
+ Button({ key: step.key, label: room(step.title, act, 2, state), plain: true, onPress: () => act.select(step.key) }),
533
+ ...stateWord(ui, state, status === 'running' || status === 'in_progress'),
329
534
  ] });
330
535
  }
331
536
 
332
- // The pane's tree. `ui` is the surface's element table; `act` holds what a press does.
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.
539
+ function planHead(ui, s, act) {
540
+ const { Box, Text, Button } = ui;
541
+ const { plan } = s;
542
+ const count = !plan.total ? '' : plan.hasStates ? `${plan.done}/${plan.total} 완료` : `${plan.total}단계`;
543
+ const arrow = plan.tree.length
544
+ ? Button({ key: 'arrow-plan', label: s.planShut ? '▸' : '▾', plain: true, onPress: () => act.foldPlan() })
545
+ : Text({ children: [' '] });
546
+ const label = room(`계획: ${plan.title}`, act, 2, count);
547
+ return Box({ key: 'row-plan', flexDirection: 'row', columnGap: 1, children: [
548
+ arrow,
549
+ Button({ key: 'plan', label, plain: true, onPress: () => act.select('plan'), children: [Text({ bold: true, children: [label] })] }),
550
+ ...(count ? [Text({ children: [count], dimColor: true })] : []),
551
+ ] });
552
+ }
553
+
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) {
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;
597
+ if (!s.plan) return [];
598
+ const rows = [planHead(ui, s, act)];
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 })] }));
604
+ }
605
+ return [...rows, Box({ key: 'plan-gap', marginBottom: 1 })];
606
+ }
607
+
608
+ // The pane's tree. `ui` is the surface's element table; `act` holds what a press does and `cols`,
609
+ // the cells across the pane's body (Infinity where the engine gives none).
333
610
  export function draw(ui, s, now, act) {
334
611
  const { Box, Text } = ui;
335
- const rows = [];
612
+ const rows = planRows(ui, s, act);
336
613
  for (const step of steps(s)) {
337
614
  rows.push(stepRow(ui, s, step, act));
338
615
  if (s.open.has(step.key)) rows.push(...callRows(ui, step, act));
@@ -350,14 +627,36 @@ async function showPane($, pane) {
350
627
  else if (!pane.isShown) await $.ui.open({ id: PANE, title: TITLE, focus: true });
351
628
  }
352
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
+
353
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.
354
650
  export async function runProgress($, args) {
355
651
  const want = String(args ?? '').trim().toLowerCase() || 'show';
356
652
  if (!['show', 'on', 'off'].includes(want)) return { text: USAGE };
357
653
  const pane = (await $.ui.panes()).find((p) => p.id === PANE);
358
654
  if (want === 'off' || (want === 'show' && pane?.isShown)) {
359
655
  if (pane) await $.ui.close({ id: PANE });
360
- } else await showPane($, pane);
656
+ return {};
657
+ }
658
+ await showPane($, pane);
659
+ await offerTodo($);
361
660
  return {};
362
661
  }
363
662
 
@@ -385,10 +684,27 @@ async function watchCall($, e, next) {
385
684
  } finally {
386
685
  endCall(call, r, Date.now());
387
686
  if (TASK_TOOLS.has(e.tool)) noteTaskTool(s, e, r, Date.now(), since);
687
+ const touched = planTouched(s, e, call);
688
+ if (touched) takePlan(s, touched.path, await readText($, touched.path), { touch: touched.touch, own: touched.own });
388
689
  $.ui.invalidate('ui.render');
389
690
  }
390
691
  }
391
692
 
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.
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
+ }
701
+ try {
702
+ return await $.fs.read(path);
703
+ } catch {
704
+ return null;
705
+ }
706
+ }
707
+
392
708
  // The session's progress, one per loaded module.
393
709
  const ref = { s: freshState() };
394
710
 
@@ -402,12 +718,30 @@ export function registerProgress(on) {
402
718
  on('command.run', { command: 'banker:progress' }, async ($, e) => runProgress($, e.args)).catch(commandFailed);
403
719
  // Every tool call passes here: a fault in this hook leaves the call as if the hook were absent.
404
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);
405
735
  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
406
736
  if (e.requestId !== PANE) return next(e);
407
737
  const redraw = () => $.ui.invalidate('ui.render');
738
+ const cols = Number.isFinite(e.props?.bodyColumns) ? e.props.bodyColumns : Infinity;
408
739
  const act = {
740
+ cols,
409
741
  select: (key) => { ref.s.selected = ref.s.selected === key ? null : key; redraw(); },
410
742
  fold: (key) => { if (!ref.s.open.delete(key)) ref.s.open.add(key); redraw(); },
743
+ foldPlan: () => { ref.s.planShut = !ref.s.planShut; redraw(); },
744
+ foldNode: (id) => { if (!ref.s.planOpen.delete(id)) ref.s.planOpen.add(id); redraw(); },
411
745
  };
412
746
  return draw($.ui.resolve(e), ref.s, Date.now(), act);
413
747
  });
@@ -425,3 +759,13 @@ export function progressTurnStarted(e) {
425
759
  export function progressTurnCompleted(e) {
426
760
  endTurn(ref.s, e, Date.now());
427
761
  }
762
+
763
+ // The plan file to read again as a turn starts and ends (register.mjs reads it), with the touches
764
+ // counted when the read begins; null without a plan.
765
+ export function progressPlanTarget() {
766
+ return ref.s.planPath ? { path: ref.s.planPath, since: ref.s.touch } : null;
767
+ }
768
+
769
+ export function progressPlanRead(target, text) {
770
+ takePlan(ref.s, target.path, text, { since: target.since });
771
+ }
@@ -16,7 +16,8 @@ import {
16
16
  registerGracefulPause,
17
17
  } from './graceful-pause.mjs';
18
18
  import {
19
- SPEC as PROGRESS_SPEC, progressReset, progressTurnCompleted, progressTurnStarted, registerProgress,
19
+ SPEC as PROGRESS_SPEC, progressPlanRead, progressPlanTarget, progressReset, progressTurnCompleted, progressTurnStarted,
20
+ registerProgress,
20
21
  } from './progress.mjs';
21
22
 
22
23
  async function engineVersion($) {
@@ -45,6 +46,26 @@ async function registerCommands($) {
45
46
  await registerOne($, PROGRESS_SPEC);
46
47
  }
47
48
 
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, and a path that
51
+ // now leads to no plain file (a pipe would hold the read) is not read.
52
+ async function rereadPlan($) {
53
+ const target = progressPlanTarget();
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
+ }
60
+ let text = null;
61
+ try {
62
+ text = await $.fs.read(target.path);
63
+ } catch {
64
+ /* missing or refused: the pane keeps the plan it read last */
65
+ }
66
+ progressPlanRead(target, text);
67
+ }
68
+
48
69
  // A /graceful-pause request that came after its turn's last model request: a note voids it.
49
70
  async function voidLateRequest($, late) {
50
71
  try {
@@ -75,6 +96,7 @@ export const register = (on) => {
75
96
  on('turn.start', async ($, e, next) => {
76
97
  pauseTurnStarted(e);
77
98
  progressTurnStarted(e);
99
+ await rereadPlan($);
78
100
  $.ui.invalidate('ui.render');
79
101
  return next(e);
80
102
  });
@@ -83,6 +105,7 @@ export const register = (on) => {
83
105
  const late = pauseTurnCompleted(e);
84
106
  if (late) await voidLateRequest($, late);
85
107
  progressTurnCompleted(e);
108
+ await rereadPlan($);
86
109
  $.ui.invalidate('ui.render');
87
110
  return next(e);
88
111
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaydash9999/banker-plugins",
3
- "version": "0.16.2",
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++) {