portable-agent-layer 0.32.0 → 0.33.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 (35) hide show
  1. package/assets/skills/presentation/SKILL.md +124 -5
  2. package/assets/skills/presentation/WORKSHOP.md +128 -0
  3. package/assets/skills/presentation/theme-base/base.css +113 -0
  4. package/assets/skills/presentation/theme-base/layouts.css +11 -2
  5. package/assets/skills/presentation/tools/build.ts +136 -6
  6. package/assets/skills/presentation/tools/doctor.ts +106 -317
  7. package/assets/skills/presentation/tools/lib/lint-helpers.ts +150 -0
  8. package/assets/skills/presentation/tools/lib/lint-rules.ts +744 -0
  9. package/assets/skills/presentation/tools/lib/lint-types.ts +40 -0
  10. package/assets/skills/presentation/tools/new-deck.ts +9 -4
  11. package/assets/skills/presentation/vendor/reveal/plugin/highlight/github-dark.css +118 -0
  12. package/assets/skills/projects/SKILL.md +111 -0
  13. package/assets/skills/telos/SKILL.md +4 -1
  14. package/assets/templates/AGENTS.md.template +28 -7
  15. package/assets/templates/PAL/ALGORITHM.md +2 -0
  16. package/assets/templates/PAL/README.md +0 -1
  17. package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +1 -1
  18. package/assets/templates/pal-settings.json +2 -2
  19. package/package.json +1 -1
  20. package/src/hooks/UserPromptOrchestrator.ts +3 -1
  21. package/src/hooks/handlers/auto-graduate.ts +169 -0
  22. package/src/hooks/handlers/inject-retrieval.ts +50 -0
  23. package/src/hooks/handlers/project-touch.ts +39 -0
  24. package/src/hooks/lib/context.ts +9 -8
  25. package/src/hooks/lib/paths.ts +2 -0
  26. package/src/hooks/lib/projects.ts +270 -0
  27. package/src/hooks/lib/retrieval-index.ts +223 -0
  28. package/src/hooks/lib/retrieval.ts +170 -0
  29. package/src/hooks/lib/security.ts +2 -0
  30. package/src/hooks/lib/stop.ts +9 -1
  31. package/src/hooks/lib/text-similarity.ts +13 -9
  32. package/src/hooks/lib/wisdom.ts +155 -1
  33. package/src/tools/agent/project.ts +336 -0
  34. package/src/tools/self-model.ts +3 -3
  35. package/assets/templates/PAL/CONTEXT_ROUTING.md +0 -30
@@ -4,34 +4,33 @@
4
4
  // Usage:
5
5
  // bun doctor.ts <deck-dir> [--strict]
6
6
  //
7
- // Reads slides/*.md (or legacy content.md), runs layout-aware lint rules,
8
- // prints per-slide findings + a summary, and exits 0 (clean) or 1 (errors).
9
- // --strict promotes warnings to errors.
7
+ // Reads slides/*.md (or legacy content.md), runs slide-scope and deck-scope
8
+ // rules from `lib/lint-rules.ts`, prints per-slide findings + a summary,
9
+ // and exits 0 (clean) or 1 (errors). --strict promotes warnings to errors.
10
10
  //
11
- // Rules are heuristic — thresholds documented in SKILL.md. Doctor is a
11
+ // Rules are heuristic — thresholds documented in SKILL.md. The doctor is a
12
12
  // safety-net, not a style guide; intentionally permissive.
13
13
 
14
- import { constants as fsConst } from "node:fs";
15
- import { access, readdir } from "node:fs/promises";
14
+ import { readdir } from "node:fs/promises";
16
15
  import { basename, join, resolve } from "node:path";
17
16
  import { readText } from "./lib/inline";
