@kaydash9999/banker-plugins 0.16.3 → 0.17.1

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 (32) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +57 -0
  4. package/README.md +14 -11
  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 +408 -20
  10. package/hooks/progress.mjs +202 -50
  11. package/hooks/register.mjs +7 -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 +105 -22
  23. package/skills/omc-patch/scripts/omc-patch.mjs +609 -255
  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/update-banker/SKILL.md +2 -1
  32. package/skills/vertical-pptx/verify_a4p.mjs +10 -2
@@ -1,17 +1,24 @@
1
1
  /**
2
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.
3
+ * a plan file as steps and as a tree. Plain functions of strings; the pane reads the files itself.
4
4
  *
5
5
  * A turn's text comes with the harness's tags: a slash command arrives as `<command-name>` and
6
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.
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.
10
14
  *
11
15
  * Every mod shares the engine's one hooks worker, and a plan may come from a cloned repository, so
12
16
  * 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.
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.
15
22
  */
16
23
 
17
24
  // ── text on the pane ─────────────────────────────────────────────────────────
@@ -39,11 +46,17 @@ export function fit(text, n) {
39
46
  return `${out}…`;
40
47
  }
41
48
 
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.
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.
44
57
  export const clip = (text, n) => {
45
58
  const raw = String(text ?? '').slice(0, n * 4 + 1024);
46
- const s = (raw.toWellFormed?.() ?? raw).replace(/\p{Cc}+/gu, ' ').replace(/\s+/g, ' ').trim();
59
+ const s = (raw.toWellFormed?.() ?? raw).replace(UNDRAWABLE, ' ').replace(/\s+/g, ' ').trim();
47
60
  const chars = [];
48
61
  for (const c of s) {
49
62
  chars.push(c);
@@ -91,6 +104,54 @@ function strip(text) {
91
104
  return s;
92
105
  }
93
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
+
94
155
  // Another session's message is a prompt from there; a teammate's joins the work at hand.
95
156
  function message(text, m) {
96
157
  const rest = text.slice(m[0].length);
@@ -109,10 +170,11 @@ export function readPrompt(raw) {
109
170
  }
110
171
  const wrapped = WRAPPER.exec(text);
111
172
  if (wrapped) return message(text, wrapped);
112
- const command = inner(text, 'command-name');
113
- const shell = inner(text, 'bash-input');
173
+ const outside = withoutPastes(text);
174
+ const command = inner(outside, 'command-name');
175
+ const shell = inner(outside, 'bash-input');
114
176
  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() };
177
+ return { kind: 'prompt', text: `${lead} ${strip(untag(text, PASTED))}`.slice(0, PROMPT_CAP).replace(/\s+/g, ' ').trim() };
116
178
  }
117
179
 
118
180
  // ── which files are plans ────────────────────────────────────────────────────
@@ -137,6 +199,11 @@ export const isPlanPath = (path) => {
137
199
  // ── a plan file ──────────────────────────────────────────────────────────────
138
200
 
139
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/;
140
207
  const LINE_CAP = 1024;
141
208
  const MAX_PLAN_STEPS = 50;
142
209
  const BOM = /^\uFEFF/;
@@ -164,6 +231,7 @@ const goalStep = (g) => ({ title: str(g.title) || str(g.id), text: str(g.objecti
164
231
  // A ralph PRD (`stories` or `userStories`, done where they pass) or ultragoal's goals; items that
165
232
  // are not objects are passed over.
166
233
  function jsonPlan(path, text) {
234
+ if (text.length > JSON_CAP) return null;
167
235
  let d;
168
236
  try {
169
237
  d = JSON.parse(text);
@@ -172,9 +240,14 @@ function jsonPlan(path, text) {
172
240
  }
173
241
  if (!isObject(d)) return null;
174
242
  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;
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) };
178
251
  }
179
252
 
180
253
  // The fence a line opens or closes (``` or ~~~), or ''. A run of backticks with a backtick after it
@@ -189,7 +262,7 @@ function fenceMark(line) {
189
262
  // or the end) are blank, so they are no heading or item.
190
263
  function proseLines(text) {
191
264
  let fence = '';
192
- return text.slice(0, PLAN_CAP).split(/\r?\n/).map((raw) => {
265
+ return text.slice(0, PLAN_CAP).split(LINE_END).map((raw) => {
193
266
  const line = raw.slice(0, LINE_CAP);
194
267
  const mark = fenceMark(line);
195
268
  if (!fence) {
@@ -306,12 +379,325 @@ function markdownPlan(path, text) {
306
379
  let records = headedSteps(lines, heads);
307
380
  if (!records.length) records = boxSteps(lines, heads);
308
381
  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 };
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 };
310
695
  }
311
696
 
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.
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.
315
701
  export function parsePlan(path, text) {
316
702
  const body = String(text).replace(BOM, '');
317
703
  const plan = /\.json$/i.test(String(path)) ? jsonPlan(path, body) : markdownPlan(path, body);
@@ -324,5 +710,7 @@ export function parsePlan(path, text) {
324
710
  done: records.filter((r) => r.status === 'completed').length,
325
711
  hasStates: records.some((r) => r.status),
326
712
  cut: Boolean(plan.cut),
713
+ tree: plan.tree.nodes,
714
+ treeCut: plan.tree.cut,
327
715
  };
328
716
  }