@kaydash9999/banker-plugins 0.16.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +44 -0
  4. package/README.md +15 -10
  5. package/bin/banker.js +2 -2
  6. package/codex/manifest.json +2 -1
  7. package/codex/transform-matrix.md +2 -1
  8. package/commands/setup.md +2 -2
  9. package/hooks/progress-plan.mjs +716 -0
  10. package/hooks/progress.mjs +378 -34
  11. package/hooks/register.mjs +24 -1
  12. package/package.json +1 -1
  13. package/skills/3d-intro-build/references/assemble.mjs +10 -2
  14. package/skills/3d-intro-build/references/curate.mjs +10 -2
  15. package/skills/3d-intro-build/references/preview-lib.mjs +2 -0
  16. package/skills/3d-intro-build/references/serve.mjs +10 -2
  17. package/skills/3d-intro-build/references/verify-intro.mjs +10 -2
  18. package/skills/motion-graphic-make/references/curate.mjs +10 -2
  19. package/skills/motion-graphic-make/references/preview-lib.mjs +2 -0
  20. package/skills/motion-graphic-make/references/serve.mjs +10 -2
  21. package/skills/obsidizer/obsidize.mjs +10 -2
  22. package/skills/omc-patch/SKILL.md +96 -21
  23. package/skills/omc-patch/scripts/omc-patch.mjs +164 -62
  24. package/skills/omc-patch/scripts/probe-pm.mjs +192 -0
  25. package/skills/ultra-chain/SKILL.md +475 -0
  26. package/skills/ultra-chain/references/capture.mjs +232 -0
  27. package/skills/ultra-chain/references/chain-ledger.mjs +491 -0
  28. package/skills/ultra-chain/references/chain-report.mjs +257 -0
  29. package/skills/ultra-chain/references/preview-lib.mjs +258 -0
  30. package/skills/ultra-chain/references/serve.mjs +139 -0
  31. package/skills/vertical-pptx/verify_a4p.mjs +10 -2
