xdrs-core 0.22.1 → 0.24.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.
@@ -14,6 +14,7 @@ Foundational standards, principles, and guidelines.
14
14
  - [_core-adr-006](principles/006-research-standards.md) - **Research standards** — How to structure research documents backing XDR decisions
15
15
  - [_core-adr-007](principles/007-plan-standards.md) - **Plan standards** — How to structure ephemeral execution plans that implement decisions
16
16
  - [_core-adr-008](principles/008-xdr-standards-structured.md) - **XDR standards - structured** — How to expose individually referenceable numbered rules inside an XDR when external citation by identifier is required
17
+ - [_core-adr-009](principles/009-presentation-standards.md) - **Presentation standards** — How to structure Marp slide presentations that support XDR documents
17
18
 
18
19
  ## Skills
19
20
 
@@ -25,6 +26,7 @@ Step-by-step procedural guides for humans and AI agents.
25
26
  - [004-write-article](principles/skills/004-write-article/SKILL.md) - **Write Article** — create a new article document
26
27
  - [005-write-research](principles/skills/005-write-research/SKILL.md) - **Write Research** — create a new research document
27
28
  - [006-write-plan](principles/skills/006-write-plan/SKILL.md) - **Write Plan** — create a new plan document
29
+ - [007-write-presentation](principles/skills/007-write-presentation/SKILL.md) - **Write Presentation** — create Marp slide presentations for XDR documents
28
30
 
29
31
  ## Articles
30
32
 