18
-
19
- export type Severity = "E" | "W";
20
- export type Finding = { rule: string; severity: Severity; msg: string };
21
- export type SlideReport = { name: string; layout: string; findings: Finding[] };
22
-
23
- async function exists(p: string): Promise<boolean> {
24
- try {
25
- await access(p, fsConst.F_OK);
26
- return true;
27
- } catch {
28
- return false;
29
- }
30
- }
17
+ import {
18
+ countAtxHeading,
19
+ extractLayout,
20
+ fileExists,
21
+ stripNotes,
22
+ } from "./lib/lint-helpers";
23
+ import { RULES } from "./lib/lint-rules";
24
+ import type { DeckContext, Finding, SlideContext, SlideReport } from "./lib/lint-types";
25
+
26
+ // Re-export for backward compatibility with external callers (tests, etc.)
27
+ // that imported these directly from doctor.ts.
28
+ export { extractLayout } from "./lib/lint-helpers";
29
+ export type { DeckContext, Finding, SlideContext, SlideReport } from "./lib/lint-types";
31
30
 
32
31
  async function loadSlides(deckDir: string): Promise<{ name: string; body: string }[]> {
33
32
  const slidesDir = join(deckDir, "slides");
34
- if (await exists(slidesDir)) {
33
+ if (await fileExists(slidesDir)) {
35
34
  const files = (await readdir(slidesDir)).filter((f) => f.endsWith(".md")).sort();
36
35
  if (files.length === 0) throw new Error(`slides/ is empty at ${slidesDir}`);
37
36
  return Promise.all(
@@ -39,313 +38,111 @@ async function loadSlides(deckDir: string): Promise<{ name: string; body: string
39
38
  );
40
39
  }
41
40
  const legacy = join(deckDir, "content.md");
42
- if (await exists(legacy)) {
41
+ if (await fileExists(legacy)) {
43
42
  const raw = await readText(legacy);
44
43
  return raw.split(/^---$/m).map((body, i) => ({ name: `slide-${i + 1}`, body }));
45
44
  }
46
45
  throw new Error(`no slides/ directory or content.md found in ${deckDir}`);
47
46
  }
48
47
 
49
- export function extractLayout(body: string): string {
50
- const m = /<!--\s*\.slide:\s*data-layout="([^"]+)"\s*-->/i.exec(body);
51
- return m ? m[1] : "content";
52
- }
53
-
54
- function stripNotes(body: string): string {
55
- // Remove speaker notes — every line from `Note:` onward at line start.
56
- const lines = body.split("\n");
57
- const cut = lines.findIndex((l) => /^Note:/i.test(l.trim()));
58
- return cut === -1 ? body : lines.slice(0, cut).join("\n");
59
- }
60
-
61
- function countAtxHeading(body: string, level: 1 | 2): string[] {
62
- const re = new RegExp(`^#{${level}}\\s+(.+?)\\s*$`, "gm");
63
- return Array.from(body.matchAll(re), (m) => m[1]);
48
+ function buildSlideContext(
49
+ slide: { name: string; body: string },
50
+ index: number,
51
+ deckDir: string
52
+ ): SlideContext {
53
+ const body = slide.body;
54
+ const bodyNoNotes = stripNotes(body);
55
+ return {
56
+ name: slide.name,
57
+ body,
58
+ bodyNoNotes,
59
+ layout: extractLayout(body),
60
+ deckDir,
61
+ heads1: countAtxHeading(bodyNoNotes, 1),
62
+ heads2: countAtxHeading(bodyNoNotes, 2),
63
+ index,
64
+ };
64
65
  }
65
66
 
66
- function countTopLevelListItems(body: string): number {
67
- // Count lines starting with `- `, `* `, or `N. ` at column 0 (no leading indent).
68
- let n = 0;
69
- for (const line of body.split("\n")) {
70
- if (/^(?:[-*]\s+|\d+\.\s+)/.test(line)) n++;
67
+ export async function lintDeck(deckDir: string): Promise<{
68
+ slides: SlideContext[];
69
+ reports: SlideReport[];
70
+ deckFindings: Finding[];
71
+ }> {
72
+ const raw = await loadSlides(deckDir);
73
+ const slides = raw.map((s, i) => buildSlideContext(s, i, deckDir));
74
+ const deckCtx: DeckContext = { deckDir, slides };
75
+
76
+ // Slide-scope rules
77
+ const reports: SlideReport[] = [];
78
+ for (const ctx of slides) {
79
+ const findings: Finding[] = [];
80
+ for (const rule of RULES) {
81
+ if (rule.scope !== "slide") continue;
82
+ if (rule.appliesTo && !rule.appliesTo(ctx)) continue;
83
+ findings.push(...(await rule.check(ctx)));
84
+ }
85
+ reports.push({ name: ctx.name, layout: ctx.layout, findings });
71
86
  }
72
- return n;
73
- }
74
87
 
75
- function findImageRefs(body: string): string[] {
76
- // Skip lines inside fenced code blocks — they're examples, not references.
77
- const out: string[] = [];
78
- const lines = body.split("\n");
79
- let inFence = false;
80
- for (const line of lines) {
81
- if (/^```/.test(line)) {
82
- inFence = !inFence;
83
- continue;
84
- }
85
- if (inFence) continue;
86
- for (const m of line.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) {
87
- const ref = m[1].trim();
88
- if (!/^(https?:|data:)/i.test(ref)) out.push(ref);
89
- }
88
+ // Deck-scope rules
89
+ const deckFindings: Finding[] = [];
90
+ for (const rule of RULES) {
91
+ if (rule.scope !== "deck") continue;
92
+ deckFindings.push(...(await rule.check(deckCtx)));
90
93
  }
91
- return out;
92
- }
93
94
 
94
- function codeBlockLineCounts(body: string): number[] {
95
- const counts: number[] = [];
96
- const lines = body.split("\n");
97
- let inBlock = false;
98
- let n = 0;
99
- for (const l of lines) {
100
- if (/^```/.test(l)) {
101
- if (inBlock) {
102
- counts.push(n);
103
- n = 0;
104
- inBlock = false;
105
- } else {
106
- inBlock = true;
107
- }
108
- } else if (inBlock) {
109
- n++;
110
- }
111
- }
112
- return counts;
95
+ return { slides, reports, deckFindings };
113
96
  }
114
97
 
98
+ // Public: kept for any external caller that imported `lintSlide` directly.
115
99
  export async function lintSlide(
116
100
  slide: { name: string; body: string },
117
101
  deckDir: string
118
102
  ): Promise<SlideReport> {
119
- const layout = extractLayout(slide.body);
120
- const body = stripNotes(slide.body);
103
+ const ctx = buildSlideContext(slide, 0, deckDir);
121
104
  const findings: Finding[] = [];
122
-
123
- const has = (s: string) => body.includes(s);
124
- const heads1 = countAtxHeading(body, 1);
125
- const heads2 = countAtxHeading(body, 2);
126
-
127
- // ── Global rules ──────────────────────────────────────────────────────
128
- if (!/<!--\s*\.slide:\s*data-layout=/i.test(slide.body)) {
129
- findings.push({
130
- rule: "no-layout",
131
- severity: "W",
132
- msg: "no <!-- .slide: data-layout=\"...\" --> directive — defaults to 'content'",
133
- });
134
- }
135
- for (const h of heads1) {
136
- if (h.length > 60) {
137
- findings.push({
138
- rule: "long-title",
139
- severity: "W",
140
- msg: `h1 is ${h.length} chars (soft limit 60): "${h.slice(0, 50)}…"`,
141
- });
142
- }
143
- }
144
- for (const h of heads2) {
145
- if (h.length > 100) {
146
- findings.push({
147
- rule: "long-subtitle",
148
- severity: "W",
149
- msg: `h2 is ${h.length} chars (soft limit 100)`,
150
- });
151
- }
152
- }
153
- for (const ref of findImageRefs(body)) {
154
- const abs = resolve(deckDir, ref);
155
- if (!(await exists(abs))) {
156
- findings.push({
157
- rule: "missing-asset",
158
- severity: "E",
159
- msg: `image referenced but not found: ${ref}`,
160
- });
161
- }
105
+ for (const rule of RULES) {
106
+ if (rule.scope !== "slide") continue;
107
+ if (rule.appliesTo && !rule.appliesTo(ctx)) continue;
108
+ findings.push(...(await rule.check(ctx)));
162
109
  }
110
+ return { name: ctx.name, layout: ctx.layout, findings };
111
+ }
163
112
 
164
- // ── Layout-specific rules ─────────────────────────────────────────────
165
- switch (layout) {
166
- case "title":
167
- case "closing": {
168
- if (heads1.length === 0) {
169
- findings.push({
170
- rule: "title-no-h1",
171
- severity: "E",
172
- msg: "missing h1 (deck title)",
173
- });
174
- }
175
- break;
176
- }
177
- case "section": {
178
- if (heads1.length === 0) {
179
- findings.push({
180
- rule: "section-no-h1",
181
- severity: "W",
182
- msg: "section divider with no h1",
183
- });
184
- }
185
- break;
186
- }
187
- case "agenda": {
188
- const items = countTopLevelListItems(body);
189
- if (items > 10) {
190
- findings.push({
191
- rule: "agenda-overflow",
192
- severity: "W",
193
- msg: `${items} items — agenda fits 10 cleanly; split into two slides`,
194
- });
195
- }
196
- if (items === 0) {
197
- findings.push({
198
- rule: "agenda-empty",
199
- severity: "E",
200
- msg: "agenda layout has no list items",
201
- });
202
- }
203
- break;
204
- }
205
- case "content": {
206
- const items = countTopLevelListItems(body);
207
- if (items > 7) {
208
- findings.push({
209
- rule: "content-bullets",
210
- severity: "W",
211
- msg: `${items} bullets — content slides fit ~7 cleanly`,
212
- });
213
- }
214
- break;
215
- }
216
- case "comparison": {
217
- if (!has(`class="compare"`)) {
218
- findings.push({
219
- rule: "comparison-wrapper",
220
- severity: "E",
221
- msg: 'missing <div class="compare"> wrapper',
222
- });
223
- }
224
- const options = (body.match(/class="option"/g) || []).length;
225
- if (options === 0) {
226
- findings.push({
227
- rule: "comparison-empty",
228
- severity: "E",
229
- msg: "no .option blocks found",
230
- });
231
- } else if (options > 3) {
232
- findings.push({
233
- rule: "comparison-count",
234
- severity: "W",
235
- msg: `${options} options — comparison fits 2–3 cleanly`,
236
- });
237
- }
238
- break;
239
- }
240
- case "metric-grid": {
241
- if (!has(`class="metrics"`)) {
242
- findings.push({
243
- rule: "metric-grid-wrapper",
244
- severity: "E",
245
- msg: 'missing <div class="metrics"> wrapper',
246
- });
247
- }
248
- const metrics = (body.match(/class="metric"/g) || []).length;
249
- if (metrics === 0) {
250
- findings.push({
251
- rule: "metric-grid-empty",
252
- severity: "E",
253
- msg: "no .metric blocks found",
254
- });
255
- } else if (metrics !== 3) {
256
- findings.push({
257
- rule: "metric-grid-count",
258
- severity: "W",
259
- msg: `${metrics} metrics — grid is 3-column, expects exactly 3`,
260
- });
261
- }
262
- break;
263
- }
264
- case "two-column": {
265
- if (!has(`class="col-left"`) || !has(`class="col-right"`)) {
266
- findings.push({
267
- rule: "two-column-wrappers",
268
- severity: "E",
269
- msg: 'missing <div class="col-left"> or <div class="col-right">',
270
- });
271
- }
272
- break;
273
- }
274
- case "image-text": {
275
- if (!has(`class="image"`) || !has(`class="text"`)) {
276
- findings.push({
277
- rule: "image-text-wrappers",
278
- severity: "E",
279
- msg: 'missing <div class="image"> or <div class="text">',
280
- });
281
- }
282
- break;
283
- }
284
- case "big-stat": {
285
- if (heads1.length === 0) {
286
- findings.push({
287
- rule: "big-stat-no-h1",
288
- severity: "E",
289
- msg: "missing h1 (the stat itself)",
290
- });
291
- } else if (heads1.length > 1) {
292
- findings.push({
293
- rule: "big-stat-multi-h1",
294
- severity: "W",
295
- msg: "multiple h1s — big-stat shows one number",
296
- });
297
- }
298
- break;
299
- }
300
- case "quote":
301
- case "pull-quote": {
302
- if (!/^>\s+/m.test(body)) {
303
- findings.push({
304
- rule: "quote-no-blockquote",
305
- severity: "E",
306
- msg: "no blockquote (`> ...`) found",
307
- });
308
- }
309
- break;
310
- }
311
- case "code": {
312
- const blocks = codeBlockLineCounts(body);
313
- if (blocks.length === 0) {
314
- findings.push({
315
- rule: "code-no-block",
316
- severity: "E",
317
- msg: "no fenced code block found",
318
- });
319
- }
320
- for (const n of blocks) {
321
- if (n > 25) {
322
- findings.push({
323
- rule: "code-too-long",
324
- severity: "W",
325
- msg: `code block has ${n} lines — fits ~25 before overflow`,
326
- });
327
- }
328
- }
329
- break;
113
+ function pad(s: string, n: number): string {
114
+ return s + " ".repeat(Math.max(0, n - s.length));
115
+ }
116
+
117
+ function printSlideFindings(reports: SlideReport[], deckBase: string, count: number) {
118
+ let printed = false;
119
+ for (const r of reports) {
120
+ if (r.findings.length === 0) continue;
121
+ if (!printed) {
122
+ console.log(`Doctor — ${deckBase} (${count} slides)`);
123
+ console.log("");
124
+ printed = true;
330
125
  }
331
- case "table": {
332
- const rows = body.split("\n").filter((l) => /^\s*\|.*\|\s*$/.test(l)).length;
333
- if (rows > 10) {
334
- findings.push({
335
- rule: "table-rows",
336
- severity: "W",
337
- msg: `${rows} table rows — gets cramped past ~8`,
338
- });
339
- }
340
- break;
126
+ console.log(` ${pad(r.name, 32)} [${r.layout}]`);
127
+ for (const f of r.findings) {
128
+ const sev = f.severity === "E" ? "ERROR" : "WARN ";
129
+ const symbol = f.severity === "E" ? "✗" : "⚠";
130
+ console.log(` ${symbol} ${sev} ${f.rule.padEnd(28)} ${f.msg}`);
341
131
  }
342
132
  }
343
-
344
- return { name: slide.name, layout, findings };
133
+ return printed;
345
134
  }
346
135
 
347
- function pad(s: string, n: number): string {
348
- return s + " ".repeat(Math.max(0, n - s.length));
136
+ function printDeckFindings(deckFindings: Finding[], anyPrintedAlready: boolean): boolean {
137
+ if (deckFindings.length === 0) return anyPrintedAlready;
138
+ if (anyPrintedAlready) console.log("");
139
+ console.log(" (deck-level)");
140
+ for (const f of deckFindings) {
141
+ const sev = f.severity === "E" ? "ERROR" : "WARN ";
142
+ const symbol = f.severity === "E" ? "✗" : "⚠";
143
+ console.log(` ${symbol} ${sev} ${f.rule.padEnd(28)} ${f.msg}`);
144
+ }
145
+ return true;
349
146
  }
350
147
 
351
148
  async function main() {
@@ -357,29 +154,20 @@ async function main() {
357
154
  const deckDir = resolve(argv[0]);
358
155
  const strict = argv.includes("--strict");
359
156
 
360
- const slides = await loadSlides(deckDir);
361
- const reports = await Promise.all(slides.map((s) => lintSlide(s, deckDir)));
157
+ const { slides, reports, deckFindings } = await lintDeck(deckDir);
362
158
 
363
159
  let errors = 0;
364
160
  let warnings = 0;
365
- let printed = false;
366
-
367
161
  for (const r of reports) {
368
- if (r.findings.length === 0) continue;
369
- if (!printed) {
370
- console.log(`Doctor — ${basename(deckDir)} (${slides.length} slides)`);
371
- console.log("");
372
- printed = true;
373
- }
374
- console.log(` ${pad(r.name, 32)} [${r.layout}]`);
375
162
  for (const f of r.findings) {
376
- const sev = f.severity === "E" ? "ERROR" : "WARN ";
377
- const symbol = f.severity === "E" ? "✗" : "⚠";
378
- console.log(` ${symbol} ${sev} ${f.rule.padEnd(22)} ${f.msg}`);
379
163
  if (f.severity === "E") errors++;
380
164
  else warnings++;
381
165
  }
382
166
  }
167
+ for (const f of deckFindings) {
168
+ if (f.severity === "E") errors++;
169
+ else warnings++;
170
+ }
383
171
 
384
172
  const total = errors + warnings;
385
173
  if (total === 0) {
@@ -387,6 +175,9 @@ async function main() {
387
175
  process.exit(0);
388
176
  }
389
177
 
178
+ const anyPrinted = printSlideFindings(reports, basename(deckDir), slides.length);
179
+ printDeckFindings(deckFindings, anyPrinted);
180
+
390
181
  console.log("");
391
182
  console.log(
392
183
  `Summary: ${errors} error(s), ${warnings} warning(s) across ${slides.length} slides`
@@ -396,8 +187,6 @@ async function main() {
396
187
  process.exit(exitCode);
397
188
  }
398
189
 
399
- // Only run as a CLI when invoked directly — allows test files to import lintSlide
400
- // without triggering the argv parsing / process.exit path.
401
190
  if (import.meta.main) {
402
191
  main().catch((e) => {
403
192
  console.error(e?.message ?? e);
@@ -0,0 +1,150 @@
1
+ // presentation skill — pure helpers for the lint pipeline.
2
+ //
3
+ // Each function operates on raw markdown strings or pre-processed pieces.
4
+ // No fs, no console, no side effects — easy to test in isolation.
5
+
6
+ import { constants as fsConst } from "node:fs";
7
+ import { access } from "node:fs/promises";
8
+
9
+ export async function fileExists(p: string): Promise<boolean> {
10
+ try {
11
+ await access(p, fsConst.F_OK);
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ export function extractLayout(body: string): string {
19
+ const m = /<!--\s*\.slide:\s*data-layout="([^"]+)"\s*-->/i.exec(body);
20
+ return m ? m[1] : "content";
21
+ }
22
+
23
+ export function hasLayoutDirective(body: string): boolean {
24
+ return /<!--\s*\.slide:\s*data-layout=/i.test(body);
25
+ }
26
+
27
+ export function stripNotes(body: string): string {
28
+ // Remove speaker notes — every line from `Note:` onward at line start.
29
+ const lines = body.split("\n");
30
+ const cut = lines.findIndex((l) => /^Note:/i.test(l.trim()));
31
+ return cut === -1 ? body : lines.slice(0, cut).join("\n");
32
+ }
33
+
34
+ export function extractNotes(body: string): string {
35
+ // Return only the speaker notes portion (everything from `Note:` onward).
36
+ const lines = body.split("\n");
37
+ const cut = lines.findIndex((l) => /^Note:/i.test(l.trim()));
38
+ return cut === -1 ? "" : lines.slice(cut).join("\n");
39
+ }
40
+
41
+ export function countAtxHeading(body: string, level: 1 | 2): string[] {
42
+ const re = new RegExp(`^#{${level}}\\s+(.+?)\\s*$`, "gm");
43
+ return Array.from(body.matchAll(re), (m) => m[1]);
44
+ }
45
+
46
+ export function countTopLevelListItems(body: string): number {
47
+ // Count lines starting with `- `, `* `, or `N. ` at column 0 (no leading indent).
48
+ let n = 0;
49
+ for (const line of body.split("\n")) {
50
+ if (/^(?:[-*]\s+|\d+\.\s+)/.test(line)) n++;
51
+ }
52
+ return n;
53
+ }
54
+
55
+ export function countAllListItems(body: string): number {
56
+ // Count all list items at any indentation (top-level + sub-bullets).
57
+ // The visual budget is "lines you read on the slide" — sub-bullets count.
58
+ let n = 0;
59
+ for (const line of body.split("\n")) {
60
+ if (/^\s*(?:[-*]\s+|\d+\.\s+)/.test(line)) n++;
61
+ }
62
+ return n;
63
+ }
64
+
65
+ export type ListItem = {
66
+ indent: number; // leading whitespace columns
67
+ content: string; // text after the bullet marker
68
+ raw: string; // the full line
69
+ };
70
+
71
+ export function listItems(body: string): ListItem[] {
72
+ const out: ListItem[] = [];
73
+ for (const line of body.split("\n")) {
74
+ const m = line.match(/^(\s*)(?:[-*]\s+|\d+\.\s+)(.*)$/);
75
+ if (!m) continue;
76
+ out.push({ indent: m[1].length, content: m[2], raw: line });
77
+ }
78
+ return out;
79
+ }
80
+
81
+ export function findImageRefs(body: string): string[] {
82
+ // Skip lines inside fenced code blocks — they're examples, not references.
83
+ const out: string[] = [];
84
+ const lines = body.split("\n");
85
+ let inFence = false;
86
+ for (const line of lines) {
87
+ if (/^```/.test(line)) {
88
+ inFence = !inFence;
89
+ continue;
90
+ }
91
+ if (inFence) continue;
92
+ for (const m of line.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) {
93
+ const ref = m[1].trim();
94
+ if (!/^(https?:|data:)/i.test(ref)) out.push(ref);
95
+ }
96
+ }
97
+ return out;
98
+ }
99
+
100
+ export function codeBlockLineCounts(body: string): number[] {
101
+ const counts: number[] = [];
102
+ const lines = body.split("\n");
103
+ let inBlock = false;
104
+ let n = 0;
105
+ for (const l of lines) {
106
+ if (/^```/.test(l)) {
107
+ if (inBlock) {
108
+ counts.push(n);
109
+ n = 0;
110
+ inBlock = false;
111
+ } else {
112
+ inBlock = true;
113
+ }
114
+ } else if (inBlock) {
115
+ n++;
116
+ }
117
+ }
118
+ return counts;
119
+ }
120
+
121
+ export function tableRowCount(body: string): number {
122
+ return body.split("\n").filter((l) => /^\s*\|.*\|\s*$/.test(l)).length;
123
+ }
124
+
125
+ export function stripCodeAndLinks(s: string): string {
126
+ // Remove inline code spans and markdown link bodies — useful when checking
127
+ // bullet content for prose-style patterns without false positives on code
128
+ // or URLs.
129
+ return s.replace(/`[^`]*`/g, "").replace(/\[[^\]]*\]\([^)]*\)/g, "");
130
+ }
131
+
132
+ export function wordCount(s: string): number {
133
+ // Count each inline code span as 1 word — preserves "label: `value`" patterns
134
+ // where the substantive content is in the code span. Strip markdown link
135
+ // bodies so links count as their visible text, not the URL.
136
+ const withCodeAsWords = s.replace(/`[^`]*`/g, "CODESPAN");
137
+ const withoutLinks = withCodeAsWords.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
138
+ const cleaned = withoutLinks.trim();
139
+ if (!cleaned) return 0;
140
+ return cleaned.split(/\s+/).length;
141
+ }
142
+
143
+ export function hasNestedChildren(items: ListItem[], parentIndex: number): boolean {
144
+ // Returns true if the item at parentIndex has any directly-following items
145
+ // at greater indent (i.e., it acts as a parent to a sub-bullet group).
146
+ const parent = items[parentIndex];
147
+ if (!parent) return false;
148
+ const next = items[parentIndex + 1];
149
+ return !!next && next.indent > parent.indent;
150
+ }