portable-agent-layer 0.31.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.
- package/assets/skills/consulting-report/SKILL.md +8 -2
- package/assets/skills/consulting-report/demo/app/globals.css +115 -0
- package/assets/skills/consulting-report/demo/app/page.tsx +75 -28
- package/assets/skills/consulting-report/demo/components/comparison-table.tsx +40 -0
- package/assets/skills/consulting-report/demo/components/section.tsx +3 -2
- package/assets/skills/consulting-report/demo/components/stat-grid.tsx +26 -0
- package/assets/skills/consulting-report/demo/components/table-of-contents.tsx +27 -0
- package/assets/skills/consulting-report/template/app/globals.css +115 -0
- package/assets/skills/consulting-report/template/app/page.tsx +55 -28
- package/assets/skills/consulting-report/template/components/comparison-table.tsx +40 -0
- package/assets/skills/consulting-report/template/components/section.tsx +3 -2
- package/assets/skills/consulting-report/template/components/stat-grid.tsx +26 -0
- package/assets/skills/consulting-report/template/components/table-of-contents.tsx +27 -0
- package/assets/skills/presentation/SKILL.md +124 -5
- package/assets/skills/presentation/WORKSHOP.md +128 -0
- package/assets/skills/presentation/theme-base/base.css +113 -0
- package/assets/skills/presentation/theme-base/layouts.css +11 -2
- package/assets/skills/presentation/tools/build.ts +136 -6
- package/assets/skills/presentation/tools/doctor.ts +106 -317
- package/assets/skills/presentation/tools/lib/lint-helpers.ts +150 -0
- package/assets/skills/presentation/tools/lib/lint-rules.ts +744 -0
- package/assets/skills/presentation/tools/lib/lint-types.ts +40 -0
- package/assets/skills/presentation/tools/new-deck.ts +9 -4
- package/assets/skills/presentation/vendor/reveal/plugin/highlight/github-dark.css +118 -0
- package/assets/skills/projects/SKILL.md +111 -0
- package/assets/skills/telos/SKILL.md +4 -1
- package/assets/templates/AGENTS.md.template +28 -7
- package/assets/templates/PAL/ALGORITHM.md +2 -0
- package/assets/templates/PAL/README.md +0 -1
- package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +1 -1
- package/assets/templates/pal-settings.json +2 -2
- package/package.json +1 -1
- package/src/hooks/UserPromptOrchestrator.ts +3 -1
- package/src/hooks/handlers/auto-graduate.ts +169 -0
- package/src/hooks/handlers/inject-retrieval.ts +50 -0
- package/src/hooks/handlers/project-touch.ts +39 -0
- package/src/hooks/lib/context.ts +9 -8
- package/src/hooks/lib/paths.ts +2 -0
- package/src/hooks/lib/projects.ts +270 -0
- package/src/hooks/lib/retrieval-index.ts +223 -0
- package/src/hooks/lib/retrieval.ts +170 -0
- package/src/hooks/lib/security.ts +2 -0
- package/src/hooks/lib/stop.ts +9 -1
- package/src/hooks/lib/text-similarity.ts +13 -9
- package/src/hooks/lib/wisdom.ts +155 -1
- package/src/tools/agent/project.ts +336 -0
- package/src/tools/self-model.ts +3 -3
- 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
|
|
8
|
-
// prints per-slide findings + a summary,
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
120
|
-
const body = stripNotes(slide.body);
|
|
103
|
+
const ctx = buildSlideContext(slide, 0, deckDir);
|
|
121
104
|
const findings: Finding[] = [];
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
348
|
-
|
|
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
|
|
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
|
+
}
|