@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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code skills + commands — QA, audit, docs, architecture, dependency setup, and wiki; the banker plugin marketplace.",
9
- "version": "0.16.2"
9
+ "version": "0.16.3"
10
10
  },
11
11
  "plugins": [
12
12
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "banker",
3
- "version": "0.16.2",
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` | 지금 작업이 어디까지 왔는지 보고 싶을 때 | 진행 단계 목록 패널을 켜고 끔. 단계를 누르면 설명, 화살표를 누르면 그 단계가 진행 중일 때 한 도구 호출이 펼쳐짐 (Claude Code) |
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
+ }
@@ -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
- 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
- };
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
- 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() });
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. A turn the module never
105
- // saw start (it loaded mid-turn) ends the step for the work at hand.
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, { turnId: AT_HAND, text: '지금 하는 작업' }, now);
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 going = !items.some((t) => t.status === 'in_progress') && s.turns.at(-1)?.status === 'running';
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 doing = step.status === 'in_progress' && step.activeForm ? `(${clip(step.activeForm, 40)})` : '';
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[step.status] ?? step.status}${doing}${time}`;
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
- // What the person pressed last, while it is still on the pane: a step, or a call under one.
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 item.tool ? callWords(item, now) : stepWords(item, now);
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) => 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
- ] }));
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 running = step.status === 'running' || step.status === 'in_progress';
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
- Text({ children: [STATUS[step.status] ?? step.status ?? ''], dimColor: !running, bold: running }),
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 pane's tree. `ui` is the surface's element table; `act` holds what a press does.
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
+ }
@@ -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,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.2",
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"