anchormd 0.1.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/LICENSE +21 -0
- package/README.md +132 -0
- package/bin/anchormd +2 -0
- package/package.json +44 -0
- package/skill/SKILL.md +42 -0
- package/src/cli.ts +362 -0
- package/src/config.ts +91 -0
- package/src/entities.ts +125 -0
- package/src/format.ts +134 -0
- package/src/index-graph.ts +123 -0
- package/src/links.ts +71 -0
- package/src/plan.ts +142 -0
- package/src/qmd.ts +136 -0
- package/src/scaffold.ts +97 -0
- package/src/types.ts +73 -0
package/src/entities.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity extraction from plan content
|
|
3
|
+
*
|
|
4
|
+
* Extracts references to:
|
|
5
|
+
* - File paths (strings with / and common extensions)
|
|
6
|
+
* - Models (PascalCase words near model/schema/table/entity keywords)
|
|
7
|
+
* - Routes (HTTP method + path patterns)
|
|
8
|
+
* - Scripts (shell script filenames and npm run commands)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Entity } from './types.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Extract file path references from content.
|
|
15
|
+
* Matches paths containing `/` with common source file extensions.
|
|
16
|
+
*/
|
|
17
|
+
function extractFilePaths(content: string): Entity[] {
|
|
18
|
+
const regex = /(?:^|\s|['"`(])([a-zA-Z0-9._\-/]+\.(?:ts|js|tsx|jsx|py|go|rs|sql|json|yaml|yml|md|css|html|sh|toml|env))\b/gm;
|
|
19
|
+
const entities: Entity[] = [];
|
|
20
|
+
let match: RegExpExecArray | null;
|
|
21
|
+
|
|
22
|
+
while ((match = regex.exec(content)) !== null) {
|
|
23
|
+
const value = match[1];
|
|
24
|
+
// Only include paths that contain a slash (to avoid bare filenames matching too broadly)
|
|
25
|
+
// Exception: script files like deploy.sh are handled by extractScripts
|
|
26
|
+
if (value.includes('/')) {
|
|
27
|
+
entities.push({ type: 'file', value });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return entities;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extract model/schema references from content.
|
|
36
|
+
* Matches PascalCase words near model/schema/table/entity keywords.
|
|
37
|
+
*/
|
|
38
|
+
function extractModels(content: string): Entity[] {
|
|
39
|
+
const entities: Entity[] = [];
|
|
40
|
+
|
|
41
|
+
// Pattern: keyword followed by PascalCase name
|
|
42
|
+
const forwardRegex = /(?:model|schema|table|entity|Model|Schema)\s+([A-Z][a-zA-Z0-9]+)/g;
|
|
43
|
+
let match: RegExpExecArray | null;
|
|
44
|
+
|
|
45
|
+
while ((match = forwardRegex.exec(content)) !== null) {
|
|
46
|
+
entities.push({ type: 'model', value: match[1] });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Pattern: PascalCase name followed by keyword
|
|
50
|
+
const reverseRegex = /([A-Z][a-zA-Z0-9]+)\s+(?:model|schema|table|entity)/g;
|
|
51
|
+
|
|
52
|
+
while ((match = reverseRegex.exec(content)) !== null) {
|
|
53
|
+
entities.push({ type: 'model', value: match[1] });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return entities;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extract HTTP route references from content.
|
|
61
|
+
* Matches HTTP methods followed by URL paths.
|
|
62
|
+
*/
|
|
63
|
+
function extractRoutes(content: string): Entity[] {
|
|
64
|
+
const regex = /\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\/[a-zA-Z0-9/:._\-{}*]+)/g;
|
|
65
|
+
const entities: Entity[] = [];
|
|
66
|
+
let match: RegExpExecArray | null;
|
|
67
|
+
|
|
68
|
+
while ((match = regex.exec(content)) !== null) {
|
|
69
|
+
entities.push({ type: 'route', value: `${match[1]} ${match[2]}` });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return entities;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract script references from content.
|
|
77
|
+
* Matches shell script filenames and npm run commands.
|
|
78
|
+
*/
|
|
79
|
+
function extractScripts(content: string): Entity[] {
|
|
80
|
+
const entities: Entity[] = [];
|
|
81
|
+
|
|
82
|
+
// Shell script filenames
|
|
83
|
+
const scriptRegex = /(?:^|\s|['"`(])([a-zA-Z0-9_\-]+\.(?:sh|bash|zsh))\b/gm;
|
|
84
|
+
let match: RegExpExecArray | null;
|
|
85
|
+
|
|
86
|
+
while ((match = scriptRegex.exec(content)) !== null) {
|
|
87
|
+
entities.push({ type: 'script', value: match[1] });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// npm run commands
|
|
91
|
+
const npmRegex = /npm\s+run\s+([a-zA-Z0-9_\-:]+)/g;
|
|
92
|
+
|
|
93
|
+
while ((match = npmRegex.exec(content)) !== null) {
|
|
94
|
+
entities.push({ type: 'script', value: `npm run ${match[1]}` });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return entities;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Extract all entities from plan content.
|
|
102
|
+
* Combines results from all extractors and deduplicates by type+value.
|
|
103
|
+
*/
|
|
104
|
+
export function extractEntities(content: string): Entity[] {
|
|
105
|
+
const all = [
|
|
106
|
+
...extractFilePaths(content),
|
|
107
|
+
...extractModels(content),
|
|
108
|
+
...extractRoutes(content),
|
|
109
|
+
...extractScripts(content),
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
// Deduplicate by type+value
|
|
113
|
+
const seen = new Set<string>();
|
|
114
|
+
const unique: Entity[] = [];
|
|
115
|
+
|
|
116
|
+
for (const entity of all) {
|
|
117
|
+
const key = `${entity.type}:${entity.value}`;
|
|
118
|
+
if (!seen.has(key)) {
|
|
119
|
+
seen.add(key);
|
|
120
|
+
unique.push(entity);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return unique;
|
|
125
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output formatting with ANSI colors
|
|
3
|
+
*
|
|
4
|
+
* Respects NO_COLOR environment variable (https://no-color.org/)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { PlanFile, PlanStatus, SearchResult } from './types.js';
|
|
8
|
+
|
|
9
|
+
const noColor = 'NO_COLOR' in process.env;
|
|
10
|
+
|
|
11
|
+
function wrap(code: string, s: string): string {
|
|
12
|
+
return noColor ? s : `\x1b[${code}m${s}\x1b[0m`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** ANSI color helpers */
|
|
16
|
+
export const color = {
|
|
17
|
+
bold: (s: string) => wrap('1', s),
|
|
18
|
+
dim: (s: string) => wrap('2', s),
|
|
19
|
+
green: (s: string) => wrap('32', s),
|
|
20
|
+
yellow: (s: string) => wrap('33', s),
|
|
21
|
+
cyan: (s: string) => wrap('36', s),
|
|
22
|
+
red: (s: string) => wrap('31', s),
|
|
23
|
+
blue: (s: string) => wrap('34', s),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Color a status string based on its value */
|
|
27
|
+
function colorStatus(status: PlanStatus): string {
|
|
28
|
+
switch (status) {
|
|
29
|
+
case 'built':
|
|
30
|
+
return color.green(status);
|
|
31
|
+
case 'in-progress':
|
|
32
|
+
return color.yellow(status);
|
|
33
|
+
case 'planned':
|
|
34
|
+
return color.cyan(status);
|
|
35
|
+
case 'deprecated':
|
|
36
|
+
return color.red(status);
|
|
37
|
+
default:
|
|
38
|
+
return status;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Strip ANSI codes for length calculation */
|
|
43
|
+
function stripAnsi(s: string): string {
|
|
44
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Format a single plan as a table row
|
|
49
|
+
*/
|
|
50
|
+
export function formatPlanRow(plan: PlanFile): string {
|
|
51
|
+
const status = colorStatus(plan.frontmatter.status);
|
|
52
|
+
return ` ${color.bold(plan.frontmatter.name)} ${status} ${color.dim(plan.frontmatter.description)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Format a list of plans as a columnar table
|
|
57
|
+
*/
|
|
58
|
+
export function formatPlanTable(plans: PlanFile[]): string {
|
|
59
|
+
if (plans.length === 0) {
|
|
60
|
+
return color.dim(' No plans found.');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Calculate column widths
|
|
64
|
+
const nameWidth = Math.max(...plans.map(p => p.frontmatter.name.length), 4);
|
|
65
|
+
const statusWidth = Math.max(...plans.map(p => p.frontmatter.status.length), 6);
|
|
66
|
+
|
|
67
|
+
const lines: string[] = [];
|
|
68
|
+
|
|
69
|
+
// Header
|
|
70
|
+
const header = ` ${'NAME'.padEnd(nameWidth)} ${'STATUS'.padEnd(statusWidth)} DESCRIPTION`;
|
|
71
|
+
lines.push(color.dim(header));
|
|
72
|
+
lines.push(color.dim(' ' + '-'.repeat(nameWidth + statusWidth + 20)));
|
|
73
|
+
|
|
74
|
+
// Rows
|
|
75
|
+
for (const plan of plans) {
|
|
76
|
+
const name = color.bold(plan.frontmatter.name.padEnd(nameWidth));
|
|
77
|
+
const status = colorStatus(plan.frontmatter.status);
|
|
78
|
+
// Pad status accounting for ANSI codes
|
|
79
|
+
const statusPad = ' '.repeat(Math.max(0, statusWidth - plan.frontmatter.status.length));
|
|
80
|
+
const desc = color.dim(plan.frontmatter.description);
|
|
81
|
+
lines.push(` ${name} ${status}${statusPad} ${desc}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return lines.join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Format search results as a numbered list
|
|
89
|
+
*/
|
|
90
|
+
export function formatSearchResults(results: SearchResult[]): string {
|
|
91
|
+
if (results.length === 0) {
|
|
92
|
+
return color.dim(' No results found.');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const lines: string[] = [];
|
|
96
|
+
|
|
97
|
+
for (let i = 0; i < results.length; i++) {
|
|
98
|
+
const r = results[i];
|
|
99
|
+
const num = color.dim(`${i + 1}.`);
|
|
100
|
+
const score = color.yellow(`[${r.score.toFixed(3)}]`);
|
|
101
|
+
const path = color.bold(r.path);
|
|
102
|
+
lines.push(` ${num} ${score} ${path}`);
|
|
103
|
+
|
|
104
|
+
if (r.content) {
|
|
105
|
+
// Show a truncated preview of the content
|
|
106
|
+
const preview = r.content.substring(0, 120).replace(/\n/g, ' ').trim();
|
|
107
|
+
lines.push(` ${color.dim(preview)}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return lines.join('\n');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Format project status information
|
|
116
|
+
*/
|
|
117
|
+
export function formatStatus(stats: {
|
|
118
|
+
planCount: number;
|
|
119
|
+
linkCount: number;
|
|
120
|
+
weakEdgeCount: number;
|
|
121
|
+
qmdEnabled: boolean;
|
|
122
|
+
}): string {
|
|
123
|
+
const lines = [
|
|
124
|
+
color.bold('AnchorMD Status'),
|
|
125
|
+
'',
|
|
126
|
+
` Plans: ${color.cyan(String(stats.planCount))}`,
|
|
127
|
+
` Links: ${color.cyan(String(stats.linkCount))}`,
|
|
128
|
+
` Weak edges: ${color.cyan(String(stats.weakEdgeCount))}`,
|
|
129
|
+
` QMD search: ${stats.qmdEnabled ? color.green('enabled') : color.yellow('disabled')}`,
|
|
130
|
+
'',
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
return lines.join('\n');
|
|
134
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Index graph builder and I/O
|
|
3
|
+
*
|
|
4
|
+
* Builds a graph of plan relationships from:
|
|
5
|
+
* - Strong links: explicit [[plan]] references
|
|
6
|
+
* - Weak edges: shared entities between plans (same file, model, route, or script)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { listPlans } from './plan.js';
|
|
12
|
+
import { parseLinks, getStrongLinks } from './links.js';
|
|
13
|
+
import { extractEntities } from './entities.js';
|
|
14
|
+
import { getPlansDir, getAnchorDir } from './config.js';
|
|
15
|
+
import type { IndexGraph, IndexGraphNode, Entity } from './types.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build the index graph from all plans in the plans directory.
|
|
19
|
+
*
|
|
20
|
+
* For each plan:
|
|
21
|
+
* 1. Extract strong links (explicit [[references]])
|
|
22
|
+
* 2. Extract entities (file paths, models, routes, scripts)
|
|
23
|
+
* 3. Compute weak edges (plans sharing the same entity)
|
|
24
|
+
*/
|
|
25
|
+
export function buildIndex(plansDir: string): IndexGraph {
|
|
26
|
+
const plans = listPlans(plansDir);
|
|
27
|
+
|
|
28
|
+
// Map of entityKey -> list of plan names that reference it
|
|
29
|
+
const entityToPlanMap = new Map<string, string[]>();
|
|
30
|
+
|
|
31
|
+
// Build initial nodes and populate the entity map
|
|
32
|
+
const nodes: Record<string, IndexGraphNode> = {};
|
|
33
|
+
|
|
34
|
+
for (const plan of plans) {
|
|
35
|
+
const name = plan.frontmatter.name;
|
|
36
|
+
const fullContent = plan.body;
|
|
37
|
+
|
|
38
|
+
// Extract strong links (target names)
|
|
39
|
+
const links = parseLinks(fullContent);
|
|
40
|
+
const strongLinks = getStrongLinks(links).map(l => l.target);
|
|
41
|
+
|
|
42
|
+
// Extract entities
|
|
43
|
+
const entities = extractEntities(fullContent);
|
|
44
|
+
|
|
45
|
+
// Register entities in the map
|
|
46
|
+
for (const entity of entities) {
|
|
47
|
+
const key = `${entity.type}:${entity.value}`;
|
|
48
|
+
const existing = entityToPlanMap.get(key) || [];
|
|
49
|
+
if (!existing.includes(name)) {
|
|
50
|
+
existing.push(name);
|
|
51
|
+
entityToPlanMap.set(key, existing);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
nodes[name] = {
|
|
56
|
+
name,
|
|
57
|
+
links: strongLinks,
|
|
58
|
+
entities,
|
|
59
|
+
weakEdges: [], // Filled in below
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Compute weak edges from shared entities
|
|
64
|
+
for (const planNames of entityToPlanMap.values()) {
|
|
65
|
+
if (planNames.length < 2) continue;
|
|
66
|
+
|
|
67
|
+
// All plans sharing this entity get weak edges to each other
|
|
68
|
+
for (const planA of planNames) {
|
|
69
|
+
for (const planB of planNames) {
|
|
70
|
+
if (planA === planB) continue;
|
|
71
|
+
const node = nodes[planA];
|
|
72
|
+
if (node && !node.weakEdges.includes(planB)) {
|
|
73
|
+
node.weakEdges.push(planB);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
nodes,
|
|
81
|
+
lastBuilt: new Date().toISOString(),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Write the index graph to .anchor/index.json
|
|
87
|
+
*/
|
|
88
|
+
export function writeIndex(anchorDir: string, graph: IndexGraph): void {
|
|
89
|
+
mkdirSync(anchorDir, { recursive: true });
|
|
90
|
+
const indexPath = path.join(anchorDir, 'index.json');
|
|
91
|
+
writeFileSync(indexPath, JSON.stringify(graph, null, 2) + '\n', 'utf-8');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read the index graph from .anchor/index.json
|
|
96
|
+
* Returns null if the file doesn't exist.
|
|
97
|
+
*/
|
|
98
|
+
export function readIndex(anchorDir: string): IndexGraph | null {
|
|
99
|
+
const indexPath = path.join(anchorDir, 'index.json');
|
|
100
|
+
|
|
101
|
+
if (!existsSync(indexPath)) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const raw = readFileSync(indexPath, 'utf-8');
|
|
107
|
+
return JSON.parse(raw) as IndexGraph;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Rebuild the index and write it to disk.
|
|
115
|
+
* Returns the built graph.
|
|
116
|
+
*/
|
|
117
|
+
export function rebuildAndWriteIndex(projectRoot: string): IndexGraph {
|
|
118
|
+
const plansDir = getPlansDir(projectRoot);
|
|
119
|
+
const anchorDir = getAnchorDir(projectRoot);
|
|
120
|
+
const graph = buildIndex(plansDir);
|
|
121
|
+
writeIndex(anchorDir, graph);
|
|
122
|
+
return graph;
|
|
123
|
+
}
|
package/src/links.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link parser for [[wiki-style]] links in plan content
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* - Strong links: [[target]]
|
|
6
|
+
* - Deep links: [[target#section]]
|
|
7
|
+
* - Multi-hash deep links: [[target#section#subsection]]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Link, StrongLink, DeepLink } from './types.js';
|
|
11
|
+
|
|
12
|
+
const LINK_REGEX = /\[\[([^\]]+)\]\]/g;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Type guard: returns true if the link has a section (is a DeepLink)
|
|
16
|
+
*/
|
|
17
|
+
export function isDeepLink(link: Link): link is DeepLink {
|
|
18
|
+
return 'section' in link;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse all [[wiki-style]] links from content.
|
|
23
|
+
* Returns deduplicated array of StrongLink and DeepLink objects.
|
|
24
|
+
*/
|
|
25
|
+
export function parseLinks(content: string): Link[] {
|
|
26
|
+
const seen = new Set<string>();
|
|
27
|
+
const links: Link[] = [];
|
|
28
|
+
|
|
29
|
+
let match: RegExpExecArray | null;
|
|
30
|
+
// Reset regex state
|
|
31
|
+
LINK_REGEX.lastIndex = 0;
|
|
32
|
+
|
|
33
|
+
while ((match = LINK_REGEX.exec(content)) !== null) {
|
|
34
|
+
const raw = match[1];
|
|
35
|
+
const hashIndex = raw.indexOf('#');
|
|
36
|
+
|
|
37
|
+
let link: Link;
|
|
38
|
+
let key: string;
|
|
39
|
+
|
|
40
|
+
if (hashIndex !== -1) {
|
|
41
|
+
const target = raw.substring(0, hashIndex);
|
|
42
|
+
const section = raw.substring(hashIndex + 1);
|
|
43
|
+
link = { target, section };
|
|
44
|
+
key = `${target}#${section}`;
|
|
45
|
+
} else {
|
|
46
|
+
link = { target: raw };
|
|
47
|
+
key = raw;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!seen.has(key)) {
|
|
51
|
+
seen.add(key);
|
|
52
|
+
links.push(link);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return links;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Filter links to only strong links (no section)
|
|
61
|
+
*/
|
|
62
|
+
export function getStrongLinks(links: Link[]): StrongLink[] {
|
|
63
|
+
return links.filter((link): link is StrongLink => !isDeepLink(link));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Filter links to only deep links (with section)
|
|
68
|
+
*/
|
|
69
|
+
export function getDeepLinks(links: Link[]): DeepLink[] {
|
|
70
|
+
return links.filter(isDeepLink);
|
|
71
|
+
}
|
package/src/plan.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan file parsing and I/O
|
|
3
|
+
*
|
|
4
|
+
* Plans are markdown files with YAML frontmatter:
|
|
5
|
+
* ---
|
|
6
|
+
* name: plan-name
|
|
7
|
+
* description: What this plan covers
|
|
8
|
+
* status: planned
|
|
9
|
+
* tags: [optional, tags]
|
|
10
|
+
* ---
|
|
11
|
+
* # Body content here
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync } from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { parse as yamlParse, stringify as yamlStringify } from 'yaml';
|
|
17
|
+
import type { PlanFrontmatter, PlanFile, PlanStatus } from './types.js';
|
|
18
|
+
import { VALID_STATUSES } from './types.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse frontmatter and body from raw plan content.
|
|
22
|
+
* Expects content starting with `---` delimiter.
|
|
23
|
+
*/
|
|
24
|
+
export function parseFrontmatter(raw: string): { frontmatter: PlanFrontmatter; body: string } {
|
|
25
|
+
const trimmed = raw.trim();
|
|
26
|
+
|
|
27
|
+
if (!trimmed.startsWith('---')) {
|
|
28
|
+
throw new Error('Plan file must start with --- frontmatter delimiter');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Find the closing --- delimiter
|
|
32
|
+
const secondDelimiter = trimmed.indexOf('---', 3);
|
|
33
|
+
if (secondDelimiter === -1) {
|
|
34
|
+
throw new Error('Plan file missing closing --- frontmatter delimiter');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const yamlContent = trimmed.substring(3, secondDelimiter).trim();
|
|
38
|
+
const body = trimmed.substring(secondDelimiter + 3).replace(/^\n/, '');
|
|
39
|
+
|
|
40
|
+
const parsed = yamlParse(yamlContent);
|
|
41
|
+
|
|
42
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
43
|
+
throw new Error('Invalid frontmatter YAML');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!parsed.name || typeof parsed.name !== 'string') {
|
|
47
|
+
throw new Error('Frontmatter missing required field: name');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!parsed.description || typeof parsed.description !== 'string') {
|
|
51
|
+
throw new Error('Frontmatter missing required field: description');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!parsed.status || typeof parsed.status !== 'string') {
|
|
55
|
+
throw new Error('Frontmatter missing required field: status');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!VALID_STATUSES.includes(parsed.status as PlanStatus)) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Invalid status "${parsed.status}". Must be one of: ${VALID_STATUSES.join(', ')}`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const frontmatter: PlanFrontmatter = {
|
|
65
|
+
name: parsed.name,
|
|
66
|
+
description: parsed.description,
|
|
67
|
+
status: parsed.status as PlanStatus,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (parsed.tags && Array.isArray(parsed.tags)) {
|
|
71
|
+
frontmatter.tags = parsed.tags;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { frontmatter, body };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Serialize a plan to its string representation (frontmatter + body)
|
|
79
|
+
*/
|
|
80
|
+
export function serializePlan(frontmatter: PlanFrontmatter, body: string): string {
|
|
81
|
+
const yamlStr = yamlStringify(frontmatter);
|
|
82
|
+
return `---\n${yamlStr}---\n${body}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Read and parse a single plan file by name (without .md extension)
|
|
87
|
+
*/
|
|
88
|
+
export function readPlan(plansDir: string, name: string): PlanFile {
|
|
89
|
+
const filePath = path.join(plansDir, name + '.md');
|
|
90
|
+
|
|
91
|
+
if (!existsSync(filePath)) {
|
|
92
|
+
throw new Error(`Plan not found: ${name} (looked for ${filePath})`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
96
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
frontmatter,
|
|
100
|
+
body,
|
|
101
|
+
filename: name + '.md',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Write plan content to a file in the plans directory
|
|
107
|
+
*/
|
|
108
|
+
export function writePlan(plansDir: string, name: string, content: string): void {
|
|
109
|
+
mkdirSync(plansDir, { recursive: true });
|
|
110
|
+
const filePath = path.join(plansDir, name + '.md');
|
|
111
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* List and parse all plan files in the plans directory
|
|
116
|
+
*/
|
|
117
|
+
export function listPlans(plansDir: string): PlanFile[] {
|
|
118
|
+
if (!existsSync(plansDir)) {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const files = readdirSync(plansDir).filter(f => f.endsWith('.md')).sort();
|
|
123
|
+
const plans: PlanFile[] = [];
|
|
124
|
+
|
|
125
|
+
for (const file of files) {
|
|
126
|
+
const filePath = path.join(plansDir, file);
|
|
127
|
+
try {
|
|
128
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
129
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
130
|
+
plans.push({
|
|
131
|
+
frontmatter,
|
|
132
|
+
body,
|
|
133
|
+
filename: file,
|
|
134
|
+
});
|
|
135
|
+
} catch {
|
|
136
|
+
// Skip files that fail to parse (e.g., malformed frontmatter)
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return plans;
|
|
142
|
+
}
|