@@ -0,0 +1,52 @@
1
+ ---
2
+ name: _core-adr-009-presentation-standards
3
+ description: Defines how Marp slide presentations are structured, named, placed, and linked within XDR projects. Use when creating, reviewing, or linting slide decks that support XDR documents.
4
+ ---
5
+
6
+ # _core-adr-009: Presentation standards
7
+
8
+ ## Context and Problem Statement
9
+
10
+ Teams often need slide presentations to communicate decisions, research findings, plans, or article content to different audiences. Without a standard, slides drift from the authoritative documents, use inconsistent formats, and become disconnected from the decision records they support.
11
+
12
+ How should slide presentations be structured, placed, and maintained within the XDR framework so they remain consistent, discoverable, and always traceable to the documents they support?
13
+
14
+ ## Decision Outcome
15
+
16
+ **Marp Markdown slides co-located in `.assets/` next to the parent document**
17
+
18
+ Presentations are Markdown files in Marp format, stored in the `.assets/` folder of the document they support, with bidirectional links and strict naming conventions.
19
+
20
+ ### Details
21
+
22
+ - Slides are supporting media for XDR documents. They must never exist as standalone artifacts without a parent decision, research, article, or plan.
23
+ - Slides must use the [Marp](https://marp.app/) Markdown format with a minimal YAML frontmatter block at the beginning of the file:
24
+ ```
25
+ ---
26
+ marp: true
27
+ ---
28
+ ```
29
+ Additional Marp configuration keys (e.g. `theme`, `paginate`, `header`, `footer`) may be added when needed, but `marp: true` is always required as the first frontmatter key.
30
+ - Slide files must be placed in the `.assets/` folder next to the element they relate to, following the `.assets/` placement rules defined in `_core-adr-001`.
31
+ - The parent document must always contain a link to the slide file. The slide file must always contain a link back to the parent document at the end of the presentation. This bidirectional linking is mandatory.
32
+ - Slide files must use the same base name as the parent file, suffixed with `-slides`. When multiple slide sets exist for the same parent, append a context indicator after `-slides` (e.g. `-slides-overview`, `-slides-executive`).
33
+ - Example: parent `003-naming-conventions.md` produces `003-naming-conventions-slides.md` or `003-naming-conventions-slides-overview.md`.
34
+ - Slide file names must not exceed 64 characters (including the `.md` extension).
35
+ - Slide file names must be lowercase.
36
+ - When slides refer to content from multiple decisions, plans, or research documents, an article explaining the combined view must be written first. The slides then support that article, not the individual documents directly.
37
+ - Slides should contain minimal text. Prefer Mermaid diagrams, short bullet points, ASCII art, key short statements, and tables. Use longer text only when the exact wording must be evaluated by the audience (policies, texts under discussion, controls).
38
+ - Slides should follow a clean, linear storytelling structure (context, problem, solution, actions). Follow the structure of the underlying document, extracting the most important points and stressing central questions, answers, doubts, decisions, and risks.
39
+ - Define the central message or objective of the presentation before creating the slides. If the objective is unclear or there are multiple possible paths, ask the user before proceeding.
40
+ - Always identify the target audience (executives, engineers, specialists, control). If the audience is not clear from the underlying document, ask before creating the slides. Include audience info in the file name when multiple audiences exist (e.g. `005-rail-standards-slides-executive.md`).
41
+ - Keep presentations under 30 slides. Create separate slide sets for different views or audiences when needed.
42
+ - Slides never replace decision records or related document contents. When the underlying document changes, the associated slides must be reviewed and updated to stay consistent.
43
+ - Never use emojis in slide content.
44
+ - Always use lowercase file names.
45
+
46
+ ## References
47
+
48
+ - [_core-adr-001 - XDRs core](001-xdrs-core.md) - Framework structure and `.assets/` placement rules
49
+ - [_core-adr-002 - XDR standards](002-xdr-standards.md) - XDR document writing rules
50
+ - [_core-adr-004 - Article standards](004-article-standards.md) - Article standards for multi-document views
51
+ - [007-write-presentation skill](skills/007-write-presentation/SKILL.md) - Skill for creating slide presentations
52
+ - [Marp](https://marp.app/) - Markdown Presentation Ecosystem
@@ -0,0 +1,169 @@
1
+ ---
2
+ name: 007-write-presentation
3
+ description: >
4
+ Creates a Marp slide presentation for an existing XDR document (decision, research, article, or plan).
5
+ Activate this skill when the user asks to create slides, a presentation, or a slide deck for an XDR document.
6
+ metadata:
7
+ author: flaviostutz
8
+ version: "1.0"
9
+ ---
10
+
11
+ ## Overview
12
+
13
+ Guides the creation of a Marp Markdown slide presentation that supports an existing XDR document. The skill ensures the slides follow presentation standards (`_core-adr-009`), are correctly placed in the `.assets/` folder, and maintain bidirectional links with the parent document.
14
+
15
+ ## Instructions
16
+
17
+ ### Phase 1: Identify the Parent Document
18
+
19
+ 1. Read `.xdrs/_core/adrs/principles/009-presentation-standards.md` in full to internalize all presentation rules.
20
+ 2. Read `.xdrs/_core/adrs/principles/001-xdrs-core.md` for `.assets/` placement rules and general framework structure.
21
+ 3. Identify the parent document (decision, research, article, or plan) that the slides will support. The parent document must already exist. If no parent document exists, inform the user that slides cannot be standalone and suggest creating the parent document first.
22
+ 4. If the user wants slides covering content from multiple documents, check whether an article already exists that synthesizes those documents. If not, suggest creating an article first (using the 004-write-article skill) and then creating slides for that article.
23
+
24
+ ### Phase 2: Define the Presentation Scope
25
+
26
+ 1. Read the parent document in full.
27
+ 2. Define the central message or objective of the presentation. If the objective is ambiguous or there are multiple possible paths, ask the user before proceeding. Ask one question at a time.
28
+ 3. Identify the target audience. Check the parent document for audience indicators. If unclear, ask the user. Common audiences:
29
+ - **Executives**: high-level, strategic, outcome-focused
30
+ - **Engineers**: technical depth, implementation details, trade-offs
31
+ - **Specialists**: domain-specific depth, compliance, controls
32
+ - **Control**: risk, compliance, audit, governance focus
33
+ 4. Determine if multiple slide sets are needed for different audiences. If so, create separate files for each.
34
+
35
+ ### Phase 3: Plan the Slide Structure
36
+
37
+ 1. Extract the most important points from the parent document.
38
+ 2. Organize slides following a linear storytelling structure:
39
+ - **Context**: what is the situation, background, who is impacted
40
+ - **Problem**: what needs to be decided, addressed, or understood
41
+ - **Solution/Decision**: what was decided and why
42
+ - **Actions/Next Steps**: what happens now, who is responsible
43
+ 3. For each slide, identify the best format:
44
+ - Mermaid diagrams for flows, relationships, architecture
45
+ - Short bullet points for key decisions and trade-offs
46
+ - Tables for comparisons, options, criteria
47
+ - ASCII art for simple layouts or structures
48
+ - Key short statements for emphasis
49
+ - Longer text only when exact wording must be evaluated (policies, controls)
50
+ 4. Keep the total under 30 slides. If more content is needed, plan separate slide sets.
51
+
52
+ ### Phase 4: Determine the File Name
53
+
54
+ 1. Take the parent document file name (without `.md` extension).
55
+ 2. Append `-slides` for the primary set.
56
+ 3. If multiple sets exist, append a context indicator: `-slides-overview`, `-slides-executive`, `-slides-technical`, etc.
57
+ 4. Verify the final file name (including `.md`) is 64 characters or fewer. If it exceeds 64 characters, shorten the base name while keeping it recognizable.
58
+ 5. Ensure the file name is entirely lowercase.
59
+
60
+ Examples:
61
+ - Parent: `003-naming-conventions.md` -> `003-naming-conventions-slides.md`
62
+ - Parent: `005-rail-standards.md` (executive audience) -> `005-rail-standards-slides-executive.md`
63
+ - Parent: `001-xdrs-overview.md` (article) -> `001-xdrs-overview-slides.md`
64
+
65
+ ### Phase 5: Write the Slides
66
+
67
+ Create the Marp Markdown file with this structure:
68
+
69
+ ```markdown
70
+ ---
71
+ marp: true
72
+ ---
73
+
74
+ # [Presentation Title]
75
+
76
+ [Subtitle or context line]
77
+ [Audience and date if relevant]
78
+
79
+ ---
80
+
81
+ [Slide 2: Context/Background]
82
+
83
+ ---
84
+
85
+ [Slide 3: Problem Statement]
86
+
87
+ ---
88
+
89
+ [... additional slides ...]
90
+
91
+ ---
92
+
93
+ # References
94
+
95
+ - [Parent document title](relative/path/to/parent.md)
96
+ - [Other related documents if applicable]
97
+ ```
98
+
99
+ Rules:
100
+ - The first line of YAML frontmatter must be `marp: true`. Additional Marp keys (`theme`, `paginate`, `header`, `footer`) may be added after.
101
+ - Use `---` as the slide separator (standard Marp syntax).
102
+ - The last slide must contain links back to the parent document and any other related documents.
103
+ - Minimize text per slide. Prefer visual elements and short statements.
104
+ - Stress central questions, answers, doubts, decisions, and risks from the parent document.
105
+ - No emojis.
106
+ - Use relative paths for all links.
107
+ - Follow the audience-appropriate level of detail.
108
+
109
+ ### Phase 6: Update the Parent Document
110
+
111
+ 1. Add a link to the slide file in the parent document. Place it in the `## References` section or, for XDRs, in the most appropriate section.
112
+ 2. Use a descriptive link text such as "Presentation slides" pointing to the slide file in `.assets/`.
113
+
114
+ ### Phase 7: Write Files
115
+
116
+ 1. Create the slide file at the correct `.assets/` location:
117
+ - XDRs: `[xdrs-root]/[scope]/[type]/[subject]/.assets/[slide-file].md`
118
+ - Articles: `[xdrs-root]/[scope]/[type]/[subject]/articles/.assets/[slide-file].md`
119
+ - Research: `[xdrs-root]/[scope]/[type]/[subject]/researches/.assets/[slide-file].md`
120
+ - Skills: `[xdrs-root]/[scope]/[type]/[subject]/skills/[number]-[skill-name]/.assets/[slide-file].md`
121
+ - Plans: `[xdrs-root]/[scope]/[type]/[subject]/plans/.assets/[slide-file].md`
122
+ 2. Update the parent document with the link to the slide file.
123
+ 3. Verify that the slide file name is <= 64 characters and lowercase.
124
+
125
+ ### Phase 8: Verify with Lint
126
+
127
+ 1. Run the CLI lint utility from the repository root:
128
+ ```
129
+ npx -y xdrs-core lint .
130
+ ```
131
+ 2. Fix all reported errors before considering the task complete.
132
+
133
+ ### Constraints
134
+
135
+ - MUST follow presentation standards from `_core-adr-009` exactly.
136
+ - MUST NOT create slides without an existing parent document.
137
+ - MUST NOT create standalone slides that reference multiple documents without an article as the parent.
138
+ - MUST maintain bidirectional links between slides and parent document.
139
+ - MUST keep slide file names under 64 characters.
140
+ - MUST include `marp: true` in the slide frontmatter.
141
+ - MUST keep presentations under 30 slides.
142
+ - MUST ask the user about audience and objective when not clear from context.
143
+
144
+ ## Examples
145
+
146
+ **Input**: "Create slides for our naming conventions decision"
147
+ - Locate the parent XDR (e.g. `003-naming-conventions.md`)
148
+ - Ask: "Who is the target audience for these slides?"
149
+ - Create: `.assets/003-naming-conventions-slides.md`
150
+ - Update: Add link in `003-naming-conventions.md`
151
+
152
+ **Input**: "Create an executive presentation covering our security and data decisions"
153
+ - Check if an article synthesizing security + data decisions exists
154
+ - If not, suggest creating the article first
155
+ - Create slides for the article, not the individual XDRs
156
+
157
+ ## Edge Cases
158
+
159
+ - If the parent document does not exist, do not create slides. Inform the user and suggest creating the parent first.
160
+ - If the slide file name would exceed 64 characters, shorten the base name while keeping it recognizable.
161
+ - If the content requires more than 30 slides, split into multiple slide sets with distinct audience or topic focus.
162
+ - If the parent document changes after slides are created, the slides must be reviewed and updated.
163
+
164
+ ## References
165
+
166
+ - [_core-adr-009 - Presentation standards](../../009-presentation-standards.md)
167
+ - [_core-adr-001 - XDRs core](../../001-xdrs-core.md)
168
+ - [_core-adr-004 - Article standards](../../004-article-standards.md)
169
+ - [_core-adr-003 - Skill standards](../../003-skill-standards.md)
@@ -35,9 +35,13 @@ Each artifact type has its own writing standard:
35
35
 
36
36
  The business decision [_core-bdr-001](bdrs/principles/001-xdr-decisions-and-skills-usage.md) establishes how agents and humans must use XDR decisions and skills, separating policy authority (which lives in XDRs) from execution guidance (which lives in skills).
37
37
 
38
+ ### Presentation standards
39
+
40
+ Slide presentations that support XDR documents follow [_core-adr-009](adrs/principles/009-presentation-standards.md). Slides use the Marp Markdown format, live in `.assets/` next to the document they support, and must maintain bidirectional links with the parent document.
41
+
38
42
  ### Available skills
39
43
 
40
- The `_core` scope ships with six skills that automate the most common framework operations:
44
+ The `_core` scope ships with seven skills that automate the most common framework operations:
41
45
 
42
46
  - **001-lint** reviews code and files against applicable XDRs
43
47
  - **002-write-xdr** guides creation of a new decision record
@@ -45,6 +49,7 @@ The `_core` scope ships with six skills that automate the most common framework
45
49
  - **004-write-article** guides creation of a new article
46
50
  - **005-write-research** guides creation of a new research document
47
51
  - **006-write-plan** guides creation of a new execution plan
52
+ - **007-write-presentation** guides creation of Marp slide presentations
48
53
 
49
54
  ### Getting started
50
55
 
package/lib/lint.js CHANGED
@@ -28,6 +28,11 @@ const SKILL_PACKAGE_OPTIONAL_DIRS = new Set(['scripts', 'references', RESOURCE_D
28
28
  const XDR_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'apply-to', 'valid-from', 'license', 'metadata']);
29
29
  const SKILL_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'license', 'metadata', 'compatibility', 'allowed-tools']);
