@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +44 -0
- package/README.md +15 -10
- package/bin/banker.js +2 -2
- package/codex/manifest.json +2 -1
- package/codex/transform-matrix.md +2 -1
- package/commands/setup.md +2 -2
- package/hooks/progress-plan.mjs +716 -0
- package/hooks/progress.mjs +378 -34
- package/hooks/register.mjs +24 -1
- package/package.json +1 -1
- package/skills/3d-intro-build/references/assemble.mjs +10 -2
- package/skills/3d-intro-build/references/curate.mjs +10 -2
- package/skills/3d-intro-build/references/preview-lib.mjs +2 -0
- package/skills/3d-intro-build/references/serve.mjs +10 -2
- package/skills/3d-intro-build/references/verify-intro.mjs +10 -2
- package/skills/motion-graphic-make/references/curate.mjs +10 -2
- package/skills/motion-graphic-make/references/preview-lib.mjs +2 -0
- package/skills/motion-graphic-make/references/serve.mjs +10 -2
- package/skills/obsidizer/obsidize.mjs +10 -2
- package/skills/omc-patch/SKILL.md +96 -21
- package/skills/omc-patch/scripts/omc-patch.mjs +164 -62
- package/skills/omc-patch/scripts/probe-pm.mjs +192 -0
- package/skills/ultra-chain/SKILL.md +475 -0
- package/skills/ultra-chain/references/capture.mjs +232 -0
- package/skills/ultra-chain/references/chain-ledger.mjs +491 -0
- package/skills/ultra-chain/references/chain-report.mjs +257 -0
- package/skills/ultra-chain/references/preview-lib.mjs +258 -0
- package/skills/ultra-chain/references/serve.mjs +139 -0
- package/skills/vertical-pptx/verify_a4p.mjs +10 -2
package/hooks/progress.mjs
CHANGED
|
@@ -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
|
-
|
|
52
|
-
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
105
|
-
// saw start (it loaded mid-turn) ends the step for
|
|
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,
|
|
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
|
|
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
|
|
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[
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/hooks/register.mjs
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
-
|
|
284
|
-
|
|
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++) {
|