portable-agent-layer 0.32.0 → 0.34.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/README.md +1 -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 +1 -2
- package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +1 -1
- package/assets/templates/pal-settings.json +2 -2
- package/package.json +2 -3
- package/src/cli/index.ts +7 -0
- 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/src/tools/token-cost.ts +4 -4
- package/assets/templates/PAL/CONTEXT_ROUTING.md +0 -30
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
// presentation skill — rule registry.
|
|
2
|
+
//
|
|
3
|
+
// Each rule is a small object with a `check` function that emits Findings.
|
|
4
|
+
// Slide-scope rules run once per slide; deck-scope rules run once over the
|
|
5
|
+
// whole deck. Adding a rule = appending to the array. The runner in
|
|
6
|
+
// doctor.ts is layout-agnostic — `appliesTo` controls which slides a rule
|
|
7
|
+
// runs on.
|
|
8
|
+
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
codeBlockLineCounts,
|
|
12
|
+
countAllListItems,
|
|
13
|
+
countAtxHeading,
|
|
14
|
+
countTopLevelListItems,
|
|
15
|
+
extractNotes,
|
|
16
|
+
fileExists,
|
|
17
|
+
findImageRefs,
|
|
18
|
+
hasLayoutDirective,
|
|
19
|
+
hasNestedChildren,
|
|
20
|
+
listItems,
|
|
21
|
+
stripCodeAndLinks,
|
|
22
|
+
tableRowCount,
|
|
23
|
+
wordCount,
|
|
24
|
+
} from "./lint-helpers";
|
|
25
|
+
import type { Finding, Rule, SlideContext } from "./lint-types";
|
|
26
|
+
|
|
27
|
+
const BULLET_LAYOUTS = new Set(["content", "agenda", "comparison", "two-column"]);
|
|
28
|
+
|
|
29
|
+
function isBulletLayout(ctx: SlideContext): boolean {
|
|
30
|
+
return BULLET_LAYOUTS.has(ctx.layout);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isExerciseSlide(ctx: SlideContext): boolean {
|
|
34
|
+
// Detect by filename (what I see) OR by h2 prefix (what the room sees).
|
|
35
|
+
if (/exercise/i.test(ctx.name)) return true;
|
|
36
|
+
if (ctx.heads2.some((h) => /^Exercise\b/i.test(h))) return true;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const RULES: Rule[] = [
|
|
41
|
+
// ── Global rules (run on every slide) ───────────────────────────────────
|
|
42
|
+
|
|
43
|
+
{
|
|
44
|
+
name: "no-layout",
|
|
45
|
+
scope: "slide",
|
|
46
|
+
check: (ctx) => {
|
|
47
|
+
if (hasLayoutDirective(ctx.body)) return [];
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
rule: "no-layout",
|
|
51
|
+
severity: "W",
|
|
52
|
+
msg: "no <!-- .slide: data-layout=\"...\" --> directive — defaults to 'content'",
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
name: "long-title",
|
|
60
|
+
scope: "slide",
|
|
61
|
+
check: (ctx) => {
|
|
62
|
+
const findings: Finding[] = [];
|
|
63
|
+
for (const h of ctx.heads1) {
|
|
64
|
+
if (h.length > 60) {
|
|
65
|
+
findings.push({
|
|
66
|
+
rule: "long-title",
|
|
67
|
+
severity: "W",
|
|
68
|
+
msg: `h1 is ${h.length} chars (soft limit 60): "${h.slice(0, 50)}…"`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return findings;
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
name: "long-subtitle",
|
|
78
|
+
scope: "slide",
|
|
79
|
+
check: (ctx) => {
|
|
80
|
+
const findings: Finding[] = [];
|
|
81
|
+
for (const h of ctx.heads2) {
|
|
82
|
+
if (h.length > 100) {
|
|
83
|
+
findings.push({
|
|
84
|
+
rule: "long-subtitle",
|
|
85
|
+
severity: "W",
|
|
86
|
+
msg: `h2 is ${h.length} chars (soft limit 100)`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return findings;
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
name: "missing-asset",
|
|
96
|
+
scope: "slide",
|
|
97
|
+
check: async (ctx) => {
|
|
98
|
+
const findings: Finding[] = [];
|
|
99
|
+
for (const ref of findImageRefs(ctx.bodyNoNotes)) {
|
|
100
|
+
// `../assets/X` is the natural relative path from a slides/*.md file —
|
|
101
|
+
// resolve it from inside `slides/`. Plain `assets/X` (deck-root style)
|
|
102
|
+
// resolves from the deck root. Both resolve to the same file on disk.
|
|
103
|
+
const base = ref.startsWith("../") ? resolve(ctx.deckDir, "slides") : ctx.deckDir;
|
|
104
|
+
const abs = resolve(base, ref);
|
|
105
|
+
if (!(await fileExists(abs))) {
|
|
106
|
+
findings.push({
|
|
107
|
+
rule: "missing-asset",
|
|
108
|
+
severity: "E",
|
|
109
|
+
msg: `image referenced but not found: ${ref}`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return findings;
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
name: "bullet-emdash-continuation",
|
|
119
|
+
scope: "slide",
|
|
120
|
+
check: (ctx) => {
|
|
121
|
+
// Em-dash continuation in body bullets is disallowed by SKILL.md content
|
|
122
|
+
// rules. Em-dash is reserved for title qualifiers in headings ("Block 4
|
|
123
|
+
// — Landscape") and Q&A format in notes (`"Question?" — short answer`).
|
|
124
|
+
// Inside the slide body, a bullet of the form `- foo — bar` is prose
|
|
125
|
+
// pretending to be a bullet. Convert to a sub-bullet instead.
|
|
126
|
+
const findings: Finding[] = [];
|
|
127
|
+
for (const line of ctx.bodyNoNotes.split("\n")) {
|
|
128
|
+
const m = line.match(/^(\s*)(?:[-*]\s+|\d+\.\s+)(.*)$/);
|
|
129
|
+
if (!m) continue;
|
|
130
|
+
const stripped = stripCodeAndLinks(m[2]);
|
|
131
|
+
if (/\s—\s/.test(stripped)) {
|
|
132
|
+
findings.push({
|
|
133
|
+
rule: "bullet-emdash-continuation",
|
|
134
|
+
severity: "W",
|
|
135
|
+
msg: `em-dash continuation in bullet: "${m[2].trim().slice(0, 60)}…" — convert to sub-bullet`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return findings;
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
{
|
|
144
|
+
name: "slide-line-budget",
|
|
145
|
+
scope: "slide",
|
|
146
|
+
appliesTo: isBulletLayout,
|
|
147
|
+
check: (ctx) => {
|
|
148
|
+
// 10 fits cleanly on a slide; 11+ overflows even when each line is short.
|
|
149
|
+
const all = countAllListItems(ctx.bodyNoNotes);
|
|
150
|
+
if (all <= 10) return [];
|
|
151
|
+
return [
|
|
152
|
+
{
|
|
153
|
+
rule: "slide-line-budget",
|
|
154
|
+
severity: "W",
|
|
155
|
+
msg: `${all} list lines (top-level + sub-bullets) — slide fits 10 cleanly`,
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
// ── Content-quality rules (Tier 1) ──────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
{
|
|
164
|
+
// Top-level bullets should land 2–15 words. Below 2 = stub; above 15 =
|
|
165
|
+
// prose pretending to be a bullet. SKILL.md's stricter target is 6–12;
|
|
166
|
+
// the doctor uses looser bounds to flag only egregious cases.
|
|
167
|
+
//
|
|
168
|
+
// Two carve-outs:
|
|
169
|
+
// 1. A bullet with nested children is a "label" — minimum doesn't apply.
|
|
170
|
+
// "Locations" → sub-bullets is fine.
|
|
171
|
+
// 2. Comparison layout has its own visual rhythm (column labels under
|
|
172
|
+
// headers); skip the rule there entirely.
|
|
173
|
+
name: "bullet-length-top-level",
|
|
174
|
+
scope: "slide",
|
|
175
|
+
appliesTo: (ctx) => isBulletLayout(ctx) && ctx.layout !== "comparison",
|
|
176
|
+
check: (ctx) => {
|
|
177
|
+
const findings: Finding[] = [];
|
|
178
|
+
const items = listItems(ctx.bodyNoNotes);
|
|
179
|
+
items.forEach((item, idx) => {
|
|
180
|
+
if (item.indent !== 0) return;
|
|
181
|
+
const wc = wordCount(item.content);
|
|
182
|
+
const isLabel = hasNestedChildren(items, idx);
|
|
183
|
+
const tooShort = wc < 2 && !isLabel;
|
|
184
|
+
const tooLong = wc > 15;
|
|
185
|
+
if (tooShort || tooLong) {
|
|
186
|
+
findings.push({
|
|
187
|
+
rule: "bullet-length-top-level",
|
|
188
|
+
severity: "W",
|
|
189
|
+
msg: `top-level bullet has ${wc} words (target 2–15): "${item.content.trim().slice(0, 50)}…"`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
return findings;
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
{
|
|
198
|
+
// Sub-bullets should land 2–10 words. They are elaborations of the parent,
|
|
199
|
+
// not new claims, so they stay short.
|
|
200
|
+
name: "bullet-length-sub",
|
|
201
|
+
scope: "slide",
|
|
202
|
+
appliesTo: isBulletLayout,
|
|
203
|
+
check: (ctx) => {
|
|
204
|
+
const findings: Finding[] = [];
|
|
205
|
+
for (const item of listItems(ctx.bodyNoNotes)) {
|
|
206
|
+
if (item.indent === 0) continue;
|
|
207
|
+
const wc = wordCount(item.content);
|
|
208
|
+
if (wc < 2 || wc > 10) {
|
|
209
|
+
findings.push({
|
|
210
|
+
rule: "bullet-length-sub",
|
|
211
|
+
severity: "W",
|
|
212
|
+
msg: `sub-bullet has ${wc} words (target 2–10): "${item.content.trim().slice(0, 50)}…"`,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return findings;
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
{
|
|
221
|
+
// If notes contain source links, the FIRST bullet should be one (or a
|
|
222
|
+
// "Sources" parent whose children are links). The rule is "if you cite,
|
|
223
|
+
// cite first" — slides without any source link are exempt because the
|
|
224
|
+
// content is analysis, not citation.
|
|
225
|
+
name: "notes-link-first",
|
|
226
|
+
scope: "slide",
|
|
227
|
+
appliesTo: (ctx) =>
|
|
228
|
+
ctx.layout === "content" ||
|
|
229
|
+
ctx.layout === "big-stat" ||
|
|
230
|
+
ctx.layout === "comparison" ||
|
|
231
|
+
ctx.layout === "table",
|
|
232
|
+
check: (ctx) => {
|
|
233
|
+
const notes = extractNotes(ctx.body);
|
|
234
|
+
if (!notes.trim()) return [];
|
|
235
|
+
// No links anywhere = analysis-only notes; rule doesn't apply.
|
|
236
|
+
if (!/\[[^\]]+\]\([^)]+\)/.test(notes)) return [];
|
|
237
|
+
const items = listItems(notes);
|
|
238
|
+
if (items.length === 0) return [];
|
|
239
|
+
const first = items[0].content.trim();
|
|
240
|
+
if (/^\[[^\]]+\]\([^)]+\)/.test(first)) return [];
|
|
241
|
+
// "Sources" / "Per-X sources" parent whose children are links — allowed.
|
|
242
|
+
if (/^(per-\w+ )?sources?\b/i.test(first)) {
|
|
243
|
+
const sub = items.slice(1).find((it) => it.indent > items[0].indent);
|
|
244
|
+
if (sub && /^\[[^\]]+\]\([^)]+\)/.test(sub.content.trim())) return [];
|
|
245
|
+
}
|
|
246
|
+
return [
|
|
247
|
+
{
|
|
248
|
+
rule: "notes-link-first",
|
|
249
|
+
severity: "W",
|
|
250
|
+
msg: `notes have a source link, but first bullet isn't one: "${first.slice(0, 50)}…"`,
|
|
251
|
+
},
|
|
252
|
+
];
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
{
|
|
257
|
+
// Body should be bullets + headings + blockquotes + HTML wrappers + code.
|
|
258
|
+
// A bare prose paragraph in body usually means "what happens in the room"
|
|
259
|
+
// narration leaked onto the slide. Only enforced on content/agenda where
|
|
260
|
+
// prose is least legitimate; two-column and image-text legitimately host
|
|
261
|
+
// prose inside their wrappers.
|
|
262
|
+
name: "prose-paragraph-in-body",
|
|
263
|
+
scope: "slide",
|
|
264
|
+
appliesTo: (ctx) => ctx.layout === "content" || ctx.layout === "agenda",
|
|
265
|
+
check: (ctx) => {
|
|
266
|
+
const findings: Finding[] = [];
|
|
267
|
+
const lines = ctx.bodyNoNotes.split("\n");
|
|
268
|
+
let inFence = false;
|
|
269
|
+
for (const line of lines) {
|
|
270
|
+
if (/^```/.test(line)) {
|
|
271
|
+
inFence = !inFence;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (inFence) continue;
|
|
275
|
+
const trimmed = line.trim();
|
|
276
|
+
if (!trimmed) continue;
|
|
277
|
+
if (/^#/.test(trimmed)) continue; // heading
|
|
278
|
+
if (/^\s*(?:[-*]\s|\d+\.\s)/.test(line)) continue; // any-indent bullet
|
|
279
|
+
if (/^>/.test(trimmed)) continue; // blockquote
|
|
280
|
+
if (/^<!--/.test(trimmed)) continue; // comment
|
|
281
|
+
if (/^<\/?\w/.test(trimmed)) continue; // html tag
|
|
282
|
+
if (trimmed.length > 80) {
|
|
283
|
+
findings.push({
|
|
284
|
+
rule: "prose-paragraph-in-body",
|
|
285
|
+
severity: "W",
|
|
286
|
+
msg: `prose paragraph in body (${trimmed.length} chars): "${trimmed.slice(0, 60)}…"`,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return findings;
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
{
|
|
295
|
+
// Exercise slides should have h2 starting with "Exercise — " so the room
|
|
296
|
+
// can see they've changed mode. The em-dash here is a legal title
|
|
297
|
+
// qualifier (per SKILL.md), not a continuation.
|
|
298
|
+
name: "exercise-title-prefix",
|
|
299
|
+
scope: "slide",
|
|
300
|
+
appliesTo: isExerciseSlide,
|
|
301
|
+
check: (ctx) => {
|
|
302
|
+
const h2 = ctx.heads2[0];
|
|
303
|
+
if (!h2) {
|
|
304
|
+
return [
|
|
305
|
+
{
|
|
306
|
+
rule: "exercise-title-prefix",
|
|
307
|
+
severity: "W",
|
|
308
|
+
msg: "exercise slide has no h2 title",
|
|
309
|
+
},
|
|
310
|
+
];
|
|
311
|
+
}
|
|
312
|
+
if (/^Exercise\s+—\s+\S/.test(h2)) return [];
|
|
313
|
+
return [
|
|
314
|
+
{
|
|
315
|
+
rule: "exercise-title-prefix",
|
|
316
|
+
severity: "W",
|
|
317
|
+
msg: `exercise title should start with "Exercise — ": "${h2.slice(0, 50)}"`,
|
|
318
|
+
},
|
|
319
|
+
];
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
{
|
|
324
|
+
// Exercise notes should have the four standard facilitation beats so the
|
|
325
|
+
// speaker can scan during delivery. Each missing beat is its own warning.
|
|
326
|
+
// Beats live as top-level bullets in the Note: section; sub-bullets carry
|
|
327
|
+
// the actual content under each beat.
|
|
328
|
+
name: "exercise-note-beats",
|
|
329
|
+
scope: "slide",
|
|
330
|
+
appliesTo: isExerciseSlide,
|
|
331
|
+
check: (ctx) => {
|
|
332
|
+
const notes = extractNotes(ctx.body);
|
|
333
|
+
const findings: Finding[] = [];
|
|
334
|
+
const required = [
|
|
335
|
+
"Facilitation",
|
|
336
|
+
"Common output",
|
|
337
|
+
"Common mistakes",
|
|
338
|
+
"Anticipated questions",
|
|
339
|
+
];
|
|
340
|
+
for (const beat of required) {
|
|
341
|
+
const re = new RegExp(`^[-*]\\s+${beat.replace(/\s+/g, "\\s+")}\\b`, "im");
|
|
342
|
+
if (!re.test(notes)) {
|
|
343
|
+
findings.push({
|
|
344
|
+
rule: "exercise-note-beats",
|
|
345
|
+
severity: "W",
|
|
346
|
+
msg: `exercise notes missing beat: "${beat}"`,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return findings;
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
{
|
|
355
|
+
// big-stat is for numbers. The h1 must contain a digit; if it doesn't,
|
|
356
|
+
// the wrong layout was probably chosen.
|
|
357
|
+
name: "big-stat-needs-digit",
|
|
358
|
+
scope: "slide",
|
|
359
|
+
appliesTo: (ctx) => ctx.layout === "big-stat",
|
|
360
|
+
check: (ctx) => {
|
|
361
|
+
if (ctx.heads1.length === 0) return []; // covered by big-stat-no-h1
|
|
362
|
+
const h1 = ctx.heads1[0];
|
|
363
|
+
if (/\d/.test(h1)) return [];
|
|
364
|
+
return [
|
|
365
|
+
{
|
|
366
|
+
rule: "big-stat-needs-digit",
|
|
367
|
+
severity: "W",
|
|
368
|
+
msg: `big-stat h1 has no digit: "${h1}" — big-stat is for numbers`,
|
|
369
|
+
},
|
|
370
|
+
];
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
// ── Layout-specific rules ───────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
{
|
|
377
|
+
name: "title-no-h1",
|
|
378
|
+
scope: "slide",
|
|
379
|
+
appliesTo: (ctx) => ctx.layout === "title" || ctx.layout === "closing",
|
|
380
|
+
check: (ctx) => {
|
|
381
|
+
if (ctx.heads1.length > 0) return [];
|
|
382
|
+
return [
|
|
383
|
+
{
|
|
384
|
+
rule: "title-no-h1",
|
|
385
|
+
severity: "E",
|
|
386
|
+
msg: "missing h1 (deck title)",
|
|
387
|
+
},
|
|
388
|
+
];
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
{
|
|
393
|
+
name: "section-no-h1",
|
|
394
|
+
scope: "slide",
|
|
395
|
+
appliesTo: (ctx) => ctx.layout === "section",
|
|
396
|
+
check: (ctx) => {
|
|
397
|
+
if (ctx.heads1.length > 0) return [];
|
|
398
|
+
return [
|
|
399
|
+
{
|
|
400
|
+
rule: "section-no-h1",
|
|
401
|
+
severity: "W",
|
|
402
|
+
msg: "section divider with no h1",
|
|
403
|
+
},
|
|
404
|
+
];
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
{
|
|
409
|
+
name: "agenda-bounds",
|
|
410
|
+
scope: "slide",
|
|
411
|
+
appliesTo: (ctx) => ctx.layout === "agenda",
|
|
412
|
+
check: (ctx) => {
|
|
413
|
+
const findings: Finding[] = [];
|
|
414
|
+
const items = countTopLevelListItems(ctx.bodyNoNotes);
|
|
415
|
+
if (items > 10) {
|
|
416
|
+
findings.push({
|
|
417
|
+
rule: "agenda-overflow",
|
|
418
|
+
severity: "W",
|
|
419
|
+
msg: `${items} items — agenda fits 10 cleanly; split into two slides`,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
if (items === 0) {
|
|
423
|
+
findings.push({
|
|
424
|
+
rule: "agenda-empty",
|
|
425
|
+
severity: "E",
|
|
426
|
+
msg: "agenda layout has no list items",
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
return findings;
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
|
|
433
|
+
{
|
|
434
|
+
name: "content-bullets",
|
|
435
|
+
scope: "slide",
|
|
436
|
+
appliesTo: (ctx) => ctx.layout === "content",
|
|
437
|
+
check: (ctx) => {
|
|
438
|
+
const items = countTopLevelListItems(ctx.bodyNoNotes);
|
|
439
|
+
if (items <= 7) return [];
|
|
440
|
+
return [
|
|
441
|
+
{
|
|
442
|
+
rule: "content-bullets",
|
|
443
|
+
severity: "W",
|
|
444
|
+
msg: `${items} bullets — content slides fit ~7 cleanly`,
|
|
445
|
+
},
|
|
446
|
+
];
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
|
|
450
|
+
{
|
|
451
|
+
name: "comparison-shape",
|
|
452
|
+
scope: "slide",
|
|
453
|
+
appliesTo: (ctx) => ctx.layout === "comparison",
|
|
454
|
+
check: (ctx) => {
|
|
455
|
+
const findings: Finding[] = [];
|
|
456
|
+
const body = ctx.bodyNoNotes;
|
|
457
|
+
if (!body.includes('class="compare"')) {
|
|
458
|
+
findings.push({
|
|
459
|
+
rule: "comparison-wrapper",
|
|
460
|
+
severity: "E",
|
|
461
|
+
msg: 'missing <div class="compare"> wrapper',
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
const options = (body.match(/class="option"/g) || []).length;
|
|
465
|
+
if (options === 0) {
|
|
466
|
+
findings.push({
|
|
467
|
+
rule: "comparison-empty",
|
|
468
|
+
severity: "E",
|
|
469
|
+
msg: "no .option blocks found",
|
|
470
|
+
});
|
|
471
|
+
} else if (options > 3) {
|
|
472
|
+
findings.push({
|
|
473
|
+
rule: "comparison-count",
|
|
474
|
+
severity: "W",
|
|
475
|
+
msg: `${options} options — comparison fits 2–3 cleanly`,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
return findings;
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
|
|
482
|
+
{
|
|
483
|
+
name: "metric-grid-shape",
|
|
484
|
+
scope: "slide",
|
|
485
|
+
appliesTo: (ctx) => ctx.layout === "metric-grid",
|
|
486
|
+
check: (ctx) => {
|
|
487
|
+
const findings: Finding[] = [];
|
|
488
|
+
const body = ctx.bodyNoNotes;
|
|
489
|
+
if (!body.includes('class="metrics"')) {
|
|
490
|
+
findings.push({
|
|
491
|
+
rule: "metric-grid-wrapper",
|
|
492
|
+
severity: "E",
|
|
493
|
+
msg: 'missing <div class="metrics"> wrapper',
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
const metrics = (body.match(/class="metric"/g) || []).length;
|
|
497
|
+
if (metrics === 0) {
|
|
498
|
+
findings.push({
|
|
499
|
+
rule: "metric-grid-empty",
|
|
500
|
+
severity: "E",
|
|
501
|
+
msg: "no .metric blocks found",
|
|
502
|
+
});
|
|
503
|
+
} else if (metrics !== 3) {
|
|
504
|
+
findings.push({
|
|
505
|
+
rule: "metric-grid-count",
|
|
506
|
+
severity: "W",
|
|
507
|
+
msg: `${metrics} metrics — grid is 3-column, expects exactly 3`,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
return findings;
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
|
|
514
|
+
{
|
|
515
|
+
name: "two-column-wrappers",
|
|
516
|
+
scope: "slide",
|
|
517
|
+
appliesTo: (ctx) => ctx.layout === "two-column",
|
|
518
|
+
check: (ctx) => {
|
|
519
|
+
const body = ctx.bodyNoNotes;
|
|
520
|
+
if (body.includes('class="col-left"') && body.includes('class="col-right"')) {
|
|
521
|
+
return [];
|
|
522
|
+
}
|
|
523
|
+
return [
|
|
524
|
+
{
|
|
525
|
+
rule: "two-column-wrappers",
|
|
526
|
+
severity: "E",
|
|
527
|
+
msg: 'missing <div class="col-left"> or <div class="col-right">',
|
|
528
|
+
},
|
|
529
|
+
];
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
|
|
533
|
+
{
|
|
534
|
+
name: "image-text-wrappers",
|
|
535
|
+
scope: "slide",
|
|
536
|
+
appliesTo: (ctx) => ctx.layout === "image-text",
|
|
537
|
+
check: (ctx) => {
|
|
538
|
+
const body = ctx.bodyNoNotes;
|
|
539
|
+
if (body.includes('class="image"') && body.includes('class="text"')) {
|
|
540
|
+
return [];
|
|
541
|
+
}
|
|
542
|
+
return [
|
|
543
|
+
{
|
|
544
|
+
rule: "image-text-wrappers",
|
|
545
|
+
severity: "E",
|
|
546
|
+
msg: 'missing <div class="image"> or <div class="text">',
|
|
547
|
+
},
|
|
548
|
+
];
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
|
|
552
|
+
{
|
|
553
|
+
name: "big-stat-shape",
|
|
554
|
+
scope: "slide",
|
|
555
|
+
appliesTo: (ctx) => ctx.layout === "big-stat",
|
|
556
|
+
check: (ctx) => {
|
|
557
|
+
const findings: Finding[] = [];
|
|
558
|
+
if (ctx.heads1.length === 0) {
|
|
559
|
+
findings.push({
|
|
560
|
+
rule: "big-stat-no-h1",
|
|
561
|
+
severity: "E",
|
|
562
|
+
msg: "missing h1 (the stat itself)",
|
|
563
|
+
});
|
|
564
|
+
} else if (ctx.heads1.length > 1) {
|
|
565
|
+
findings.push({
|
|
566
|
+
rule: "big-stat-multi-h1",
|
|
567
|
+
severity: "W",
|
|
568
|
+
msg: "multiple h1s — big-stat shows one number",
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
return findings;
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
|
|
575
|
+
{
|
|
576
|
+
name: "quote-blockquote",
|
|
577
|
+
scope: "slide",
|
|
578
|
+
appliesTo: (ctx) => ctx.layout === "quote" || ctx.layout === "pull-quote",
|
|
579
|
+
check: (ctx) => {
|
|
580
|
+
if (/^>\s+/m.test(ctx.bodyNoNotes)) return [];
|
|
581
|
+
return [
|
|
582
|
+
{
|
|
583
|
+
rule: "quote-no-blockquote",
|
|
584
|
+
severity: "E",
|
|
585
|
+
msg: "no blockquote (`> ...`) found",
|
|
586
|
+
},
|
|
587
|
+
];
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
|
|
591
|
+
{
|
|
592
|
+
name: "code-shape",
|
|
593
|
+
scope: "slide",
|
|
594
|
+
appliesTo: (ctx) => ctx.layout === "code",
|
|
595
|
+
check: (ctx) => {
|
|
596
|
+
const findings: Finding[] = [];
|
|
597
|
+
const blocks = codeBlockLineCounts(ctx.bodyNoNotes);
|
|
598
|
+
if (blocks.length === 0) {
|
|
599
|
+
findings.push({
|
|
600
|
+
rule: "code-no-block",
|
|
601
|
+
severity: "E",
|
|
602
|
+
msg: "no fenced code block found",
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
for (const n of blocks) {
|
|
606
|
+
if (n > 25) {
|
|
607
|
+
findings.push({
|
|
608
|
+
rule: "code-too-long",
|
|
609
|
+
severity: "W",
|
|
610
|
+
msg: `code block has ${n} lines — fits ~25 before overflow`,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return findings;
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
|
|
618
|
+
{
|
|
619
|
+
name: "table-rows",
|
|
620
|
+
scope: "slide",
|
|
621
|
+
appliesTo: (ctx) => ctx.layout === "table",
|
|
622
|
+
check: (ctx) => {
|
|
623
|
+
const rows = tableRowCount(ctx.bodyNoNotes);
|
|
624
|
+
if (rows <= 10) return [];
|
|
625
|
+
return [
|
|
626
|
+
{
|
|
627
|
+
rule: "table-rows",
|
|
628
|
+
severity: "W",
|
|
629
|
+
msg: `${rows} table rows — gets cramped past ~8`,
|
|
630
|
+
},
|
|
631
|
+
];
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
|
|
635
|
+
// ── Deck-scope rules (Tier 3) ───────────────────────────────────────────
|
|
636
|
+
|
|
637
|
+
{
|
|
638
|
+
// Visual rhythm check (period-aware). Each slide gets a shape signature:
|
|
639
|
+
// - non-content slides → just their layout name (interleaving naturally
|
|
640
|
+
// breaks runs)
|
|
641
|
+
// - content slides → `content:<top-count>:<flat|nested>` so a flat slide
|
|
642
|
+
// and a nested slide count as different shapes even at the same count
|
|
643
|
+
//
|
|
644
|
+
// Detects periodic patterns of length 1, 2, or 3:
|
|
645
|
+
// period 1: 4+ same in a row (e.g., `5-flat, 5-flat, 5-flat, 5-flat`)
|
|
646
|
+
// period 2: 3+ AB cycles, 6+ slides (e.g., `4-flat, 5-flat, 4-flat...`)
|
|
647
|
+
// period 3: 3+ ABC cycles, 9+ slides
|
|
648
|
+
//
|
|
649
|
+
// When multiple periods fit a run, the smallest wins. Adding a single
|
|
650
|
+
// nested slide to a flat stretch breaks the run.
|
|
651
|
+
name: "monotone-rhythm",
|
|
652
|
+
scope: "deck",
|
|
653
|
+
check: (ctx) => {
|
|
654
|
+
const shape = (s: SlideContext): string => {
|
|
655
|
+
if (s.layout !== "content") return s.layout;
|
|
656
|
+
const items = listItems(s.bodyNoNotes);
|
|
657
|
+
const top = items.filter((it) => it.indent === 0).length;
|
|
658
|
+
const hasSubs = items.some((it) => it.indent > 0);
|
|
659
|
+
return `content:${top}:${hasSubs ? "nested" : "flat"}`;
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
const shapes = ctx.slides.map(shape);
|
|
663
|
+
const used = new Array(shapes.length).fill(false);
|
|
664
|
+
const minLength: Record<number, number> = { 1: 4, 2: 6, 3: 9 };
|
|
665
|
+
const findings: Finding[] = [];
|
|
666
|
+
|
|
667
|
+
for (let i = 0; i < shapes.length; i++) {
|
|
668
|
+
if (used[i]) continue;
|
|
669
|
+
|
|
670
|
+
let chosenPeriod = -1;
|
|
671
|
+
let chosenLen = 0;
|
|
672
|
+
|
|
673
|
+
for (const p of [1, 2, 3]) {
|
|
674
|
+
let runLen = p;
|
|
675
|
+
while (
|
|
676
|
+
i + runLen < shapes.length &&
|
|
677
|
+
shapes[i + runLen] === shapes[i + runLen - p]
|
|
678
|
+
) {
|
|
679
|
+
runLen++;
|
|
680
|
+
}
|
|
681
|
+
if (runLen >= minLength[p]) {
|
|
682
|
+
chosenPeriod = p;
|
|
683
|
+
chosenLen = runLen;
|
|
684
|
+
break; // smallest period wins
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (chosenPeriod < 0) continue;
|
|
689
|
+
|
|
690
|
+
const startName = ctx.slides[i].name;
|
|
691
|
+
const endName = ctx.slides[i + chosenLen - 1].name;
|
|
692
|
+
const cycles = Math.floor(chosenLen / chosenPeriod);
|
|
693
|
+
const sig =
|
|
694
|
+
chosenPeriod === 1
|
|
695
|
+
? `shape "${shapes[i]}"`
|
|
696
|
+
: `period ${chosenPeriod} pattern (${shapes.slice(i, i + chosenPeriod).join(" → ")})`;
|
|
697
|
+
findings.push({
|
|
698
|
+
rule: "monotone-rhythm",
|
|
699
|
+
severity: "W",
|
|
700
|
+
msg: `${cycles} cycles of ${sig} across ${chosenLen} slides (${startName} → ${endName}) — vary layout or nest some bullets`,
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
for (let k = i; k < i + chosenLen; k++) used[k] = true;
|
|
704
|
+
}
|
|
705
|
+
return findings;
|
|
706
|
+
},
|
|
707
|
+
},
|
|
708
|
+
|
|
709
|
+
{
|
|
710
|
+
// Each section divider opens a block; the slide after it should earn the
|
|
711
|
+
// block. Strict check here only flags structural failures: a section as
|
|
712
|
+
// the last slide, or two section slides in a row (empty block). The
|
|
713
|
+
// qualitative "earn-it" judgement stays human.
|
|
714
|
+
name: "block-needs-opener",
|
|
715
|
+
scope: "deck",
|
|
716
|
+
check: (ctx) => {
|
|
717
|
+
const findings: Finding[] = [];
|
|
718
|
+
for (let i = 0; i < ctx.slides.length; i++) {
|
|
719
|
+
const slide = ctx.slides[i];
|
|
720
|
+
if (slide.layout !== "section") continue;
|
|
721
|
+
const next = ctx.slides[i + 1];
|
|
722
|
+
if (!next) {
|
|
723
|
+
findings.push({
|
|
724
|
+
rule: "block-needs-opener",
|
|
725
|
+
severity: "W",
|
|
726
|
+
msg: `section "${slide.name}" is the last slide — no opener follows`,
|
|
727
|
+
});
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
if (next.layout === "section") {
|
|
731
|
+
findings.push({
|
|
732
|
+
rule: "block-needs-opener",
|
|
733
|
+
severity: "W",
|
|
734
|
+
msg: `section "${slide.name}" followed by another section "${next.name}" — empty block`,
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return findings;
|
|
739
|
+
},
|
|
740
|
+
},
|
|
741
|
+
];
|
|
742
|
+
|
|
743
|
+
// Helpers also exported for tests / external runners.
|
|
744
|
+
export { countAtxHeading };
|