30
30
  const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp']);
31
+ const EMOJI_RE = /\p{Extended_Pictographic}/u;
32
+ const XDR_MAX_WORDS = 2600;
33
+ const ARTICLE_MAX_WORDS = 5000;
34
+ const RESEARCH_MAX_WORDS = 5000;
35
+ const SKILL_MAX_WORDS = 6500;
31
36
 
32
37
  function runLintCli(args) {
33
38
  if (args.includes('--help') || args.includes('-h')) {
@@ -331,6 +336,8 @@ function lintXdrFile(xdrsRoot, scopeName, typeName, filePath, xdrNumbers, errors
331
336
  || `${scopeName}-${TYPE_TO_ID[typeName]}-${number}-${match[2]}`;
332
337
  lintXdrFrontmatter(content, expectedName, filePath, errors);
333
338
  lintRequiredSections(content, filePath, XDR_REQUIRED_SECTIONS, 'XDR', errors);
339
+ lintNoEmojis(content, filePath, 'XDR', errors);
340
+ lintWordCount(content, filePath, 'XDR', XDR_MAX_WORDS, errors);
334
341
  lintDocumentLinks(filePath, xdrsRoot, scopeName, errors, externalScopes);
335
342
  }
336
343
 
@@ -367,11 +374,18 @@ function lintXdrFrontmatter(content, expectedName, filePath, errors) {
367
374
  }
368
375
  if (!fm.name) {
369
376
  errors.push(`XDR frontmatter must include a non-empty name field: ${toDisplayPath(filePath)}`);
370
- } else if (fm.name !== expectedName) {
371
- errors.push(`XDR frontmatter name must be "${expectedName}": ${toDisplayPath(filePath)}`);
377
+ } else {
378
+ if (fm.name !== expectedName) {
379
+ errors.push(`XDR frontmatter name must be "${expectedName}": ${toDisplayPath(filePath)}`);
380
+ }
381
+ if (fm.name.length > 64) {
382
+ errors.push(`XDR frontmatter name must be 64 characters or fewer: ${toDisplayPath(filePath)}`);
383
+ }
372
384
  }
373
385
  if (!fm.description) {
374
386
  errors.push(`XDR frontmatter must include a non-empty description field: ${toDisplayPath(filePath)}`);
387
+ } else if (fm.descriptionText && fm.descriptionText.length > 1024) {
388
+ errors.push(`XDR frontmatter description must be 1024 characters or fewer: ${toDisplayPath(filePath)}`);
375
389
  }
376
390
  if (fm.validFrom !== undefined) {
377
391
  if (!isIsoDate(fm.validFrom)) {
@@ -455,11 +469,18 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
455
469
  } else {
456
470
  if (!skillFm.name) {
457
471
  errors.push(`SKILL.md frontmatter must include a non-empty name field: ${toDisplayPath(skillFilePath)}`);
458
- } else if (skillFm.name !== entry.name) {
459
- errors.push(`Skill frontmatter name must match package directory "${entry.name}": ${toDisplayPath(skillFilePath)}`);
472
+ } else {
473
+ if (skillFm.name !== entry.name) {
474
+ errors.push(`Skill frontmatter name must match package directory "${entry.name}": ${toDisplayPath(skillFilePath)}`);
475
+ }
476
+ if (skillFm.name.length > 64) {
477
+ errors.push(`SKILL.md frontmatter name must be 64 characters or fewer: ${toDisplayPath(skillFilePath)}`);
478
+ }
460
479
  }
461
480
  if (!skillFm.description) {
462
481
  errors.push(`SKILL.md frontmatter must include a non-empty description field: ${toDisplayPath(skillFilePath)}`);
482
+ } else if (skillFm.descriptionText && skillFm.descriptionText.length > 1024) {
483
+ errors.push(`SKILL.md frontmatter description must be 1024 characters or fewer: ${toDisplayPath(skillFilePath)}`);
463
484
  }
464
485
  for (const key of skillFm.topLevelKeys) {
465
486
  if (!SKILL_ALLOWED_FRONTMATTER_KEYS.has(key)) {
@@ -468,6 +489,8 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
468
489
  }
469
490
  }
470
491
 
492
+ lintNoEmojis(skillContent, skillFilePath, 'Skill', errors);
493
+ lintWordCount(skillContent, skillFilePath, 'Skill', SKILL_MAX_WORDS, errors);
471
494
  lintDocumentLinks(skillFilePath, xdrsRoot, scopeName, errors, externalScopes);
472
495
  lintOrphanAssets(path.join(entryPath, RESOURCE_DIR_NAME), [skillFilePath], xdrsRoot, errors);
473
496
  }
@@ -519,6 +542,8 @@ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, artic
519
542
  }
520
543
 
521
544
  lintRequiredSections(content, entryPath, ARTICLE_REQUIRED_SECTIONS, 'Article', errors);
545
+ lintNoEmojis(content, entryPath, 'Article', errors);
546
+ lintWordCount(content, entryPath, 'Article', ARTICLE_MAX_WORDS, errors);
522
547
  lintDocumentLinks(entryPath, xdrsRoot, scopeName, errors, externalScopes);
523
548
  }
524
549
 
@@ -571,6 +596,9 @@ function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, resea
571
596
  }
