@kaydash9999/banker-plugins 0.16.2 → 0.16.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +4 -2
- package/hooks/progress-plan.mjs +328 -0
- package/hooks/progress.mjs +224 -32
- package/hooks/register.mjs +18 -1
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "banker",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.3",
|
|
4
4
|
"description": "Claude Code plugin: QA/audit/docs/architecture skills + commands, a /banker:setup installer for OS dependencies (playwright, etc.), and wiki compaction.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "smallOpenSource",
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.16.3] - 2026-10-12
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **`/progress` 패널에 계획이 보인다.** Claude Code 2.1.296 은 Claude 5 모델에 작업 목록 도구(TaskCreate, TaskUpdate, TodoWrite)를 주지 않아, 패널은 요청 단위 단계만 보였다.\
|
|
7
|
+
이제 세션이 쓰거나 읽은 마지막 계획 파일의 단계를 맨 위에 보인다. 읽는 파일은 `.omc/plans/*.md`, `docs/superpowers/plans/*.md`, `~/.claude/plans/*.md`, ralph `prd.json`, ultragoal `goals.json` 이다. PRD 의 `passes`, goals 의 `status`, 계획의 체크박스가 있으면 완료와 진행 중을 표시한다. 파일은 그 파일을 고친 도구 호출이 끝날 때와 턴의 시작과 끝에 다시 읽는다. 세션이 만지지 않은 파일은 보이지 않는다.
|
|
8
|
+
- 계획 문서의 단계는 다음 순서로 고른다. superpowers 의 `### Task N:` 제목, 같은 수준의 `Phase N` 이나 `N단계` 제목 묶음, 맨 위 수준의 체크박스, 이름이 단계인 구역(작업 순서, 구현 단계, Implementation Steps 등)의 번호 목록이나 하위 제목. 코드 블록 안의 줄과 수용 기준 구역의 체크박스는 단계가 아니다.
|
|
9
|
+
- 계획 폴더의 검토 문서(이름이 `-critic.md`, `-architect-r2.md`, `-review.md`, `-verdict.md` 처럼 끝나는 파일)와 `open-questions.md` 는 계획으로 보지 않는다. 하위 에이전트가 계획을 읽기만 하면 패널의 계획이 바뀌지 않는다. 단계가 없는 문서는 단계가 있는 계획을 대신하지 않는다.
|
|
10
|
+
- 슬래시 명령의 단계 제목이 `<command-message>` 태그 원문이 아니라 `/명령 인자` 로 보인다. 그 밖의 하네스 태그(`<system-reminder>` 등)도 제목과 설명에서 지운다.
|
|
11
|
+
- 백그라운드 작업이 끝났다는 알림(`<task-notification>`)과 팀원 메시지가 단계를 새로 만들지 않는다. 직전 요청의 단계 아래에 한 줄로 붙고, 그동안 그 단계를 진행 중으로 보이되 원래 결과와 걸린 시간은 그대로 둔다. 실패한 작업은 실패 행으로 보인다. 다른 세션에서 온 메시지는 `[다른 세션]` 단계가 된다.
|
|
12
|
+
- 긴 제목은 패널 폭에 맞춰 자른다(한글과 기본이 이모지인 문자는 2칸). 상태 글자가 다음 줄로 넘어가지 않는다.
|
|
13
|
+
|
|
3
14
|
## [0.16.2] - 2026-10-11
|
|
4
15
|
|
|
5
16
|
### Security
|
package/README.md
CHANGED
|
@@ -59,7 +59,7 @@ Claude Code는 `/banker:<이름>`, Codex는 `banker-<이름>` 으로 호출합
|
|
|
59
59
|
| `cleansing-memory` | 프로젝트가 장기화될 때 | 메모리 최적화 |
|
|
60
60
|
| `ready-compact` | 컨텍스트를 compact 하거나 새 세션을 대비할 때 | 이어갈 프롬프트만 출력(`/copy` 한 번이면 복사), `--hand-off` = 새 세션에서 이어가기 |
|
|
61
61
|
| `/graceful-pause` | 작업 도중 방향을 바꾸거나 끼어들고 싶을 때 | 작업 중에 입력해도 바로 전달. 실행 중인 도구 호출이 끝나면 그 단계까지만 마무리하고 멈춘 뒤 지시를 기다림 (Claude Code) |
|
|
62
|
-
| `/progress` | 지금 작업이 어디까지 왔는지 보고 싶을 때 | 진행
|
|
62
|
+
| `/progress` | 지금 작업이 어디까지 왔는지 보고 싶을 때 | 진행 상황 패널을 켜고 끔. 세션이 쓰거나 읽은 계획 파일의 단계가 위에, 요청과 그 도구 호출이 아래에 보임. 단계를 누르면 설명, 화살표를 누르면 펼쳐짐 (Claude Code) |
|
|
63
63
|
| `tone-compact` | 답변을 짧은 한글 개조식으로 받고 싶을 때 | 문체 규칙을 켜 두면 끌 때까지 모든 세션에 적용 |
|
|
64
64
|
| `omc-patch` | OMC 자동 업데이트 뒤 훅이 느려지거나 훅 프로세스가 쌓일 때 | 사라진 훅 패치를 다시 적용하고 OMC 자동 업데이트를 고정 |
|
|
65
65
|
| `remains` | 작업 뒤에 남은 버그, 하자, 미검증 항목을 한눈에 보고 싶을 때 | 남은 항목을 표로 정리하고, 등록한 테스트박스에서 시험까지 돌림 |
|
|
@@ -74,7 +74,9 @@ Claude Code는 `/banker:<이름>`, Codex는 `banker-<이름>` 으로 호출합
|
|
|
74
74
|
| `/banker:front-qa` | 스펙(note) 기반 프론트엔드 구현 + parity QA |
|
|
75
75
|
| `/banker:setup` | 구성요소·의존성 설치 오케스트레이터 (multi-select) |
|
|
76
76
|
| `/graceful-pause` | 작업 중에도 바로 실행되는 정지 요청. 지금 단계만 끝내고 멈춘 뒤 보고하고 지시를 기다림 (Claude Code 2.1.289 이상, mod. Codex 미지원) |
|
|
77
|
-
| `/progress` | 진행 상황 패널을 켜고 끔(`show` 기본 토글, `on`, `off`). 단계는 Claude 의 작업 목록, 없으면 이 세션의 요청이고, 하위 목록은 그 단계가 진행 중일 때 한 도구 호출 한 단계까지. 작업 중에도 바로 실행됨(2.1.289 이상에서 확인) (mod. Codex 미지원) |
|
|
77
|
+
| `/progress` | 진행 상황 패널을 켜고 끔(`show` 기본 토글, `on`, `off`). 맨 위는 이 세션이 쓰거나 읽은 마지막 계획 파일(`.omc/plans/*.md`, `docs/superpowers/plans/*.md`, `~/.claude/plans/*.md`, ralph `prd.json`, ultragoal `goals.json`)의 단계. 그 아래 단계는 Claude 의 작업 목록, 없으면 이 세션의 요청이고, 하위 목록은 그 단계가 진행 중일 때 한 도구 호출 한 단계까지. 작업 중에도 바로 실행됨(2.1.289 이상에서 확인) (mod. Codex 미지원) |
|
|
78
|
+
|
|
79
|
+
Claude Code 2.1.296 은 Claude 5 모델(Opus 5.5 등)에 작업 목록 도구(TaskCreate, TodoWrite)를 주지 않습니다. 그래서 `/progress` 의 단계는 보통 요청 단위이고, 계획은 계획 파일로 보입니다. 작업 목록을 쓰려면 `CLAUDE_CODE_ENABLE_TODO_TOOLS=1` 을 설정합니다.
|
|
78
80
|
|
|
79
81
|
`/graceful-pause` 와 `/progress` 는 Claude Code 의 mod(Claude Mods, 얼리 액세스 이름 function hooks, `hooks/register.mjs`)로 등록되는 명령이라 구성요소 수에 넣지 않습니다. `commands/progress.md` 도 넣지 않습니다. 이 파일은 mod 가 없는 Claude Code 에서 `/progress` 를 받아 "mod 를 지원하지않는 claude code 버전입니다" 만 보여 주는 대체 명령이고, mod 가 있으면 mod 가 대신 처리합니다.
|
|
80
82
|
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What banker's /progress pane (progress.mjs) reads from text: a prompt as the person wrote it, and
|
|
3
|
+
* a plan file as steps. Plain functions of strings; the pane reads the files itself.
|
|
4
|
+
*
|
|
5
|
+
* A turn's text comes with the harness's tags: a slash command arrives as `<command-name>` and
|
|
6
|
+
* `<command-args>`, a background task's end as `<task-notification>`, a shell command as
|
|
7
|
+
* `<bash-input>`. Claude 5 models get no task list tool in Claude Code 2.1.296, so the plan a
|
|
8
|
+
* session writes (ralplan's, superpowers', ralph's PRD, ultragoal's goals) is what shows the work
|
|
9
|
+
* ahead.
|
|
10
|
+
*
|
|
11
|
+
* Every mod shares the engine's one hooks worker, and a plan may come from a cloned repository, so
|
|
12
|
+
* no step here takes more than linear time: a markdown plan is read to its first 256 Ki characters
|
|
13
|
+
* and each line to its first 1 Ki, a JSON plan's fields and a prompt's text are cut before they are
|
|
14
|
+
* cleaned, tags are found with indexOf, and no pattern backtracks over a line.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ── text on the pane ─────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
// Characters a terminal draws two cells wide: Hangul, CJK, fullwidth forms, and emoji shown as
|
|
20
|
+
// emoji by default. Combining marks, joiners and variation selectors take none.
|
|
21
|
+
const WIDE = /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA000-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{20000}-\u{3FFFD}]|\p{Emoji_Presentation}/u;
|
|
22
|
+
const NONE = /[\p{Mn}\p{Me}\u200B-\u200D\uFE0E\uFE0F]/u;
|
|
23
|
+
const width = (c) => (NONE.test(c) ? 0 : WIDE.test(c) ? 2 : 1);
|
|
24
|
+
|
|
25
|
+
// The cells a line of text takes on a terminal.
|
|
26
|
+
export const cells = (text) => [...String(text)].reduce((n, c) => n + width(c), 0);
|
|
27
|
+
|
|
28
|
+
// A line cut to `n` cells, its end an ellipsis where it was cut.
|
|
29
|
+
export function fit(text, n) {
|
|
30
|
+
if (cells(text) <= n) return text;
|
|
31
|
+
if (n <= 0) return '';
|
|
32
|
+
let out = '';
|
|
33
|
+
let used = 0;
|
|
34
|
+
for (const c of text) {
|
|
35
|
+
if (used + width(c) > n - 1) break;
|
|
36
|
+
out += c;
|
|
37
|
+
used += width(c);
|
|
38
|
+
}
|
|
39
|
+
return `${out}…`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// One line of plain text, cut to `n` characters. Control characters and lone surrogates go too: the
|
|
43
|
+
// engine refuses a drawing that holds either. A long text is cut first, so cleaning it stays cheap.
|
|
44
|
+
export const clip = (text, n) => {
|
|
45
|
+
const raw = String(text ?? '').slice(0, n * 4 + 1024);
|
|
46
|
+
const s = (raw.toWellFormed?.() ?? raw).replace(/\p{Cc}+/gu, ' ').replace(/\s+/g, ' ').trim();
|
|
47
|
+
const chars = [];
|
|
48
|
+
for (const c of s) {
|
|
49
|
+
chars.push(c);
|
|
50
|
+
if (chars.length > n) break;
|
|
51
|
+
}
|
|
52
|
+
return chars.length > n ? `${chars.slice(0, n - 1).join('')}…` : s;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// ── a prompt's text ──────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
// Past this a prompt, its tags taken out, has nothing more for a title or a description.
|
|
58
|
+
const PROMPT_CAP = 65536;
|
|
59
|
+
|
|
60
|
+
// Tags the harness wraps around what it adds to a prompt: they go with what they hold.
|
|
61
|
+
const HARNESS = ['system-reminder', 'local-command-caveat', 'local-command-stdout', 'local-command-stderr',
|
|
62
|
+
'bash-stdout', 'bash-stderr', 'command-message', 'command-name', 'command-args', 'bash-input'];
|
|
63
|
+
|
|
64
|
+
// Messages the harness wraps in a tag with attributes: another session's, a teammate's.
|
|
65
|
+
const WRAPPER = /^\s*<(cross-session-message|teammate-message)\b[^>]*>/;
|
|
66
|
+
|
|
67
|
+
// The first `open … close` pair from `from` on, or null where there is none.
|
|
68
|
+
function pair(text, open, close, from = 0) {
|
|
69
|
+
const start = text.indexOf(open, from);
|
|
70
|
+
if (start < 0) return null;
|
|
71
|
+
const stop = text.indexOf(close, start + open.length);
|
|
72
|
+
return stop < 0 ? null : { start, inner: text.slice(start + open.length, stop), end: stop + close.length };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const inner = (text, tag) => pair(text, `<${tag}>`, `</${tag}>`)?.inner.trim() ?? '';
|
|
76
|
+
|
|
77
|
+
// The text with each pair of a harness tag taken out, with what it holds; an unclosed tag stays.
|
|
78
|
+
function strip(text) {
|
|
79
|
+
let s = text;
|
|
80
|
+
for (const tag of HARNESS) {
|
|
81
|
+
let out = '';
|
|
82
|
+
let at = 0;
|
|
83
|
+
let m = pair(s, `<${tag}>`, `</${tag}>`);
|
|
84
|
+
while (m) {
|
|
85
|
+
out += `${s.slice(at, m.start)} `;
|
|
86
|
+
at = m.end;
|
|
87
|
+
m = pair(s, `<${tag}>`, `</${tag}>`, at);
|
|
88
|
+
}
|
|
89
|
+
s = out + s.slice(at);
|
|
90
|
+
}
|
|
91
|
+
return s;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Another session's message is a prompt from there; a teammate's joins the work at hand.
|
|
95
|
+
function message(text, m) {
|
|
96
|
+
const rest = text.slice(m[0].length);
|
|
97
|
+
const stop = rest.indexOf(`</${m[1]}>`);
|
|
98
|
+
const body = (stop < 0 ? rest : rest.slice(0, stop)).replace(/\s+/g, ' ').trim();
|
|
99
|
+
if (m[1] === 'teammate-message') return { kind: 'notice', text: clip(body, 200), status: 'message' };
|
|
100
|
+
return { kind: 'prompt', text: `[다른 세션] ${body}`.trim() };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// What a turn's text says: a notice (a background task's end, with its summary and status, or a
|
|
104
|
+
// teammate's message), or a prompt, a slash command as `/name args` and a shell command as `! command`.
|
|
105
|
+
export function readPrompt(raw) {
|
|
106
|
+
const text = String(raw ?? '');
|
|
107
|
+
if (/^\s*<task-notification>/.test(text)) {
|
|
108
|
+
return { kind: 'notice', text: clip(inner(text, 'summary'), 200), status: inner(text, 'status') };
|
|
109
|
+
}
|
|
110
|
+
const wrapped = WRAPPER.exec(text);
|
|
111
|
+
if (wrapped) return message(text, wrapped);
|
|
112
|
+
const command = inner(text, 'command-name');
|
|
113
|
+
const shell = inner(text, 'bash-input');
|
|
114
|
+
const lead = command ? `${command} ${inner(text, 'command-args')}` : shell ? `! ${shell}` : '';
|
|
115
|
+
return { kind: 'prompt', text: `${lead} ${strip(text)}`.slice(0, PROMPT_CAP).replace(/\s+/g, ' ').trim() };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── which files are plans ────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
const PLAN_PATHS = [
|
|
121
|
+
/\/\.omc\/plans\/[^/]+\.md$/,
|
|
122
|
+
/\/docs\/superpowers\/plans\/[^/]+\.md$/,
|
|
123
|
+
/\/\.claude\/plans\/[^/]+\.md$/,
|
|
124
|
+
/\/\.omc\/(?:state\/(?:[^/]+\/)*)?prd\.json$/,
|
|
125
|
+
/\/\.omc\/ultragoal\/goals\.json$/,
|
|
126
|
+
];
|
|
127
|
+
// Plan folders keep a plan's review rounds beside it (ralplan's `<plan>-critic-r2.md`,
|
|
128
|
+
// `architect-review-r1.md`) and OMC's open questions: a name that ends so is no plan.
|
|
129
|
+
const NOT_A_PLAN = [/(?:^|[-_])(?:critic|architect|review|verdict|feedback)(?:-r\d+)?\.md$/i, /^open-questions\.md$/i];
|
|
130
|
+
|
|
131
|
+
export const isPlanPath = (path) => {
|
|
132
|
+
const p = String(path ?? '').replaceAll('\\', '/');
|
|
133
|
+
const name = p.split('/').at(-1);
|
|
134
|
+
return PLAN_PATHS.some((re) => re.test(p)) && !NOT_A_PLAN.some((re) => re.test(name));
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// ── a plan file ──────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
const PLAN_CAP = 256 * 1024;
|
|
140
|
+
const LINE_CAP = 1024;
|
|
141
|
+
const MAX_PLAN_STEPS = 50;
|
|
142
|
+
const BOM = /^\uFEFF/;
|
|
143
|
+
const fileName = (path) => String(path).split(/[\\/]/).filter(Boolean).at(-1) ?? '';
|
|
144
|
+
// Markdown's marks off a line: a link to its text, code spans' backticks, bold's asterisks.
|
|
145
|
+
const plain = (line) => line.replace(/\[([^[\]]*)\]\([^()]*\)/g, '$1').replace(/\*\*|`/g, '');
|
|
146
|
+
const step = (title, text, status) => ({
|
|
147
|
+
title: clip(plain(clip(title, 400)), 60), text: clip(plain(clip(text || title, 400)), 200), status,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const str = (v) => (typeof v === 'string' ? v : typeof v === 'number' ? String(v) : '');
|
|
151
|
+
// A map's own entry: a key like `constructor` finds nothing.
|
|
152
|
+
export const pick = (map, key) => (Object.hasOwn(map, key) ? map[key] : undefined);
|
|
153
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
154
|
+
// ultragoal's states, and the ones like them; one it does not know shows no state.
|
|
155
|
+
const GOAL_STATUS = { complete: 'completed', completed: 'completed', in_progress: 'in_progress', active: 'in_progress',
|
|
156
|
+
pending: 'pending', failed: 'failed', review_blocked: 'blocked', blocked: 'blocked' };
|
|
157
|
+
|
|
158
|
+
const storyStep = (st) => {
|
|
159
|
+
const id = str(st.id);
|
|
160
|
+
return { title: str(st.title) || id, text: [id, str(st.description)].filter(Boolean).join(': '), status: st.passes === true ? 'completed' : 'pending' };
|
|
161
|
+
};
|
|
162
|
+
const goalStep = (g) => ({ title: str(g.title) || str(g.id), text: str(g.objective), status: pick(GOAL_STATUS, str(g.status)) ?? null });
|
|
163
|
+
|
|
164
|
+
// A ralph PRD (`stories` or `userStories`, done where they pass) or ultragoal's goals; items that
|
|
165
|
+
// are not objects are passed over.
|
|
166
|
+
function jsonPlan(path, text) {
|
|
167
|
+
let d;
|
|
168
|
+
try {
|
|
169
|
+
d = JSON.parse(text);
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
if (!isObject(d)) return null;
|
|
174
|
+
const stories = d.stories ?? d.userStories;
|
|
175
|
+
if (Array.isArray(stories)) return { title: str(d.title) || str(d.project) || fileName(path), records: stories.filter(isObject).map(storyStep) };
|
|
176
|
+
if (Array.isArray(d.goals)) return { title: 'ultragoal', records: d.goals.filter(isObject).map(goalStep) };
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The fence a line opens or closes (``` or ~~~), or ''. A run of backticks with a backtick after it
|
|
181
|
+
// opens inline code, not a fence (CommonMark).
|
|
182
|
+
function fenceMark(line) {
|
|
183
|
+
const m = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
184
|
+
if (!m) return '';
|
|
185
|
+
return m[1][0] === '`' && line.slice(m[0].length).includes('`') ? '' : m[1];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The plan's lines as a reader sees them: a fenced code block's lines (to the fence that closes it,
|
|
189
|
+
// or the end) are blank, so they are no heading or item.
|
|
190
|
+
function proseLines(text) {
|
|
191
|
+
let fence = '';
|
|
192
|
+
return text.slice(0, PLAN_CAP).split(/\r?\n/).map((raw) => {
|
|
193
|
+
const line = raw.slice(0, LINE_CAP);
|
|
194
|
+
const mark = fenceMark(line);
|
|
195
|
+
if (!fence) {
|
|
196
|
+
fence = mark;
|
|
197
|
+
return mark ? '' : line;
|
|
198
|
+
}
|
|
199
|
+
if (mark[0] === fence[0] && mark.length >= fence.length && !line.slice(line.indexOf(mark) + mark.length).trim()) fence = '';
|
|
200
|
+
return '';
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The headings, each with where its section ends (the next heading of its level or above) and the
|
|
205
|
+
// headings right under it: one pass, with a stack of the sections still open.
|
|
206
|
+
function outline(lines) {
|
|
207
|
+
const heads = [];
|
|
208
|
+
const open = [];
|
|
209
|
+
lines.forEach((line, i) => {
|
|
210
|
+
const m = /^(#{1,6})[ \t]+([^\r\n]*)/.exec(line);
|
|
211
|
+
if (!m) return;
|
|
212
|
+
const h = { i, level: m[1].length, text: m[2].trim(), end: lines.length, subs: [] };
|
|
213
|
+
while (open.length && open.at(-1).level >= h.level) open.pop().end = i;
|
|
214
|
+
open.at(-1)?.subs.push(h);
|
|
215
|
+
open.push(h);
|
|
216
|
+
heads.push(h);
|
|
217
|
+
});
|
|
218
|
+
return heads;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// How far along a section's checkboxes say it is; null where it has none.
|
|
222
|
+
function boxStatus(lines, h) {
|
|
223
|
+
let all = 0;
|
|
224
|
+
let done = 0;
|
|
225
|
+
for (let i = h.i + 1; i < h.end; i++) {
|
|
226
|
+
const m = /^\s*[-*+][ \t]+\[([ xX])\]/.exec(lines[i]);
|
|
227
|
+
if (!m) continue;
|
|
228
|
+
all += 1;
|
|
229
|
+
if (m[1] !== ' ') done += 1;
|
|
230
|
+
}
|
|
231
|
+
if (!all) return null;
|
|
232
|
+
return done === 0 ? 'pending' : done === all ? 'completed' : 'in_progress';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const TASK_HEAD = /^Task\s+\d+\s*[:.]\s*/i;
|
|
236
|
+
const STEP_HEAD = /^(?:(?:phase|step|stage|milestone|sprint)\s*[\d.]+|단계\s*\d+|\d+(?:\.\d+)?\s*단계)/i;
|
|
237
|
+
|
|
238
|
+
// Two or more headings of one level named as steps (Phase 1, Step 2, 3단계): the shallowest such level.
|
|
239
|
+
function stepHeadings(heads) {
|
|
240
|
+
const byLevel = new Map();
|
|
241
|
+
for (const h of heads) {
|
|
242
|
+
if (!STEP_HEAD.test(h.text)) continue;
|
|
243
|
+
if (!byLevel.has(h.level)) byLevel.set(h.level, []);
|
|
244
|
+
byLevel.get(h.level).push(h);
|
|
245
|
+
}
|
|
246
|
+
const level = [...byLevel.keys()].sort((a, b) => a - b).find((l) => byLevel.get(l).length >= 2);
|
|
247
|
+
return byLevel.get(level) ?? [];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Rules 1 and 2: superpowers' `Task N:` headings, else headings named as steps; each as far along
|
|
251
|
+
// as the checkboxes in its section.
|
|
252
|
+
function headedSteps(lines, heads) {
|
|
253
|
+
const tasks = heads.filter((h) => TASK_HEAD.test(h.text));
|
|
254
|
+
return (tasks.length ? tasks : stepHeadings(heads))
|
|
255
|
+
.map((h) => ({ title: h.text.replace(TASK_HEAD, ''), text: h.text, status: boxStatus(lines, h) }));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Sections whose checkboxes test the work rather than lay out its steps.
|
|
259
|
+
const CRITERIA = /수용\s*기준|인수\s*기준|완료\s*(?:기준|조건|판정)|검증\s*기준|acceptance|definition of done|criteria/i;
|
|
260
|
+
|
|
261
|
+
// Rule 3: checkboxes at the top level of the text, outside the criteria sections.
|
|
262
|
+
function boxSteps(lines, heads) {
|
|
263
|
+
const marks = new Int32Array(lines.length + 1);
|
|
264
|
+
for (const h of heads) {
|
|
265
|
+
if (!CRITERIA.test(h.text)) continue;
|
|
266
|
+
marks[h.i] += 1;
|
|
267
|
+
marks[h.end] -= 1;
|
|
268
|
+
}
|
|
269
|
+
const out = [];
|
|
270
|
+
let depth = 0;
|
|
271
|
+
lines.forEach((line, i) => {
|
|
272
|
+
depth += marks[i];
|
|
273
|
+
const m = depth > 0 ? null : /^[-*+][ \t]+\[([ xX])\][ \t]+([^\r\n]*)/.exec(line);
|
|
274
|
+
if (m) out.push({ title: m[2], text: m[2], status: m[1] === ' ' ? 'pending' : 'completed' });
|
|
275
|
+
});
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const STEPS_SECTION = /단계|(?:작업|집행|실행|구현|진행)\s*(?:순서|흐름|계획)|\bsteps?\b|implementation|phases?|\btasks?\b|milestones?|로드맵|road ?map/i;
|
|
280
|
+
|
|
281
|
+
// The numbered items of a section, up to `stop`.
|
|
282
|
+
function numbered(lines, h, stop) {
|
|
283
|
+
const out = [];
|
|
284
|
+
for (let i = h.i + 1; i < stop; i++) {
|
|
285
|
+
const m = /^\d+[.)][ \t]+([^\r\n]*)/.exec(lines[i]);
|
|
286
|
+
if (m) out.push({ title: m[1], text: m[1], status: null });
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Rule 4: the first section named for the steps: its own numbered items (those before its first
|
|
292
|
+
// sub-heading), else its sub-headings.
|
|
293
|
+
function sectionSteps(lines, heads) {
|
|
294
|
+
for (const h of heads) {
|
|
295
|
+
if (h.level === 1 || STEP_HEAD.test(h.text) || !STEPS_SECTION.test(h.text)) continue;
|
|
296
|
+
const items = numbered(lines, h, h.subs[0]?.i ?? h.end);
|
|
297
|
+
if (items.length) return items;
|
|
298
|
+
if (h.subs.length) return h.subs.map((x) => ({ title: x.text, text: x.text, status: boxStatus(lines, x) }));
|
|
299
|
+
}
|
|
300
|
+
return [];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function markdownPlan(path, text) {
|
|
304
|
+
const lines = proseLines(text);
|
|
305
|
+
const heads = outline(lines);
|
|
306
|
+
let records = headedSteps(lines, heads);
|
|
307
|
+
if (!records.length) records = boxSteps(lines, heads);
|
|
308
|
+
if (!records.length) records = sectionSteps(lines, heads);
|
|
309
|
+
return { title: heads.find((h) => h.level === 1)?.text || fileName(path), records, cut: text.length > PLAN_CAP };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// A plan file as `{ title, steps, total, done, hasStates, cut }`: its first 50 steps, each
|
|
313
|
+
// `{ title, text, status }` (status null where the file says none), how many it has and how many
|
|
314
|
+
// are done, and whether the file was longer than what was read. Null for a JSON file that is no plan.
|
|
315
|
+
export function parsePlan(path, text) {
|
|
316
|
+
const body = String(text).replace(BOM, '');
|
|
317
|
+
const plan = /\.json$/i.test(String(path)) ? jsonPlan(path, body) : markdownPlan(path, body);
|
|
318
|
+
if (!plan) return null;
|
|
319
|
+
const { records } = plan;
|
|
320
|
+
return {
|
|
321
|
+
title: clip(plain(clip(plan.title, 400)), 60),
|
|
322
|
+
steps: records.slice(0, MAX_PLAN_STEPS).map((r) => step(r.title, r.text, r.status)),
|
|
323
|
+
total: records.length,
|
|
324
|
+
done: records.filter((r) => r.status === 'completed').length,
|
|
325
|
+
hasStates: records.some((r) => r.status),
|
|
326
|
+
cut: Boolean(plan.cut),
|
|
327
|
+
};
|
|
328
|
+
}
|
package/hooks/progress.mjs
CHANGED
|
@@ -9,11 +9,20 @@
|
|
|
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), 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.
|
|
15
|
+
* A prompt's harness tags leave its title, and a background task's notice (or a teammate's message)
|
|
16
|
+
* goes on with the last prompt's step instead of becoming one: the step runs again while it does,
|
|
17
|
+
* and keeps its own end and time.
|
|
18
|
+
*
|
|
12
19
|
* `/progress` and `/progress show` toggle the pane, `on` and `off` set it. The command is
|
|
13
20
|
* registered `immediate`, so it answers while a turn runs. An engine without mods never loads
|
|
14
21
|
* this file: commands/progress.md and hooks/progress-fallback.mjs answer there instead.
|
|
15
22
|
*/
|
|
16
23
|
|
|
24
|
+
import { cells, clip, fit, isPlanPath, parsePlan, pick, readPrompt } from './progress-plan.mjs';
|
|
25
|
+
|
|
17
26
|
const COMMAND = 'progress';
|
|
18
27
|
const PANE = 'banker-progress';
|
|
19
28
|
const TITLE = '진행 상황';
|
|
@@ -22,6 +31,9 @@ const HINT = '단계나 하위 항목을 누르면 설명이 여기에 나옵니
|
|
|
22
31
|
const MAX_STEPS = 50;
|
|
23
32
|
const MAX_KEPT_CALLS = 200;
|
|
24
33
|
const MAX_SHOWN_CALLS = 30;
|
|
34
|
+
const MAX_SHOWN_PLAN = 20;
|
|
35
|
+
// The calls whose file, when it is a plan file, becomes the plan the pane shows.
|
|
36
|
+
const PLAN_TOUCH = new Set(['Write', 'Edit', 'MultiEdit', 'Read', 'NotebookEdit']);
|
|
25
37
|
// The id of the step made for work already under way when the module loaded mid-turn.
|
|
26
38
|
const AT_HAND = 'now';
|
|
27
39
|
// Bookkeeping calls: they become steps, not calls under a step.
|
|
@@ -30,6 +42,13 @@ const STATUS = {
|
|
|
30
42
|
running: '진행 중', in_progress: '진행 중', pending: '대기', done: '완료', completed: '완료',
|
|
31
43
|
stopped: '중단됨', refused: '거절됨', failed: '오류로 끝남',
|
|
32
44
|
};
|
|
45
|
+
// A plan step's state, as progress-plan.mjs names it.
|
|
46
|
+
const PLAN_STATUS = { completed: '완료', in_progress: '진행 중', pending: '대기', failed: '실패', blocked: '막힘' };
|
|
47
|
+
// A notice's row, by the status it came with.
|
|
48
|
+
const NOTICE = {
|
|
49
|
+
completed: '백그라운드 작업 끝남', failed: '백그라운드 작업 실패', stopped: '백그라운드 작업 중지됨',
|
|
50
|
+
killed: '백그라운드 작업 중지됨', message: '팀원 메시지',
|
|
51
|
+
};
|
|
33
52
|
// How a prompt's turn ended (turn.complete's reason), as its step's state.
|
|
34
53
|
const ENDED = { answer: 'done', aborted: 'stopped', refusal: 'refused', error: 'failed' };
|
|
35
54
|
// Why a hook failed (the kind of the engine's HookFailure), for a failure that brings no message.
|
|
@@ -48,15 +67,12 @@ const tally = () => ({ calls: [], count: 0, failed: 0 });
|
|
|
48
67
|
const looseStep = () => ({ key: 'loose', title: '작업 목록 밖의 작업', ...tally() });
|
|
49
68
|
|
|
50
69
|
// `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
|
-
};
|
|
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.
|
|
73
|
+
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 });
|
|
75
|
+
|
|
60
76
|
const tail = (path) => String(path).split(/[\\/]/).filter(Boolean).slice(-2).join('/');
|
|
61
77
|
const seconds = (ms) => {
|
|
62
78
|
const all = Math.max(0, Math.round(ms / 1000));
|
|
@@ -72,14 +88,39 @@ export function callLabel(e) {
|
|
|
72
88
|
|
|
73
89
|
// ── what the module learns from the session ──────────────────────────────────
|
|
74
90
|
|
|
75
|
-
// A prompt as a step. A prompt whose end never came is done where the next one starts
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
91
|
+
// A prompt as a step. A prompt whose end never came is done where the next one starts, and so are
|
|
92
|
+
// the notices still running on a step. `notices` holds the turns of the notices running on it.
|
|
93
|
+
function addTurn(s, turnId, text, now, title) {
|
|
94
|
+
for (const t of s.turns) {
|
|
95
|
+
if (t.status === 'running') Object.assign(t, { status: 'done', endedAt: now });
|
|
96
|
+
t.notices.clear();
|
|
97
|
+
}
|
|
98
|
+
s.turns.push({ key: `t-${turnId}`, id: turnId, title: title ?? (clip(text, 60) || '(이어서 진행)'), text,
|
|
99
|
+
startedAt: now, endedAt: null, status: 'running', notices: new Set(), ...tally() });
|
|
80
100
|
if (s.turns.length > MAX_STEPS) s.turns.shift();
|
|
81
101
|
}
|
|
82
102
|
|
|
103
|
+
// A step's state on the pane: a prompt's step runs again while a notice runs on it.
|
|
104
|
+
const statusOf = (step) => (step.notices?.size ? 'running' : step.status);
|
|
105
|
+
|
|
106
|
+
// A row under a step that is no tool call: it counts as none. `ok` false shows it failed.
|
|
107
|
+
function note(s, step, label, ok, now) {
|
|
108
|
+
step.calls.push({ key: `c-${++s.seq}`, tool: '', label: clip(label, 60), detail: clip(label, 240), isNote: true,
|
|
109
|
+
startedAt: now, endedAt: now, ok, steps: [step] });
|
|
110
|
+
if (step.calls.length > MAX_KEPT_CALLS) step.calls.shift();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// A background task's end (or a teammate's message) opens a turn of its own: it runs on the last
|
|
114
|
+
// prompt's step, under which a row says so, and the list stays as it is. Before any prompt it is a
|
|
115
|
+
// step of its own.
|
|
116
|
+
function noticeTurn(s, e, said, now) {
|
|
117
|
+
const word = pick(NOTICE, said.status) ?? '백그라운드 작업 알림';
|
|
118
|
+
if (!s.turns.length) addTurn(s, e.turnId, '', now, said.status === 'message' ? word : '백그라운드 작업 알림');
|
|
119
|
+
const t = s.turns.at(-1);
|
|
120
|
+
if (t.id !== e.turnId) t.notices.add(e.turnId);
|
|
121
|
+
if (said.text) note(s, t, `${word}: ${said.text}`, said.status !== 'failed', now);
|
|
122
|
+
}
|
|
123
|
+
|
|
83
124
|
// The task list's items, which are the steps while there are any: TodoWrite's, else the Tasks list's.
|
|
84
125
|
function listItems(s) {
|
|
85
126
|
return s.todos?.length ? s.todos : [...s.tasks.values()];
|
|
@@ -97,14 +138,22 @@ function dropFinished(s) {
|
|
|
97
138
|
}
|
|
98
139
|
|
|
99
140
|
export function startTurn(s, e, now) {
|
|
141
|
+
const said = readPrompt(e.text);
|
|
142
|
+
if (said.kind === 'notice') return noticeTurn(s, e, said, now);
|
|
100
143
|
dropFinished(s);
|
|
101
|
-
addTurn(s, e, now);
|
|
144
|
+
addTurn(s, e.turnId, said.text, now);
|
|
102
145
|
}
|
|
103
146
|
|
|
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
|
|
147
|
+
// A main turn's end; a subagent's (it carries an agentId) ends no step, and a notice's leaves its
|
|
148
|
+
// step's own end as it was. A turn the module never saw start (it loaded mid-turn) ends the step for
|
|
149
|
+
// the work at hand.
|
|
106
150
|
export function endTurn(s, e, now) {
|
|
107
151
|
if (e.agentId) return;
|
|
152
|
+
const host = s.turns.find((x) => x.notices.has(e.turnId));
|
|
153
|
+
if (host) {
|
|
154
|
+
host.notices.delete(e.turnId);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
108
157
|
const t = s.turns.find((x) => x.id === e.turnId) ?? s.turns.find((x) => x.id === AT_HAND && x.status === 'running');
|
|
109
158
|
if (!t) return;
|
|
110
159
|
t.status = ENDED[e.reason] ?? 'failed';
|
|
@@ -118,7 +167,7 @@ function owners(s, now) {
|
|
|
118
167
|
const items = listItems(s);
|
|
119
168
|
const task = [...items].reverse().find((t) => t.status === 'in_progress');
|
|
120
169
|
const first = task ?? (items.length ? s.loose : null);
|
|
121
|
-
if (!s.turns.length) addTurn(s,
|
|
170
|
+
if (!s.turns.length) addTurn(s, AT_HAND, '지금 하는 작업', now);
|
|
122
171
|
return first ? [first, s.turns.at(-1)] : [s.turns.at(-1)];
|
|
123
172
|
}
|
|
124
173
|
|
|
@@ -249,6 +298,50 @@ export function noteTaskTool(s, e, r, now, since) {
|
|
|
249
298
|
if (CHANGES.has(e.tool)) s.rev += 1;
|
|
250
299
|
}
|
|
251
300
|
|
|
301
|
+
// The plan file a call touched, with the touch's count, when it is one and the call went through:
|
|
302
|
+
// a write by anyone, a read by the session itself (an agent reviewing a plan does not make it the plan).
|
|
303
|
+
export function planTouched(s, e, call) {
|
|
304
|
+
const path = e.file_path ?? e.notebook_path;
|
|
305
|
+
if (!call?.ok || !PLAN_TOUCH.has(e.tool) || !isPlanPath(path) || (e.tool === 'Read' && e.agentId)) return null;
|
|
306
|
+
s.touch += 1;
|
|
307
|
+
return { path, touch: s.touch };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function parsed(path, text) {
|
|
311
|
+
try {
|
|
312
|
+
return typeof text === 'string' ? parsePlan(path, text) : null;
|
|
313
|
+
} catch {
|
|
314
|
+
return null; /* a plan the parser trips on leaves the pane as it was */
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Whether a read may change the plan: a read for a touch newer than the one the plan came from, or a
|
|
319
|
+
// read again of the plan's own file (begun when `since` touches were counted) that no touch taken
|
|
320
|
+
// in the meantime overtook.
|
|
321
|
+
function current(s, path, { touch, since }) {
|
|
322
|
+
if (touch !== undefined) return touch > s.taken;
|
|
323
|
+
return path === s.planPath && s.taken <= since;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const isPlanKey = (key) => key === 'plan' || /^p-\d+$/.test(key ?? '');
|
|
327
|
+
|
|
328
|
+
// 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));
|
|
331
|
+
|
|
332
|
+
// 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.
|
|
334
|
+
export function takePlan(s, path, text, read = {}) {
|
|
335
|
+
if (!current(s, path, read)) return;
|
|
336
|
+
const plan = parsed(path, text);
|
|
337
|
+
if (keepsOff(s, path, plan)) return;
|
|
338
|
+
const moving = path !== s.planPath;
|
|
339
|
+
if (read.touch !== undefined) s.taken = read.touch;
|
|
340
|
+
if (moving && isPlanKey(s.selected)) s.selected = null;
|
|
341
|
+
s.planPath = path;
|
|
342
|
+
s.plan = { ...plan, path, steps: plan.steps.map((st, i) => ({ ...st, key: `p-${i}`, planStep: true })) };
|
|
343
|
+
}
|
|
344
|
+
|
|
252
345
|
// ── what the pane shows ──────────────────────────────────────────────────────
|
|
253
346
|
|
|
254
347
|
// Nothing seen yet: one step that says so, with no state word beside it.
|
|
@@ -257,7 +350,8 @@ const IDLE = { key: 'idle', title: '진행 중인 작업 없음', status: null,
|
|
|
257
350
|
// The calls outside the list are in progress while calls still go there: no task is in progress
|
|
258
351
|
// and the prompt runs.
|
|
259
352
|
function looseStatus(s, items) {
|
|
260
|
-
const
|
|
353
|
+
const last = s.turns.at(-1);
|
|
354
|
+
const going = !items.some((t) => t.status === 'in_progress') && last !== undefined && statusOf(last) === 'running';
|
|
261
355
|
return going ? 'running' : 'done';
|
|
262
356
|
}
|
|
263
357
|
|
|
@@ -272,9 +366,10 @@ function callCount(step) {
|
|
|
272
366
|
}
|
|
273
367
|
|
|
274
368
|
function stepState(step, now) {
|
|
275
|
-
const
|
|
369
|
+
const status = statusOf(step);
|
|
370
|
+
const doing = status === 'in_progress' && step.activeForm ? `(${clip(step.activeForm, 40)})` : '';
|
|
276
371
|
const time = Number.isFinite(step.startedAt) ? `, ${seconds((step.endedAt ?? now) - step.startedAt)}` : '';
|
|
277
|
-
return `${STATUS[
|
|
372
|
+
return `${STATUS[status] ?? status}${doing}${time}`;
|
|
278
373
|
}
|
|
279
374
|
|
|
280
375
|
function stepWords(step, now) {
|
|
@@ -285,54 +380,127 @@ function stepWords(step, now) {
|
|
|
285
380
|
}
|
|
286
381
|
|
|
287
382
|
function callWords(call, now) {
|
|
383
|
+
if (call.isNote) return `${call.detail.replace(/[.!?。]+$/, '')}.`;
|
|
288
384
|
const state = call.ok === null ? '실행 중' : call.ok ? '성공' : '실패';
|
|
289
385
|
const who = call.isAgent ? '하위 에이전트의 ' : '';
|
|
290
386
|
return `${who}${call.tool}${call.detail ? `: ${call.detail}` : ''}. ${state}, ${seconds((call.endedAt ?? now) - call.startedAt)}.`;
|
|
291
387
|
}
|
|
292
388
|
|
|
293
|
-
|
|
389
|
+
function planWords(plan) {
|
|
390
|
+
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
|
+
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}.`;
|
|
400
|
+
}
|
|
401
|
+
|
|
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.
|
|
294
404
|
function pressed(s) {
|
|
405
|
+
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;
|
|
295
408
|
const all = steps(s);
|
|
296
409
|
return all.find((x) => x.key === s.selected) ?? all.flatMap((x) => x.calls).find((c) => c.key === s.selected);
|
|
297
410
|
}
|
|
298
411
|
|
|
412
|
+
function itemWords(s, item, now) {
|
|
413
|
+
if (item.isPlan) return planWords(s.plan);
|
|
414
|
+
if (item.planStep) return planStepWords(item);
|
|
415
|
+
return item.tool !== undefined ? callWords(item, now) : stepWords(item, now);
|
|
416
|
+
}
|
|
417
|
+
|
|
299
418
|
// The description under the list: the pressed step's or call's, or how to get one.
|
|
300
419
|
export function describe(s, now) {
|
|
301
420
|
const item = pressed(s);
|
|
302
|
-
if (item) return
|
|
421
|
+
if (item) return itemWords(s, item, now);
|
|
303
422
|
const all = steps(s);
|
|
304
423
|
return all.length === 1 && all[0].key === 'idle' ? stepWords(IDLE, now) : HINT;
|
|
305
424
|
}
|
|
306
425
|
|
|
426
|
+
// A label cut so that it, the cells before it and the words after it fit on one row of the pane.
|
|
427
|
+
const room = (label, act, before, after) => fit(label, (act.cols ?? Infinity) - before - (after ? cells(after) + 1 : 0));
|
|
428
|
+
|
|
307
429
|
function callRows(ui, step, act) {
|
|
308
430
|
const { Box, Text, Button } = ui;
|
|
309
431
|
const shown = step.calls.slice(-MAX_SHOWN_CALLS);
|
|
310
|
-
const rows = shown.map((c) =>
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
432
|
+
const rows = shown.map((c) => {
|
|
433
|
+
const state = c.ok === null ? '실행 중' : c.ok ? '' : '실패';
|
|
434
|
+
return Box({ key: `row-${c.key}`, flexDirection: 'row', columnGap: 1, paddingLeft: 4, children: [
|
|
435
|
+
Button({ key: c.key, label: room(c.label, act, 4, state), plain: true, dimColor: c.ok !== false, onPress: () => act.select(c.key) }),
|
|
436
|
+
...stateWord(ui, state, false),
|
|
437
|
+
] });
|
|
438
|
+
});
|
|
314
439
|
const hidden = step.calls.length - shown.length;
|
|
315
440
|
return hidden ? [Box({ paddingLeft: 4, children: [Text({ children: [`앞의 ${hidden}개는 줄였습니다`], dimColor: true })] }), ...rows] : rows;
|
|
316
441
|
}
|
|
317
442
|
|
|
443
|
+
// A row's state word, bold while it runs; none where the state is empty, so no gap is kept for it.
|
|
444
|
+
function stateWord(ui, state, running) {
|
|
445
|
+
return state ? [ui.Text({ children: [state], dimColor: !running, bold: running })] : [];
|
|
446
|
+
}
|
|
447
|
+
|
|
318
448
|
function stepRow(ui, s, step, act) {
|
|
319
449
|
const { Box, Text, Button } = ui;
|
|
320
450
|
const isOpen = s.open.has(step.key);
|
|
321
451
|
const arrow = step.calls.length
|
|
322
452
|
? Button({ key: `arrow-${step.key}`, label: isOpen ? '▾' : '▸', plain: true, onPress: () => act.fold(step.key) })
|
|
323
453
|
: Text({ children: [' '] });
|
|
324
|
-
const
|
|
454
|
+
const status = statusOf(step);
|
|
455
|
+
const state = STATUS[status] ?? status ?? '';
|
|
325
456
|
return Box({ key: `row-${step.key}`, flexDirection: 'row', columnGap: 1, children: [
|
|
326
457
|
arrow,
|
|
327
|
-
Button({ key: step.key, label: step.title, plain: true, onPress: () => act.select(step.key) }),
|
|
328
|
-
|
|
458
|
+
Button({ key: step.key, label: room(step.title, act, 2, state), plain: true, onPress: () => act.select(step.key) }),
|
|
459
|
+
...stateWord(ui, state, status === 'running' || status === 'in_progress'),
|
|
460
|
+
] });
|
|
461
|
+
}
|
|
462
|
+
|
|
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.
|
|
465
|
+
function planHead(ui, s, act) {
|
|
466
|
+
const { Box, Text, Button } = ui;
|
|
467
|
+
const { plan } = s;
|
|
468
|
+
const count = !plan.total ? '단계 없음' : plan.hasStates ? `${plan.done}/${plan.total} 완료` : `${plan.total}단계`;
|
|
469
|
+
const arrow = plan.steps.length
|
|
470
|
+
? Button({ key: 'arrow-plan', label: s.planShut ? '▸' : '▾', plain: true, onPress: () => act.foldPlan() })
|
|
471
|
+
: Text({ children: [' '] });
|
|
472
|
+
const label = room(`계획: ${plan.title}`, act, 2, count);
|
|
473
|
+
return Box({ key: 'row-plan', flexDirection: 'row', columnGap: 1, children: [
|
|
474
|
+
arrow,
|
|
475
|
+
Button({ key: 'plan', label, plain: true, onPress: () => act.select('plan'), children: [Text({ bold: true, children: [label] })] }),
|
|
476
|
+
Text({ children: [count], dimColor: true }),
|
|
329
477
|
] });
|
|
330
478
|
}
|
|
331
479
|
|
|
332
|
-
// The
|
|
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) {
|
|
482
|
+
const { Box, Text, Button } = ui;
|
|
483
|
+
if (!s.plan) return [];
|
|
484
|
+
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
|
+
] }));
|
|
493
|
+
}
|
|
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
|
+
return [...rows, Box({ key: 'plan-gap', marginBottom: 1 })];
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// The pane's tree. `ui` is the surface's element table; `act` holds what a press does and `cols`,
|
|
500
|
+
// the cells across the pane's body (Infinity where the engine gives none).
|
|
333
501
|
export function draw(ui, s, now, act) {
|
|
334
502
|
const { Box, Text } = ui;
|
|
335
|
-
const rows =
|
|
503
|
+
const rows = planRows(ui, s, act);
|
|
336
504
|
for (const step of steps(s)) {
|
|
337
505
|
rows.push(stepRow(ui, s, step, act));
|
|
338
506
|
if (s.open.has(step.key)) rows.push(...callRows(ui, step, act));
|
|
@@ -385,10 +553,21 @@ async function watchCall($, e, next) {
|
|
|
385
553
|
} finally {
|
|
386
554
|
endCall(call, r, Date.now());
|
|
387
555
|
if (TASK_TOOLS.has(e.tool)) noteTaskTool(s, e, r, Date.now(), since);
|
|
556
|
+
const touched = planTouched(s, e, call);
|
|
557
|
+
if (touched) takePlan(s, touched.path, await readText($, touched.path), { touch: touched.touch });
|
|
388
558
|
$.ui.invalidate('ui.render');
|
|
389
559
|
}
|
|
390
560
|
}
|
|
391
561
|
|
|
562
|
+
// A file's text, or null where it cannot be read (missing, over the engine's 4 MiB, refused).
|
|
563
|
+
async function readText($, path) {
|
|
564
|
+
try {
|
|
565
|
+
return await $.fs.read(path);
|
|
566
|
+
} catch {
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
392
571
|
// The session's progress, one per loaded module.
|
|
393
572
|
const ref = { s: freshState() };
|
|
394
573
|
|
|
@@ -405,9 +584,12 @@ export function registerProgress(on) {
|
|
|
405
584
|
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
|
|
406
585
|
if (e.requestId !== PANE) return next(e);
|
|
407
586
|
const redraw = () => $.ui.invalidate('ui.render');
|
|
587
|
+
const cols = Number.isFinite(e.props?.bodyColumns) ? e.props.bodyColumns : Infinity;
|
|
408
588
|
const act = {
|
|
589
|
+
cols,
|
|
409
590
|
select: (key) => { ref.s.selected = ref.s.selected === key ? null : key; redraw(); },
|
|
410
591
|
fold: (key) => { if (!ref.s.open.delete(key)) ref.s.open.add(key); redraw(); },
|
|
592
|
+
foldPlan: () => { ref.s.planShut = !ref.s.planShut; redraw(); },
|
|
411
593
|
};
|
|
412
594
|
return draw($.ui.resolve(e), ref.s, Date.now(), act);
|
|
413
595
|
});
|
|
@@ -425,3 +607,13 @@ export function progressTurnStarted(e) {
|
|
|
425
607
|
export function progressTurnCompleted(e) {
|
|
426
608
|
endTurn(ref.s, e, Date.now());
|
|
427
609
|
}
|
|
610
|
+
|
|
611
|
+
// The plan file to read again as a turn starts and ends (register.mjs reads it), with the touches
|
|
612
|
+
// counted when the read begins; null without a plan.
|
|
613
|
+
export function progressPlanTarget() {
|
|
614
|
+
return ref.s.planPath ? { path: ref.s.planPath, since: ref.s.touch } : null;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export function progressPlanRead(target, text) {
|
|
618
|
+
takePlan(ref.s, target.path, text, { since: target.since });
|
|
619
|
+
}
|
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,20 @@ 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.
|
|
51
|
+
async function rereadPlan($) {
|
|
52
|
+
const target = progressPlanTarget();
|
|
53
|
+
if (!target) return;
|
|
54
|
+
let text = null;
|
|
55
|
+
try {
|
|
56
|
+
text = await $.fs.read(target.path);
|
|
57
|
+
} catch {
|
|
58
|
+
/* missing or refused: the pane keeps the plan it read last */
|
|
59
|
+
}
|
|
60
|
+
progressPlanRead(target, text);
|
|
61
|
+
}
|
|
62
|
+
|
|
48
63
|
// A /graceful-pause request that came after its turn's last model request: a note voids it.
|
|
49
64
|
async function voidLateRequest($, late) {
|
|
50
65
|
try {
|
|
@@ -75,6 +90,7 @@ export const register = (on) => {
|
|
|
75
90
|
on('turn.start', async ($, e, next) => {
|
|
76
91
|
pauseTurnStarted(e);
|
|
77
92
|
progressTurnStarted(e);
|
|
93
|
+
await rereadPlan($);
|
|
78
94
|
$.ui.invalidate('ui.render');
|
|
79
95
|
return next(e);
|
|
80
96
|
});
|
|
@@ -83,6 +99,7 @@ export const register = (on) => {
|
|
|
83
99
|
const late = pauseTurnCompleted(e);
|
|
84
100
|
if (late) await voidLateRequest($, late);
|
|
85
101
|
progressTurnCompleted(e);
|
|
102
|
+
await rereadPlan($);
|
|
86
103
|
$.ui.invalidate('ui.render');
|
|
87
104
|
return next(e);
|
|
88
105
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaydash9999/banker-plugins",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.3",
|
|
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"
|