@kaydash9999/banker-plugins 0.16.1 → 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.1"
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.1",
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,29 @@
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
+
14
+ ## [0.16.2] - 2026-10-11
15
+
16
+ ### Security
17
+ - **3d-intro 가 프로젝트 폴더에 든 파일로 실행할 프로그램을 정하지 않는다.** 0.16.1 까지는 남이 만든 저장소에서 3d-intro-build 를 돌리면, 그 저장소가 커밋해 둔 파일이 실행될 수 있었다.\
18
+ 프로젝트 키 파일(`.env.3d-intro.local`)의 `FFMPEG_PATH` 를 ffmpeg 로 실행했다. 이제 그 값은 읽지 않고 이름만 알린다. 독립 검증(`verify-intro.mjs`)은 프로젝트의 `node_modules/playwright` 를 먼저 불러왔다. 이제 `--playwright-from-project` 를 줄 때만 읽는다. Windows 에서는 `npm root -g` 를 프로젝트 폴더에서 돌려 그 폴더의 `npm.cmd` 가 먼저 실행될 수 있었다. 이제 홈 폴더에서 돌린다.
19
+ - ffmpeg 는 셸의 `FFMPEG_PATH`, 홈 설정 파일 `~/.config/banker/3d-intro/settings.env`, 예전 판이 홈 키 파일에 남긴 값, `PATH` 의 절대 경로 항목, `ffmpeg-static` 순으로 찾는다. 경로는 절대 경로만 받고, `PATH` 의 `.` 같은 상대 항목은 건너뛴다. 설정 파일에는 키를 두지 않는다.
20
+ - 키 파일에 줄바꿈이 든 값이나 변수 이름이 아닌 키를 쓰지 않는다. 0.16.1 까지는 콘솔 샘플의 여러 줄 값이 홈 키 파일에 `FFMPEG_PATH` 나 두 번째 endpoint 줄을 심을 수 있었다.
21
+ - `3d-intro-setup` 의 `ffmpeg-static` 폴백은 프로젝트가 아니라 홈의 전용 폴더(`~/.config/banker/3d-intro/tools`)에 설치한다. 프로젝트의 `.npmrc` 와 스크립트가 끼어들지 않는다. 새로 만드는 설정 폴더는 0700 이다.
22
+
23
+ ### Notes
24
+ - 예전 판이 프로젝트 키 파일에만 ffmpeg 경로를 남겼고 `PATH` 에 ffmpeg 가 없다면, 3d-intro-build 가 "ffmpeg not found" 로 멈춘다. 셸에서 `export FFMPEG_PATH=<절대 경로>` 한 뒤 `3d-intro-setup` §2 를 한 번 다시 돌리면 홈 설정 파일에 남는다.
25
+ - 프로젝트에만 playwright 를 설치했다면 독립 검증에 `--playwright-from-project` 를 준다.
26
+
3
27
  ## [0.16.1] - 2026-10-11
4
28
 
5
29
  ### 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
+ }