572
597
 
573
598
  lintRequiredSections(content, entryPath, RESEARCH_REQUIRED_SECTIONS, 'Research', errors);
599
+ lintResearchIntroductionQuestion(content, entryPath, errors);
600
+ lintNoEmojis(content, entryPath, 'Research', errors);
601
+ lintWordCount(content, entryPath, 'Research', RESEARCH_MAX_WORDS, errors);
574
602
  lintDocumentLinks(entryPath, xdrsRoot, scopeName, errors, externalScopes);
575
603
  }
576
604
 
@@ -624,6 +652,7 @@ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPat
624
652
 
625
653
  lintRequiredSections(content, entryPath, PLAN_REQUIRED_SECTIONS, 'Plan', errors);
626
654
  lintPlanExpectedEndDate(content, entryPath, errors);
655
+ lintNoEmojis(content, entryPath, 'Plan', errors);
627
656
  lintDocumentLinks(entryPath, xdrsRoot, scopeName, errors, externalScopes);
628
657
  }
629
658
 
@@ -632,6 +661,52 @@ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPat
632
661
  return artifacts;
633
662
  }
634
663
 
664
+ function lintNoEmojis(content, filePath, docType, errors) {
665
+ const lines = content.split(/\r?\n/);
666
+ const ignoredLines = findIgnoredMarkdownLines(lines);
667
+ for (let i = 0; i < lines.length; i++) {
668
+ if (ignoredLines[i]) continue;
669
+ if (EMOJI_RE.test(lines[i])) {
670
+ errors.push(`${docType} must not contain emojis: ${toDisplayPath(filePath)}:${i + 1}`);
671
+ }
672
+ }
673
+ }
674
+
675
+ function lintWordCount(content, filePath, docType, maxWords, errors) {
676
+ const body = stripFrontmatter(content);
677
+ const wordCount = countWords(body);
678
+ if (wordCount > maxWords) {
679
+ errors.push(`${docType} exceeds maximum word count of ${maxWords} (${wordCount} words): ${toDisplayPath(filePath)}`);
680
+ }
681
+ }
682
+
683
+ function lintResearchIntroductionQuestion(content, filePath, errors) {
684
+ const lines = content.split(/\r?\n/);
685
+ const ignoredLines = findIgnoredMarkdownLines(lines);
686
+ const introStart = findHeadingLine(lines, ignoredLines, '## Introduction');
687
+ if (introStart === -1) return; // Already caught by lintRequiredSections
688
+
689
+ let introEnd = lines.length;
690
+ for (let i = introStart + 1; i < lines.length; i++) {
691
+ if (!ignoredLines[i] && /^## /.test(lines[i].trim())) {
692
+ introEnd = i;
693
+ break;
694
+ }
695
+ }
696
+
697
+ let hasQuestion = false;
698
+ for (let i = introStart + 1; i < introEnd; i++) {
699
+ if (!ignoredLines[i] && lines[i].trim().startsWith('Question:')) {
700
+ hasQuestion = true;
701
+ break;
702
+ }
703
+ }
704
+
705
+ if (!hasQuestion) {
706
+ errors.push(`Research ## Introduction must contain a "Question:" line: ${toDisplayPath(filePath)}`);
707
+ }
708
+ }
709
+
635
710
  const XDR_REQUIRED_SECTIONS = ['## Context and Problem Statement', '## Decision Outcome'];