@@ -0,0 +1,716 @@
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 and as a tree. 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>`, pasted text inside `<pasted_content id="…">`. Claude 5 models get no task list
8
+ * tool in Claude Code 2.1.296, so the plan a session writes (ralplan's, superpowers', ralph's PRD,
9
+ * ultragoal's goals) is what shows the work ahead.
10
+ *
11
+ * The pane shows the whole plan as a tree that folds like a toggle list: headings hold what follows
12
+ * them, list items their nested items, a table its rows, a code block its lines. The steps (the
13
+ * rules below) only count how far along the plan is.
14
+ *
15
+ * Every mod shares the engine's one hooks worker, and a plan may come from a cloned repository, so
16
+ * no step here takes more than linear time: a markdown plan is read to its first 256 Ki characters
17
+ * and each line to its first 1 Ki (a lone CR ends a line too, so no line holds one), a JSON plan to
18
+ * 512 Ki characters, a JSON plan's fields and a prompt's text are cut before they are cleaned, tags
19
+ * are found with indexOf, node names are told apart by a count, and no pattern backtracks over a line.
20
+ * What the engine will not draw (control characters, lone surrogates, private-use characters such as
21
+ * its own U+10EEEE, bidirectional overrides) never reaches the pane.
22
+ */
23
+
24
+ // ── text on the pane ─────────────────────────────────────────────────────────
25
+
26
+ // Characters a terminal draws two cells wide: Hangul, CJK, fullwidth forms, and emoji shown as
27
+ // emoji by default. Combining marks, joiners and variation selectors take none.
28
+ 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;
29
+ const NONE = /[\p{Mn}\p{Me}\u200B-\u200D\uFE0E\uFE0F]/u;
30
+ const width = (c) => (NONE.test(c) ? 0 : WIDE.test(c) ? 2 : 1);
31
+
32
+ // The cells a line of text takes on a terminal.
33
+ export const cells = (text) => [...String(text)].reduce((n, c) => n + width(c), 0);
34
+
35
+ // A line cut to `n` cells, its end an ellipsis where it was cut.
36
+ export function fit(text, n) {
37
+ if (cells(text) <= n) return text;
38
+ if (n <= 0) return '';
39
+ let out = '';
40
+ let used = 0;
41
+ for (const c of text) {
42
+ if (used + width(c) > n - 1) break;
43
+ out += c;
44
+ used += width(c);
45
+ }
46
+ return `${out}…`;
47
+ }
48
+
49
+ // Characters the engine refuses in a drawing, or that would turn the text drawn after them around:
50
+ // control characters, private-use ones (the engine keeps U+10EEEE for itself; icon fonts' glyphs go
51
+ // too), bidirectional overrides. Lone surrogates go by toWellFormed. For replace only: a global
52
+ // pattern keeps lastIndex between test() calls.
53
+ export const UNDRAWABLE = /[\p{Cc}\p{Co}\u202A-\u202E\u2066-\u2069]/gu;
54
+
55
+ // One line of plain text, cut to `n` characters, with what the engine cannot draw gone: it refuses a
56
+ // whole drawing that holds any. A long text is cut first, so cleaning it stays cheap.
57
+ export const clip = (text, n) => {
58
+ const raw = String(text ?? '').slice(0, n * 4 + 1024);
59
+ const s = (raw.toWellFormed?.() ?? raw).replace(UNDRAWABLE, ' ').replace(/\s+/g, ' ').trim();
60
+ const chars = [];
61
+ for (const c of s) {
62
+ chars.push(c);
63
+ if (chars.length > n) break;
64
+ }
65
+ return chars.length > n ? `${chars.slice(0, n - 1).join('')}…` : s;
66
+ };
67
+
68
+ // ── a prompt's text ──────────────────────────────────────────────────────────
69
+
70
+ // Past this a prompt, its tags taken out, has nothing more for a title or a description.
71
+ const PROMPT_CAP = 65536;
72
+
73
+ // Tags the harness wraps around what it adds to a prompt: they go with what they hold.
74
+ const HARNESS = ['system-reminder', 'local-command-caveat', 'local-command-stdout', 'local-command-stderr',
75
+ 'bash-stdout', 'bash-stderr', 'command-message', 'command-name', 'command-args', 'bash-input'];
76
+
77
+ // Messages the harness wraps in a tag with attributes: another session's, a teammate's.
78
+ const WRAPPER = /^\s*<(cross-session-message|teammate-message)\b[^>]*>/;
79
+
80
+ // The first `open … close` pair from `from` on, or null where there is none.
81
+ function pair(text, open, close, from = 0) {
82
+ const start = text.indexOf(open, from);
83
+ if (start < 0) return null;
84
+ const stop = text.indexOf(close, start + open.length);
85
+ return stop < 0 ? null : { start, inner: text.slice(start + open.length, stop), end: stop + close.length };
86
+ }
87
+
88
+ const inner = (text, tag) => pair(text, `<${tag}>`, `</${tag}>`)?.inner.trim() ?? '';
89
+
90
+ // The text with each pair of a harness tag taken out, with what it holds; an unclosed tag stays.
91
+ function strip(text) {
92
+ let s = text;
93
+ for (const tag of HARNESS) {
94
+ let out = '';
95
+ let at = 0;
96
+ let m = pair(s, `<${tag}>`, `</${tag}>`);
97
+ while (m) {
98
+ out += `${s.slice(at, m.start)} `;
99
+ at = m.end;
100
+ m = pair(s, `<${tag}>`, `</${tag}>`, at);
101
+ }
102
+ s = out + s.slice(at);
103
+ }
104
+ return s;
105
+ }
106
+
107
+ // What the harness puts around pasted text, an id on both of its tags (`<pasted_content id="e6a9">`
108
+ // and `</pasted_content id="e6a9">`): the tags go, the text stays. Past this many characters after
109
+ // its name a `>` closes no such tag.
110
+ const PASTED = 'pasted_content';
111
+ const TAG_ROOM = 64;
112
+
113
+ // Where the next opening or closing tag named `name` starts and ends from `from` on, or null.
114
+ function nextTag(text, name, from) {
115
+ for (let i = text.indexOf(name, from); i >= 0; i = text.indexOf(name, i + name.length)) {
116
+ const start = text[i - 1] === '<' ? i - 1 : text.startsWith('</', i - 2) ? i - 2 : -1;
117
+ const after = text[i + name.length];
118
+ const close = text.slice(i + name.length, i + name.length + TAG_ROOM).indexOf('>');
119
+ if (start >= 0 && close >= 0 && (after === '>' || after === ' ')) return { start, end: i + name.length + close + 1 };
120
+ }
121
+ return null;
122
+ }
123
+
124
+ // The text with each `<name …>` and `</name …>` tag taken out, what they held left in place.
125
+ export function untag(text, name) {
126
+ let out = '';
127
+ let at = 0;
128
+ for (let tag = nextTag(text, name, 0); tag; tag = nextTag(text, name, at)) {
129
+ out += `${text.slice(at, tag.start)} `;
130
+ at = tag.end;
131
+ }
132
+ return out + text.slice(at);
133
+ }
134
+
135
+ // The next closing tag named `name` from `from` on, or null.
136
+ function nextClosing(text, name, from) {
137
+ let tag = nextTag(text, name, from);
138
+ while (tag && text[tag.start + 1] !== '/') tag = nextTag(text, name, tag.end);
139
+ return tag;
140
+ }
141
+
142
+ // The text with each pasted block, its tags and what it holds, taken out (an unclosed one to the end):
143
+ // where the harness's own tags are looked for, so a tag that was pasted names no command.
144
+ export function withoutPastes(text) {
145
+ let out = '';
146
+ let at = 0;
147
+ for (let tag = nextTag(text, PASTED, 0); tag; tag = nextTag(text, PASTED, at)) {
148
+ out += text.slice(at, tag.start);
149
+ const close = text[tag.start + 1] === '/' ? tag : nextClosing(text, PASTED, tag.end);
150
+ at = close ? close.end : text.length;
151
+ }
152
+ return out + text.slice(at);
153
+ }
154
+
155
+ // Another session's message is a prompt from there; a teammate's joins the work at hand.
156
+ function message(text, m) {
157
+ const rest = text.slice(m[0].length);
158
+ const stop = rest.indexOf(`</${m[1]}>`);
159
+ const body = (stop < 0 ? rest : rest.slice(0, stop)).replace(/\s+/g, ' ').trim();
160
+ if (m[1] === 'teammate-message') return { kind: 'notice', text: clip(body, 200), status: 'message' };
161
+ return { kind: 'prompt', text: `[다른 세션] ${body}`.trim() };
162
+ }
163
+
164
+ // What a turn's text says: a notice (a background task's end, with its summary and status, or a
165
+ // teammate's message), or a prompt, a slash command as `/name args` and a shell command as `! command`.
166
+ export function readPrompt(raw) {
167
+ const text = String(raw ?? '');
168
+ if (/^\s*<task-notification>/.test(text)) {
169
+ return { kind: 'notice', text: clip(inner(text, 'summary'), 200), status: inner(text, 'status') };
170
+ }
171
+ const wrapped = WRAPPER.exec(text);
172
+ if (wrapped) return message(text, wrapped);
173
+ const outside = withoutPastes(text);
174
+ const command = inner(outside, 'command-name');
175
+ const shell = inner(outside, 'bash-input');
176
+ const lead = command ? `${command} ${inner(text, 'command-args')}` : shell ? `! ${shell}` : '';
177
+ return { kind: 'prompt', text: `${lead} ${strip(untag(text, PASTED))}`.slice(0, PROMPT_CAP).replace(/\s+/g, ' ').trim() };
178
+ }
179
+
180
+ // ── which files are plans ────────────────────────────────────────────────────
181
+
182
+ const PLAN_PATHS = [
183
+ /\/\.omc\/plans\/[^/]+\.md$/,
184
+ /\/docs\/superpowers\/plans\/[^/]+\.md$/,
185
+ /\/\.claude\/plans\/[^/]+\.md$/,
186
+ /\/\.omc\/(?:state\/(?:[^/]+\/)*)?prd\.json$/,
187
+ /\/\.omc\/ultragoal\/goals\.json$/,
188
+ ];
189
+ // Plan folders keep a plan's review rounds beside it (ralplan's `<plan>-critic-r2.md`,
190
+ // `architect-review-r1.md`) and OMC's open questions: a name that ends so is no plan.
191
+ const NOT_A_PLAN = [/(?:^|[-_])(?:critic|architect|review|verdict|feedback)(?:-r\d+)?\.md$/i, /^open-questions\.md$/i];
192
+
193
+ export const isPlanPath = (path) => {
194
+ const p = String(path ?? '').replaceAll('\\', '/');
195
+ const name = p.split('/').at(-1);
196
+ return PLAN_PATHS.some((re) => re.test(p)) && !NOT_A_PLAN.some((re) => re.test(name));
197
+ };
198
+
199
+ // ── a plan file ──────────────────────────────────────────────────────────────
200
+
201
+ const PLAN_CAP = 256 * 1024;
202
+ // Past this a JSON file is no plan: a PRD or a goals file is far smaller, and each item costs a step.
203
+ const JSON_CAP = 512 * 1024;
204
+ // CRLF, CR alone or LF: no line keeps a CR, so a pattern that runs to a line's end cannot stop short
205
+ // of it and backtrack.
206
+ const LINE_END = /\r\n?|\n/;
207
+ const LINE_CAP = 1024;
208
+ const MAX_PLAN_STEPS = 50;
209
+ const BOM = /^\uFEFF/;
210
+ const fileName = (path) => String(path).split(/[\\/]/).filter(Boolean).at(-1) ?? '';
211
+ // Markdown's marks off a line: a link to its text, code spans' backticks, bold's asterisks.
212
+ const plain = (line) => line.replace(/\[([^[\]]*)\]\([^()]*\)/g, '$1').replace(/\*\*|`/g, '');
213
+ const step = (title, text, status) => ({
214
+ title: clip(plain(clip(title, 400)), 60), text: clip(plain(clip(text || title, 400)), 200), status,
215
+ });
216
+
217
+ const str = (v) => (typeof v === 'string' ? v : typeof v === 'number' ? String(v) : '');
218
+ // A map's own entry: a key like `constructor` finds nothing.
219
+ export const pick = (map, key) => (Object.hasOwn(map, key) ? map[key] : undefined);
220
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
221
+ // ultragoal's states, and the ones like them; one it does not know shows no state.
222
+ const GOAL_STATUS = { complete: 'completed', completed: 'completed', in_progress: 'in_progress', active: 'in_progress',
223
+ pending: 'pending', failed: 'failed', review_blocked: 'blocked', blocked: 'blocked' };
224
+
225
+ const storyStep = (st) => {
226
+ const id = str(st.id);
227
+ return { title: str(st.title) || id, text: [id, str(st.description)].filter(Boolean).join(': '), status: st.passes === true ? 'completed' : 'pending' };
228
+ };
229
+ const goalStep = (g) => ({ title: str(g.title) || str(g.id), text: str(g.objective), status: pick(GOAL_STATUS, str(g.status)) ?? null });
230
+
231
+ // A ralph PRD (`stories` or `userStories`, done where they pass) or ultragoal's goals; items that
232
+ // are not objects are passed over.
233
+ function jsonPlan(path, text) {
234
+ if (text.length > JSON_CAP) return null;
235
+ let d;
236
+ try {
237
+ d = JSON.parse(text);
238
+ } catch {
239
+ return null;
240
+ }
241
+ if (!isObject(d)) return null;
242
+ const stories = d.stories ?? d.userStories;
243
+ if (Array.isArray(stories)) {
244
+ const items = stories.filter(isObject);
245
+ const title = str(d.title) || [str(d.project), str(d.description)].filter(Boolean).join(': ') || fileName(path);
246
+ return { title, records: items.map(storyStep), tree: jsonTree(items, storyTree) };
247
+ }
248
+ if (!Array.isArray(d.goals)) return null;
249
+ const goals = d.goals.filter(isObject);
250
+ return { title: 'ultragoal', records: goals.map(goalStep), tree: jsonTree(goals, goalTree) };
251
+ }
252
+
253
+ // The fence a line opens or closes (``` or ~~~), or ''. A run of backticks with a backtick after it
254
+ // opens inline code, not a fence (CommonMark).
255
+ function fenceMark(line) {
256
+ const m = /^ {0,3}(`{3,}|~{3,})/.exec(line);
257
+ if (!m) return '';
258
+ return m[1][0] === '`' && line.slice(m[0].length).includes('`') ? '' : m[1];
259
+ }
260
+
261
+ // The plan's lines as a reader sees them: a fenced code block's lines (to the fence that closes it,
262
+ // or the end) are blank, so they are no heading or item.
263
+ function proseLines(text) {
264
+ let fence = '';
265
+ return text.slice(0, PLAN_CAP).split(LINE_END).map((raw) => {
266
+ const line = raw.slice(0, LINE_CAP);
267
+ const mark = fenceMark(line);
268
+ if (!fence) {
269
+ fence = mark;
270
+ return mark ? '' : line;
271
+ }
272
+ if (mark[0] === fence[0] && mark.length >= fence.length && !line.slice(line.indexOf(mark) + mark.length).trim()) fence = '';
273
+ return '';
274
+ });
275
+ }
276
+
277
+ // The headings, each with where its section ends (the next heading of its level or above) and the
278
+ // headings right under it: one pass, with a stack of the sections still open.
279
+ function outline(lines) {
280
+ const heads = [];
281
+ const open = [];
282
+ lines.forEach((line, i) => {
283
+ const m = /^(#{1,6})[ \t]+([^\r\n]*)/.exec(line);
284
+ if (!m) return;
285
+ const h = { i, level: m[1].length, text: m[2].trim(), end: lines.length, subs: [] };
286
+ while (open.length && open.at(-1).level >= h.level) open.pop().end = i;
287
+ open.at(-1)?.subs.push(h);
288
+ open.push(h);
289
+ heads.push(h);
290
+ });
291
+ return heads;
292
+ }
293
+
294
+ // How far along a section's checkboxes say it is; null where it has none.
295
+ function boxStatus(lines, h) {
296
+ let all = 0;
297
+ let done = 0;
298
+ for (let i = h.i + 1; i < h.end; i++) {
299
+ const m = /^\s*[-*+][ \t]+\[([ xX])\]/.exec(lines[i]);
300
+ if (!m) continue;
301
+ all += 1;
302
+ if (m[1] !== ' ') done += 1;
303
+ }
304
+ if (!all) return null;
305
+ return done === 0 ? 'pending' : done === all ? 'completed' : 'in_progress';
306
+ }
307
+
308
+ const TASK_HEAD = /^Task\s+\d+\s*[:.]\s*/i;
309
+ const STEP_HEAD = /^(?:(?:phase|step|stage|milestone|sprint)\s*[\d.]+|단계\s*\d+|\d+(?:\.\d+)?\s*단계)/i;
310
+
311
+ // Two or more headings of one level named as steps (Phase 1, Step 2, 3단계): the shallowest such level.
312
+ function stepHeadings(heads) {
313
+ const byLevel = new Map();
314
+ for (const h of heads) {
315
+ if (!STEP_HEAD.test(h.text)) continue;
316
+ if (!byLevel.has(h.level)) byLevel.set(h.level, []);
317
+ byLevel.get(h.level).push(h);
318
+ }
319
+ const level = [...byLevel.keys()].sort((a, b) => a - b).find((l) => byLevel.get(l).length >= 2);
320
+ return byLevel.get(level) ?? [];
321
+ }
322
+
323
+ // Rules 1 and 2: superpowers' `Task N:` headings, else headings named as steps; each as far along
324
+ // as the checkboxes in its section.
325
+ function headedSteps(lines, heads) {
326
+ const tasks = heads.filter((h) => TASK_HEAD.test(h.text));
327
+ return (tasks.length ? tasks : stepHeadings(heads))
328
+ .map((h) => ({ title: h.text.replace(TASK_HEAD, ''), text: h.text, status: boxStatus(lines, h) }));
329
+ }
330
+
331
+ // Sections whose checkboxes test the work rather than lay out its steps.
332
+ const CRITERIA = /수용\s*기준|인수\s*기준|완료\s*(?:기준|조건|판정)|검증\s*기준|acceptance|definition of done|criteria/i;
333
+
334
+ // Rule 3: checkboxes at the top level of the text, outside the criteria sections.
335
+ function boxSteps(lines, heads) {
336
+ const marks = new Int32Array(lines.length + 1);
337
+ for (const h of heads) {
338
+ if (!CRITERIA.test(h.text)) continue;
339
+ marks[h.i] += 1;
340
+ marks[h.end] -= 1;
341
+ }
342
+ const out = [];
343
+ let depth = 0;
344
+ lines.forEach((line, i) => {
345
+ depth += marks[i];
346
+ const m = depth > 0 ? null : /^[-*+][ \t]+\[([ xX])\][ \t]+([^\r\n]*)/.exec(line);
347
+ if (m) out.push({ title: m[2], text: m[2], status: m[1] === ' ' ? 'pending' : 'completed' });
348
+ });
349
+ return out;
350
+ }
351
+
352
+ const STEPS_SECTION = /단계|(?:작업|집행|실행|구현|진행)\s*(?:순서|흐름|계획)|\bsteps?\b|implementation|phases?|\btasks?\b|milestones?|로드맵|road ?map/i;
353
+
354
+ // The numbered items of a section, up to `stop`.
355
+ function numbered(lines, h, stop) {
356
+ const out = [];
357
+ for (let i = h.i + 1; i < stop; i++) {
358
+ const m = /^\d+[.)][ \t]+([^\r\n]*)/.exec(lines[i]);
359
+ if (m) out.push({ title: m[1], text: m[1], status: null });
360
+ }
361
+ return out;
362
+ }
363
+
364
+ // Rule 4: the first section named for the steps: its own numbered items (those before its first
365
+ // sub-heading), else its sub-headings.
366
+ function sectionSteps(lines, heads) {
367
+ for (const h of heads) {
368
+ if (h.level === 1 || STEP_HEAD.test(h.text) || !STEPS_SECTION.test(h.text)) continue;
369
+ const items = numbered(lines, h, h.subs[0]?.i ?? h.end);
370
+ if (items.length) return items;
371
+ if (h.subs.length) return h.subs.map((x) => ({ title: x.text, text: x.text, status: boxStatus(lines, x) }));
372
+ }
373
+ return [];
374
+ }
375
+
376
+ function markdownPlan(path, text) {
377
+ const lines = proseLines(text);
378
+ const heads = outline(lines);
379
+ let records = headedSteps(lines, heads);
380
+ if (!records.length) records = boxSteps(lines, heads);
381
+ if (!records.length) records = sectionSteps(lines, heads);
382
+ const tree = planTree(text);
383
+ return { title: heads.find((h) => h.level === 1)?.text || fileName(path), records, cut: text.length > PLAN_CAP, tree };
384
+ }
385
+
386
+ // ── a plan file as a tree ────────────────────────────────────────────────────
387
+
388
+ // Past these a tree takes no more: nodes, nested list levels, the raw text a node gathers, a code
389
+ // line's characters, a code block's lines.
390
+ const MAX_NODES = 3000;
391
+ const MAX_DEPTH = 12;
392
+ const TEXT_CAP = 1000;
393
+ const CODE_CAP = 200;
394
+ const MAX_CODE_LINES = 200;
395
+ // A first line `---` opens YAML front matter only when a closing line comes within this many lines;
396
+ // else it is a rule, and the plan below it is no front matter.
397
+ const FRONT_LINES = 60;
398
+
399
+ const ITEM_LINE = /^([ \t]*)([-*+]|\d{1,9}[.)])[ \t]+([^\r\n]*)$/;
400
+ const BOX_MARK = /^\[([ xX])\][ \t]+/;
401
+ const RULE_LINE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
402
+ const TABLE_LINE = /^[ \t]*\|/;
403
+ // A table's rule row (`|---|:--:|`): dashes, colons, bars and blanks only. A test per character, so
404
+ // no pattern backtracks over the line.
405
+ const isTableRule = (line) => line.includes('-') && !/[^ \t|:-]/.test(line);
406
+ const HEAD_LINE = /^ {0,3}(#{1,6})(?:[ \t]+([^\r\n]*))?$/;
407
+ const FENCE_LINE = /^([ \t]*)(`{3,}|~{3,})([^\r\n]*)$/;
408
+
409
+ // A node's own text: a link's words, no code ticks or bold marks, one line of plain text.
410
+ const prose = (raw) => clip(plain(String(raw).slice(0, TEXT_CAP * 4)), TEXT_CAP);
411
+ const indentWidth = (lead) => lead.replace(/\t/g, ' ').length;
412
+ const treeNode = (kind, extra) => ({ kind, text: '', children: [], ...extra });
413
+
414
+ // A heading's words without the closing run of `#` that ATX allows after a space.
415
+ function headWords(raw) {
416
+ const text = String(raw ?? '').trimEnd();
417
+ let end = text.length;
418
+ while (end > 0 && text[end - 1] === '#') end -= 1;
419
+ return end === text.length || (end > 0 && text[end - 1] !== ' ' && text[end - 1] !== '\t') ? text : text.slice(0, end).trimEnd();
420
+ }
421
+
422
+ // A code line as written: its leading spaces kept, tabs as two spaces, control characters gone.
423
+ function codeText(line) {
424
+ const raw = line.slice(0, CODE_CAP * 2).replace(/\t/g, ' ');
425
+ const s = (raw.toWellFormed?.() ?? raw).replace(UNDRAWABLE, '');
426
+ return [...s].slice(0, CODE_CAP).join('') || ' ';
427
+ }
428
+
429
+ const treeState = () => ({ root: [], heads: [], items: [], open: null, table: null, code: null, count: 0, cut: false,
430
+ titled: false, front: false, comment: false });
431
+ const sectionOf = (st) => st.heads.at(-1)?.node.children ?? st.root;
432
+
433
+ // Puts a node under the deepest open list item indented less than it, else under the section; false
434
+ // once the tree holds all the nodes it takes.
435
+ function attach(st, node, indent) {
436
+ if (st.count >= MAX_NODES) {
437
+ st.cut = true;
438
+ st.open = null;
439
+ return false;
440
+ }
441
+ st.count += 1;
442
+ while (st.items.length && st.items.at(-1).indent >= indent) st.items.pop();
443
+ (st.items.at(-1)?.node.children ?? sectionOf(st)).push(node);
444
+ return true;
445
+ }
446
+
447
+ // Text that a node gathers line by line, joined once the tree is read.
448
+ function gather(node, text) {
449
+ if (node.size >= TEXT_CAP * 4) return;
450
+ node.parts.push(text);
451
+ node.size += text.length + 1;
452
+ }
453
+
454
+ function headingLine(st, line) {
455
+ const m = HEAD_LINE.exec(line);
456
+ const level = m[1].length;
457
+ const words = headWords(m[2]);
458
+ st.items = [];
459
+ st.open = null;
460
+ if (level === 1 && !st.titled && !st.count && !st.heads.length) {
461
+ st.titled = true;
462
+ return;
463
+ }
464
+ st.titled = true;
465
+ while (st.heads.length && st.heads.at(-1).level >= level) st.heads.pop();
466
+ const node = treeNode('head', { text: prose(words), level });
467
+ if (!attach(st, node, 0)) return;
468
+ st.items = [];
469
+ st.heads.push({ level, node });
470
+ }
471
+
472
+ function itemLine(st, line) {
473
+ const m = ITEM_LINE.exec(line);
474
+ let indent = indentWidth(m[1]);
475
+ let text = m[3];
476
+ const box = BOX_MARK.exec(text);
477
+ const status = box ? (box[1] === ' ' ? 'pending' : 'completed') : null;
478
+ if (box) text = text.slice(box[0].length);
479
+ const deepest = st.items.at(-1);
480
+ if (st.items.length >= MAX_DEPTH && deepest.indent < indent) indent = deepest.indent;
481
+ const node = treeNode('item', { marker: /\d/.test(m[2]) ? m[2] : status ? '' : '-', status, parts: [text], size: text.length });
482
+ if (!attach(st, node, indent)) return;
483
+ st.items.push({ indent, node });
484
+ st.open = node;
485
+ st.titled = true;
486
+ }
487
+
488
+ function textLine(st, line) {
489
+ const text = line.trim().replace(/^>[ \t]?/, '');
490
+ st.titled = true;
491
+ if (st.open) {
492
+ gather(st.open, text);
493
+ return;
494
+ }
495
+ const node = treeNode('text', { parts: [text], size: text.length });
496
+ if (attach(st, node, indentWidth(/^[ \t]*/.exec(line)[0]))) st.open = node;
497
+ }
498
+
499
+ const cellsOf = (line) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim()).join(' | ');
500
+
501
+ function tableLine(st, line) {
502
+ st.open = null;
503
+ st.titled = true;
504
+ if (!st.table) {
505
+ const head = prose(cellsOf(line));
506
+ const node = treeNode('table', { head, name: head, rows: 0 });
507
+ st.table = attach(st, node, indentWidth(/^[ \t]*/.exec(line)[0])) ? node : { rows: 0, children: [], skip: true };
508
+ return;
509
+ }
510
+ if (isTableRule(line) && !st.table.rows && !st.table.ruled) {
511
+ st.table.ruled = true;
512
+ return;
513
+ }
514
+ st.table.rows += 1;
515
+ if (!st.table.skip && st.count < MAX_NODES) {
516
+ st.count += 1;
517
+ st.table.children.push(treeNode('row', { text: prose(cellsOf(line)) }));
518
+ } else st.cut = true;
519
+ }
520
+
521
+ function finishTable(st) {
522
+ const t = st.table;
523
+ st.table = null;
524
+ if (t && !t.skip) t.text = `표: ${t.head} (${t.rows}행)`;
525
+ }
526
+
527
+ // A fence opens a code block; a backtick fence with a backtick after it is inline code (CommonMark).
528
+ function fenceOpen(st, line) {
529
+ const m = FENCE_LINE.exec(line);
530
+ if (!m || (m[2][0] === '`' && m[3].includes('`'))) return false;
531
+ st.open = null;
532
+ st.titled = true;
533
+ const info = prose(m[3].trim().split(/[ \t]/)[0] ?? '');
534
+ const node = treeNode('code', { info, name: info, lines: 0 });
535
+ st.code = { node, mark: m[2], kept: attach(st, node, indentWidth(m[1])) };
536
+ return true;
537
+ }
538
+
539
+ function codeLine(st, line) {
540
+ const { code } = st;
541
+ const close = FENCE_LINE.exec(line);
542
+ if (close && close[2][0] === code.mark[0] && close[2].length >= code.mark.length && !close[3].trim()) {
543
+ finishCode(st);
544
+ return;
545
+ }
546
+ code.node.lines += 1;
547
+ if (code.kept && st.count >= MAX_NODES) st.cut = true;
548
+ if (!code.kept || code.node.children.length >= MAX_CODE_LINES || st.count >= MAX_NODES) return;
549
+ st.count += 1;
550
+ code.node.children.push(treeNode('code-line', { text: codeText(line) }));
551
+ }
552
+
553
+ function finishCode(st) {
554
+ const { node } = st.code;
555
+ st.code = null;
556
+ const more = node.lines - node.children.length;
557
+ if (more > 0) node.children.push(treeNode('text', { text: `외 ${more}줄` }));
558
+ node.text = `코드${node.info ? ` ${node.info}` : ''} (${node.lines}줄)`;
559
+ }
560
+
561
+ // A YAML front matter block at the top, and HTML comments, are no part of the plan as it reads.
562
+ function skipped(st, line, i) {
563
+ const t = line.trim();
564
+ if (st.front) {
565
+ if (i > 0 && (t === '---' || t === '...')) st.front = false;
566
+ return true;
567
+ }
568
+ if (st.comment) {
569
+ st.comment = !line.includes('-->');
570
+ return true;
571
+ }
572
+ if (!t.startsWith('<!--')) return false;
573
+ st.comment = !t.slice(4).includes('-->');
574
+ return true;
575
+ }
576
+
577
+ const LINE_KINDS = [
578
+ [(line) => !line.trim(), (st) => { st.open = null; }],
579
+ [(line) => TABLE_LINE.test(line), tableLine],
580
+ [(line) => HEAD_LINE.test(line), headingLine],
581
+ [(line) => RULE_LINE.test(line), (st) => { st.open = null; }],
582
+ [(line) => ITEM_LINE.test(line), itemLine],
583
+ [() => true, textLine],
584
+ ];
585
+
586
+ function treeLine(st, raw, i) {
587
+ const line = raw.slice(0, LINE_CAP);
588
+ if (!st.code && skipped(st, line, i)) return;
589
+ if (st.code) {
590
+ codeLine(st, line);
591
+ return;
592
+ }
593
+ if (st.table && !TABLE_LINE.test(line)) finishTable(st);
594
+ if (fenceOpen(st, line)) return;
595
+ const kind = LINE_KINDS.find(([test]) => test(line));
596
+ kind[1](st, line);
597
+ }
598
+
599
+ // After the read: gathered text joined and cleaned, each section's checkboxes counted.
600
+ function settle(nodes) {
601
+ let all = 0;
602
+ let done = 0;
603
+ for (const n of nodes) {
604
+ if (n.parts) n.text = prose(n.parts.join(' '));
605
+ delete n.parts;
606
+ delete n.size;
607
+ const below = settle(n.children);
608
+ if (below.all && !n.status) n.count = `${below.done}/${below.all}`;
609
+ all += below.all + (n.status ? 1 : 0);
610
+ done += below.done + (n.status === 'completed' ? 1 : 0);
611
+ }
612
+ return { all, done };
613
+ }
614
+
615
+ // FNV-1a: a short name for a node's place in the plan.
616
+ function hash(s) {
617
+ let h = 0x811c9dc5;
618
+ for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193);
619
+ return (h >>> 0).toString(36);
620
+ }
621
+
622
+ // Each node's id comes from its parent's, its kind and its first words (a table's header, a code
623
+ // block's language: not their counts, which grow as they are written), and how many before it under
624
+ // the same parent share those: the same section keeps its id (and so stays open) when the plan is
625
+ // written again around it.
626
+ // Two places whose names hash alike (a plan can be written so) are told apart by how many came before:
627
+ // `<id>_1`, `<id>_2`. A hashed name holds no `_`, so the marked ones meet no other.
628
+ export function nameNodes(nodes, parent, taken, h = hash) {
629
+ const seen = new Map();
630
+ for (const n of nodes) {
631
+ const base = `${n.kind}:${String(n.name ?? n.text).slice(0, 40)}`;
632
+ const nth = (seen.get(base) ?? 0) + 1;
633
+ seen.set(base, nth);
634
+ const id = `n${h(`${parent}/${base}#${nth}`)}`;
635
+ const before = taken.get(id) ?? 0;
636
+ taken.set(id, before + 1);
637
+ n.id = before ? `${id}_${before}` : id;
638
+ nameNodes(n.children, n.id, taken, h);
639
+ }
640
+ }
641
+
642
+ // A markdown plan as a tree: `{ nodes, cut }`, each node `{ id, kind, text, children }` with
643
+ // `marker` (a list item's number or `-`), `status` (a checkbox's) or `count` (`2/5` for the
644
+ // checkboxes under it), and `cut` true where the plan held more nodes than the tree takes. A
645
+ // level-1 heading with nothing before it is the plan's title, not a node.
646
+ export function planTree(text) {
647
+ const st = treeState();
648
+ const lines = String(text).replace(BOM, '').slice(0, PLAN_CAP).split(LINE_END);
649
+ if (lines.at(-1) === '') lines.pop();
650
+ const isEnd = (l) => l.trim() === '---' || l.trim() === '...';
651
+ st.front = lines[0]?.trim() === '---' && lines.slice(1, FRONT_LINES).some(isEnd);
652
+ lines.forEach((raw, i) => treeLine(st, raw, i));
653
+ if (st.code) finishCode(st);
654
+ finishTable(st);
655
+ settle(st.root);
656
+ nameNodes(st.root, 'n', new Map());
657
+ return { nodes: st.root, cut: st.cut };
658
+ }
659
+
660
+ // ── a JSON plan as a tree ────────────────────────────────────────────────────
661
+
662
+ const textNode = (text) => treeNode('text', { text: prose(text) });
663
+
664
+ // A node with the given texts under it, those that are not empty, as far as the tree takes them.
665
+ function withTexts(node, texts, budget) {
666
+ for (const t of texts) if (t && budget.left-- > 0) node.children.push(textNode(t));
667
+ return node;
668
+ }
669
+
670
+ function storyTree(st, budget) {
671
+ const id = str(st.id);
672
+ const node = treeNode('story', { text: prose([id, str(st.title)].filter(Boolean).join(' ')), status: st.passes === true ? 'completed' : 'pending' });
673
+ withTexts(node, [str(st.description)], budget);
674
+ const criteria = Array.isArray(st.acceptanceCriteria) ? st.acceptanceCriteria.map(str).filter(Boolean) : [];
675
+ if (criteria.length && budget.left-- > 0) {
676
+ const group = treeNode('group', { text: `수용 기준 (${criteria.length})` });
677
+ for (const c of criteria) if (budget.left-- > 0) group.children.push(treeNode('item', { text: prose(c), marker: '-' }));
678
+ node.children.push(group);
679
+ }
680
+ return withTexts(node, [str(st.notes) && `메모: ${str(st.notes)}`], budget);
681
+ }
682
+
683
+ function goalTree(g, budget) {
684
+ const node = treeNode('story', { text: prose(str(g.title) || str(g.id)), status: pick(GOAL_STATUS, str(g.status)) ?? null });
685
+ return withTexts(node, [str(g.objective), str(g.evidence) && `근거: ${str(g.evidence)}`, str(g.failureReason) && `실패 이유: ${str(g.failureReason)}`], budget);
686
+ }
687
+
688
+ // A ralph PRD's stories or ultragoal's goals as a tree, the same shape as a markdown plan's.
689
+ function jsonTree(items, make) {
690
+ const budget = { left: MAX_NODES };
691
+ const nodes = [];
692
+ for (const it of items) if (budget.left-- > 0) nodes.push(make(it, budget));
693
+ nameNodes(nodes, 'n', new Map());
694
+ return { nodes, cut: budget.left < 0 };
695
+ }
696
+
697
+ // A plan file as `{ title, steps, total, done, hasStates, cut, tree, treeCut }`: its first 50 steps,
698
+ // each `{ title, text, status }` (status null where the file says none), how many it has and how many
699
+ // are done, whether the file was longer than what was read, the whole plan as a tree (planTree) and
700
+ // whether the tree left nodes out. Null for a JSON file that is no plan.
701
+ export function parsePlan(path, text) {
702
+ const body = String(text).replace(BOM, '');
703
+ const plan = /\.json$/i.test(String(path)) ? jsonPlan(path, body) : markdownPlan(path, body);
704
+ if (!plan) return null;
705
+ const { records } = plan;
706
+ return {
707
+ title: clip(plain(clip(plan.title, 400)), 60),
708
+ steps: records.slice(0, MAX_PLAN_STEPS).map((r) => step(r.title, r.text, r.status)),
709
+ total: records.length,
710
+ done: records.filter((r) => r.status === 'completed').length,
711
+ hasStates: records.some((r) => r.status),
712
+ cut: Boolean(plan.cut),
713
+ tree: plan.tree.nodes,
714
+ treeCut: plan.tree.cut,
715
+ };
716
+ }