636
711
  const ARTICLE_REQUIRED_SECTIONS = ['## Overview', '## Content', '## References'];
637
712
  const RESEARCH_REQUIRED_SECTIONS = ['## Abstract', '## Introduction', '## Methods', '## Results', '## Discussion', '## Conclusion', '## References'];
@@ -882,10 +957,33 @@ function shouldValidateResourceLink(rawTarget) {
882
957
  || IMAGE_EXTENSIONS.has(extension);
883
958
  }
884
959
 
960
+ function extractDescriptionText(block) {
961
+ const lines = block.split(/\r?\n/);
962
+ for (let i = 0; i < lines.length; i++) {
963
+ const m = lines[i].match(/^description:\s*(.*)$/);
964
+ if (!m) continue;
965
+ const inlineValue = m[1].trim();
966
+ if (inlineValue && inlineValue !== '>' && inlineValue !== '|') {
967
+ return inlineValue;
968
+ }
969
+ // Block scalar: collect following indented lines
970
+ const bodyLines = [];
971
+ for (let j = i + 1; j < lines.length; j++) {
972
+ if (lines[j] === '' || /^\s+/.test(lines[j])) {
973
+ bodyLines.push(lines[j].trim());
974
+ } else {
975
+ break;
976
+ }
977
+ }
978
+ return bodyLines.join(' ').trim() || null;
979
+ }
980
+ return null;
981
+ }
982
+
885
983
  function extractFrontmatter(content) {
886
984
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/);
887
985
  if (!match) {
888
- return { present: false, name: null, description: false, validFrom: undefined, appliedTo: undefined, topLevelKeys: [] };
986
+ return { present: false, name: null, description: false, descriptionText: null, validFrom: undefined, appliedTo: undefined, topLevelKeys: [] };
889
987
  }
890
988
  const block = match[1];
891
989
  const nameMatch = block.match(/^name:\s*(.+)$/m);
@@ -896,10 +994,12 @@ function extractFrontmatter(content) {
896
994
  const keyMatch = line.match(/^([a-zA-Z][a-zA-Z0-9_-]*):\s*/);
897
995
  if (keyMatch) topLevelKeys.push(keyMatch[1]);
898
996
  }
997
+ const descriptionText = extractDescriptionText(block);
899
998
  return {
900
999
  present: true,
901
1000
  name: nameMatch ? nameMatch[1].trim() || null : null,
902
1001
  description: /^description:\s*\S/m.test(block),
1002
+ descriptionText,
903
1003
  validFrom: validFromMatch ? validFromMatch[1].trim() : undefined,
904
1004
  appliedTo: appliedToMatch ? appliedToMatch[1].trim() : undefined,
905
1005
  topLevelKeys,
package/lib/lint.test.js CHANGED
@@ -919,4 +919,401 @@ test('falls back to .xdrs subdirectory when given path has no index.md', () => {
919
919
 
920
920
  expect(result.errors).toHaveLength(0);
921
921
  expect(result.xdrsRoot).toBe(path.join(workspaceRoot, '.xdrs'));
922
- });
922
+ });
923
+
924
+ // ─── Emoji checks ─────────────────────────────────────────────────────────────
925
+
926
+ test('reports emoji in XDR document body', () => {
927
+ const workspaceRoot = createWorkspace('xdr-emoji-body', {
928
+ '.xdrs/index.md': rootIndex(),
929
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
930
+ '- [001-main](principles/001-main.md) - Main decision'
931
+ ]),
932
+ '.xdrs/_local/adrs/principles/001-main.md': xdrDocument('This is great \uD83C\uDF89'),
933
+ });
934
+
935
+ const result = lintWorkspace(workspaceRoot);
936
+
937
+ expect(result.errors.join('\n')).toContain('XDR must not contain emojis');
938
+ });
939
+
940
+ test('does not report emoji inside a code block in XDR documents', () => {
941
+ const workspaceRoot = createWorkspace('xdr-emoji-in-code-block', {
942
+ '.xdrs/index.md': rootIndex(),
943
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
944
+ '- [001-main](principles/001-main.md) - Main decision'
945
+ ]),
946
+ '.xdrs/_local/adrs/principles/001-main.md': xdrDocument('```\necho "hello \uD83C\uDF89"\n```'),
947
+ });
948
+
949
+ const result = lintWorkspace(workspaceRoot);
950
+
951
+ expect(result.errors.join('\n')).not.toContain('must not contain emojis');
952
+ });
953
+
954
+ test('reports emoji in SKILL.md', () => {
955
+ const workspaceRoot = createWorkspace('skill-emoji', {
956
+ '.xdrs/index.md': rootIndex(),
957
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
958
+ '- [001-check-links](principles/skills/001-check-links/SKILL.md) - Check links'
959
+ ]),
960
+ '.xdrs/_local/adrs/principles/skills/001-check-links/SKILL.md': skillDocument('Step 1: do something \u2705'),
961
+ });
962
+
963
+ const result = lintWorkspace(workspaceRoot);
964
+
965
+ expect(result.errors.join('\n')).toContain('Skill must not contain emojis');
966
+ });
967
+
968
+ test('reports emoji in article document', () => {
969
+ const workspaceRoot = createWorkspace('article-emoji', {
970
+ '.xdrs/index.md': rootIndex(),
971
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
972
+ '- [001-guide](principles/articles/001-guide.md) - Guide'
973
+ ]),
974
+ '.xdrs/_local/adrs/principles/articles/001-guide.md': articleDocument('Nice overview \uD83D\uDE80'),
975
+ });
976
+
977
+ const result = lintWorkspace(workspaceRoot);
978
+
979
+ expect(result.errors.join('\n')).toContain('Article must not contain emojis');
980
+ });
981
+
982
+ test('reports emoji in research document', () => {
983
+ const workspaceRoot = createWorkspace('research-emoji', {
984
+ '.xdrs/index.md': rootIndex(),
985
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
986
+ '- [001-study](principles/researches/001-study.md) - Study'
987
+ ]),
988
+ '.xdrs/_local/adrs/principles/researches/001-study.md': researchDocument('Some finding \uD83D\uDD2C', 'Question: Is this right?'),
989
+ });
990
+
991
+ const result = lintWorkspace(workspaceRoot);
992
+
993
+ expect(result.errors.join('\n')).toContain('Research must not contain emojis');
994
+ });
995
+
996
+ test('reports emoji in plan document', () => {
997
+ const workspaceRoot = createWorkspace('plan-emoji', {
998
+ '.xdrs/index.md': rootIndex(),
999
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1000
+ '- [001-myplan](principles/plans/001-myplan.md) - My plan'
1001
+ ]),
1002
+ '.xdrs/_local/adrs/principles/plans/001-myplan.md': planDocument('Great plan \uD83C\uDFAF'),
1003
+ });
1004
+
1005
+ const result = lintWorkspace(workspaceRoot);
1006
+
1007
+ expect(result.errors.join('\n')).toContain('Plan must not contain emojis');
1008
+ });
1009
+
1010
+ // ─── Name / description length ────────────────────────────────────────────────
1011
+
1012
+ test('reports XDR frontmatter name exceeding 64 characters', () => {
1013
+ const longName = '_local-adr-001-' + 'a'.repeat(50);
1014
+ const workspaceRoot = createWorkspace('xdr-name-too-long', {
1015
+ '.xdrs/index.md': rootIndex(),
1016
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1017
+ '- [001-main](principles/001-main.md) - Main decision'
1018
+ ]),
1019
+ '.xdrs/_local/adrs/principles/001-main.md': [
1020
+ '---',
1021
+ `name: ${longName}`,
1022
+ 'description: Test XDR document',
1023
+ '---',
1024
+ '',
1025
+ '# _local-adr-001: Main decision',
1026
+ '',
1027
+ '## Context and Problem Statement',
1028
+ '',
1029
+ 'Test body.',
1030
+ '',
1031
+ '## Decision Outcome',
1032
+ '',
1033
+ 'Test decision outcome.',
1034
+ ''
1035
+ ].join('\n'),
1036
+ });
1037
+
1038
+ const result = lintWorkspace(workspaceRoot);
1039
+
1040
+ expect(result.errors.join('\n')).toContain('XDR frontmatter name must be 64 characters or fewer');
1041
+ });
1042
+
1043
+ test('reports XDR frontmatter description exceeding 1024 characters', () => {
1044
+ const workspaceRoot = createWorkspace('xdr-description-too-long', {
1045
+ '.xdrs/index.md': rootIndex(),
1046
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1047
+ '- [001-main](principles/001-main.md) - Main decision'
1048
+ ]),
1049
+ '.xdrs/_local/adrs/principles/001-main.md': [
1050
+ '---',
1051
+ 'name: _local-adr-001-main',
1052
+ `description: ${'x'.repeat(1025)}`,
1053
+ '---',
1054
+ '',
1055
+ '# _local-adr-001: Main decision',
1056
+ '',
1057
+ '## Context and Problem Statement',
1058
+ '',
1059
+ 'Test body.',
1060
+ '',
1061
+ '## Decision Outcome',
1062
+ '',
1063
+ 'Test decision outcome.',
1064
+ ''
1065
+ ].join('\n'),
1066
+ });
1067
+
1068
+ const result = lintWorkspace(workspaceRoot);
1069
+
1070
+ expect(result.errors.join('\n')).toContain('XDR frontmatter description must be 1024 characters or fewer');
1071
+ });
1072
+
1073
+ test('reports SKILL.md frontmatter name exceeding 64 characters', () => {
1074
+ const longName = '001-check-links-' + 'a'.repeat(50);
1075
+ const workspaceRoot = createWorkspace('skill-name-too-long', {
1076
+ '.xdrs/index.md': rootIndex(),
1077
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1078
+ '- [001-check-links](principles/skills/001-check-links/SKILL.md) - Check links'
1079
+ ]),
1080
+ '.xdrs/_local/adrs/principles/skills/001-check-links/SKILL.md': [
1081
+ '---',
1082
+ `name: ${longName}`,
1083
+ 'description: Test skill document',
1084
+ '---',
1085
+ '',
1086
+ '## Overview',
1087
+ '',
1088
+ 'Overview.',
1089
+ '',
1090
+ '## Instructions',
1091
+ '',
1092
+ 'Instructions.',
1093
+ ''
1094
+ ].join('\n'),
1095
+ });
1096
+
1097
+ const result = lintWorkspace(workspaceRoot);
1098
+
1099
+ expect(result.errors.join('\n')).toContain('SKILL.md frontmatter name must be 64 characters or fewer');
1100
+ });
1101
+
1102
+ test('reports SKILL.md frontmatter description exceeding 1024 characters', () => {
1103
+ const workspaceRoot = createWorkspace('skill-description-too-long', {
1104
+ '.xdrs/index.md': rootIndex(),
1105
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1106
+ '- [001-check-links](principles/skills/001-check-links/SKILL.md) - Check links'
1107
+ ]),
1108
+ '.xdrs/_local/adrs/principles/skills/001-check-links/SKILL.md': [
1109
+ '---',
1110
+ 'name: 001-check-links',
1111
+ `description: ${'x'.repeat(1025)}`,
1112
+ '---',
1113
+ '',
1114
+ '## Overview',
1115
+ '',
1116
+ 'Overview.',
1117
+ '',
1118
+ '## Instructions',
1119
+ '',
1120
+ 'Instructions.',
1121
+ ''
1122
+ ].join('\n'),
1123
+ });
1124
+
1125
+ const result = lintWorkspace(workspaceRoot);
1126
+
1127
+ expect(result.errors.join('\n')).toContain('SKILL.md frontmatter description must be 1024 characters or fewer');
1128
+ });
1129
+
1130
+ // ─── Word count ───────────────────────────────────────────────────────────────
1131
+
1132
+ test('reports XDR document exceeding 2600 word limit', () => {
1133
+ const longBody = ('word '.repeat(2601)).trimEnd();
1134
+ const workspaceRoot = createWorkspace('xdr-too-many-words', {
1135
+ '.xdrs/index.md': rootIndex(),
1136
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1137
+ '- [001-main](principles/001-main.md) - Main decision'
1138
+ ]),
1139
+ '.xdrs/_local/adrs/principles/001-main.md': xdrDocument(longBody),
1140
+ });
1141
+
1142
+ const result = lintWorkspace(workspaceRoot);
1143
+
1144
+ expect(result.errors.join('\n')).toContain('XDR exceeds maximum word count of 2600');
1145
+ });
1146
+
1147
+ test('reports article exceeding 5000 word limit', () => {
1148
+ const longBody = ('word '.repeat(5001)).trimEnd();
1149
+ const workspaceRoot = createWorkspace('article-too-many-words', {
1150
+ '.xdrs/index.md': rootIndex(),
1151
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1152
+ '- [001-guide](principles/articles/001-guide.md) - Guide'
1153
+ ]),
1154
+ '.xdrs/_local/adrs/principles/articles/001-guide.md': articleDocument(longBody),
1155
+ });
1156
+
1157
+ const result = lintWorkspace(workspaceRoot);
1158
+
1159
+ expect(result.errors.join('\n')).toContain('Article exceeds maximum word count of 5000');
1160
+ });
1161
+
1162
+ test('reports research exceeding 5000 word limit', () => {
1163
+ const longBody = ('word '.repeat(5001)).trimEnd();
1164
+ const workspaceRoot = createWorkspace('research-too-many-words', {
1165
+ '.xdrs/index.md': rootIndex(),
1166
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1167
+ '- [001-study](principles/researches/001-study.md) - Study'
1168
+ ]),
1169
+ '.xdrs/_local/adrs/principles/researches/001-study.md': researchDocument(longBody, 'Question: What is the answer?'),
1170
+ });
1171
+
1172
+ const result = lintWorkspace(workspaceRoot);
1173
+
1174
+ expect(result.errors.join('\n')).toContain('Research exceeds maximum word count of 5000');
1175
+ });
1176
+
1177
+ test('reports SKILL.md exceeding 6500 word limit', () => {
1178
+ const longBody = ('word '.repeat(6501)).trimEnd();
1179
+ const workspaceRoot = createWorkspace('skill-too-many-words', {
1180
+ '.xdrs/index.md': rootIndex(),
1181
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1182
+ '- [001-check-links](principles/skills/001-check-links/SKILL.md) - Check links'
1183
+ ]),
1184
+ '.xdrs/_local/adrs/principles/skills/001-check-links/SKILL.md': skillDocument(longBody),
1185
+ });
1186
+
1187
+ const result = lintWorkspace(workspaceRoot);
1188
+
1189
+ expect(result.errors.join('\n')).toContain('Skill exceeds maximum word count of 6500');
1190
+ });
1191
+
1192
+ // ─── Files outside .assets in skill package root ────────────────────────────
1193
+
1194
+ test('passes when extra files exist at skill package root', () => {
1195
+ const workspaceRoot = createWorkspace('extra-skill-file', {
1196
+ '.xdrs/index.md': rootIndex(),
1197
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1198
+ '- [001-check-links](principles/skills/001-check-links/SKILL.md) - Check links'
1199
+ ]),
1200
+ '.xdrs/_local/adrs/principles/skills/001-check-links/SKILL.md': skillDocument('Body.'),
1201
+ '.xdrs/_local/adrs/principles/skills/001-check-links/extra.md': 'Allowed extra file',
1202
+ '.xdrs/_local/adrs/principles/skills/001-check-links/schema.json': '{"type":"object"}',
1203
+ });
1204
+
1205
+ const result = lintWorkspace(workspaceRoot);
1206
+
1207
+ expect(result.errors.join('\n')).not.toContain('extra.md');
1208
+ expect(result.errors.join('\n')).not.toContain('schema.json');
1209
+ });
1210
+
1211
+ // ─── Research Question: in Introduction ──────────────────────────────────────
1212
+
1213
+ test('reports research ## Introduction missing Question: line', () => {
1214
+ const workspaceRoot = createWorkspace('research-no-question', {
1215
+ '.xdrs/index.md': rootIndex(),
1216
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1217
+ '- [001-study](principles/researches/001-study.md) - Study'
1218
+ ]),
1219
+ '.xdrs/_local/adrs/principles/researches/001-study.md': researchDocument('No question here.', null),
1220
+ });
1221
+
1222
+ const result = lintWorkspace(workspaceRoot);
1223
+
1224
+ expect(result.errors.join('\n')).toContain('Research ## Introduction must contain a "Question:" line');
1225
+ });
1226
+
1227
+ test('passes when research ## Introduction contains Question: line', () => {
1228
+ const workspaceRoot = createWorkspace('research-with-question', {
1229
+ '.xdrs/index.md': rootIndex(),
1230
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1231
+ '- [001-study](principles/researches/001-study.md) - Study'
1232
+ ]),
1233
+ '.xdrs/_local/adrs/principles/researches/001-study.md': researchDocument('Some context.', 'Question: Is this the right approach?'),
1234
+ });
1235
+
1236
+ const result = lintWorkspace(workspaceRoot);
1237
+
1238
+ expect(result.errors.join('\n')).not.toContain('Research ## Introduction must contain');
1239
+ });
1240
+
1241
+ // ─── New helpers ──────────────────────────────────────────────────────────────
1242
+
1243
+ function articleDocument(body) {
1244
+ return [
1245
+ '# _local-article-001: Guide',
1246
+ '',
1247
+ '## Overview',
1248
+ '',
1249
+ 'Overview text.',
1250
+ '',
1251
+ '## Content',
1252
+ '',
1253
+ body,
1254
+ '',
1255
+ '## References',
1256
+ '',
1257
+ '- No references.',
1258
+ ''
1259
+ ].join('\n');
1260
+ }
1261
+
1262
+ function researchDocument(introBody, questionLine) {
1263
+ const introSection = questionLine
1264
+ ? `${introBody}\n\n${questionLine}`
1265
+ : introBody;
1266
+ return [
1267
+ '# _local-research-001: Study',
1268
+ '',
1269
+ '## Abstract',
1270
+ '',
1271
+ 'Single paragraph abstract.',
1272
+ '',
1273
+ '## Introduction',
1274
+ '',
1275
+ introSection,
1276
+ '',
1277
+ '## Methods',
1278
+ '',
1279
+ 'Study methods.',
1280
+ '',
1281
+ '## Results',
1282
+ '',
1283
+ 'Study results.',
1284
+ '',
1285
+ '## Discussion',
1286
+ '',
1287
+ 'Discussion.',
1288
+ '',
1289
+ '## Conclusion',
1290
+ '',
1291
+ 'Conclusion.',
1292
+ '',
1293
+ '## References',
1294
+ '',
1295
+ '- No references.',
1296
+ ''
1297
+ ].join('\n');
1298
+ }
1299
+
1300
+ function planDocument(body) {
1301
+ return [
1302
+ '# _local-plan-001: My plan',
1303
+ '',
1304
+ '## Executive Summary',
1305
+ '',
1306
+ 'Summary.',
1307
+ '',
1308
+ '## Context and Problem Statement',
1309
+ '',
1310
+ body,
1311
+ '',
1312
+ '## Proposed Solution',
1313
+ '',
1314
+ 'We will fix it.',
1315
+ '',
1316
+ 'Expected end date: 2026-12-31',
1317
+ ''
1318
+ ].join('\n');
1319
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xdrs-core",
3
- "version": "0.22.1",
3
+ "version": "0.24.0",
4
4
  "description": "A standard way to organize Decision Records (XDRs) across scopes, subjects, and teams so that AI agents can reliably query and follow them.",
5
5
  "repository": {
6
6
  "type": "git",