cdspec 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.
Files changed (73) hide show
  1. package/AGENTS.md +14 -0
  2. package/CLAUDE.md +10 -0
  3. package/README.md +55 -0
  4. package/cdspec.config.yaml +34 -0
  5. package/dist/cli.js +94 -0
  6. package/dist/config/default.js +48 -0
  7. package/dist/config/loader.js +30 -0
  8. package/dist/config/path.js +11 -0
  9. package/dist/config/types.js +1 -0
  10. package/dist/skill-core/adapters/claudecode-adapter.js +35 -0
  11. package/dist/skill-core/adapters/codex-adapter.js +28 -0
  12. package/dist/skill-core/adapters/iflow-adapter.js +39 -0
  13. package/dist/skill-core/adapters/index.js +34 -0
  14. package/dist/skill-core/adapters/shared.js +36 -0
  15. package/dist/skill-core/agent-config.js +40 -0
  16. package/dist/skill-core/manifest-loader.js +63 -0
  17. package/dist/skill-core/scaffold.js +169 -0
  18. package/dist/skill-core/service.js +156 -0
  19. package/dist/skill-core/tool-interactions.js +70 -0
  20. package/dist/skill-core/types.js +1 -0
  21. package/dist/skill-core/validator.js +25 -0
  22. package/dist/task-core/parser.js +70 -0
  23. package/dist/task-core/service.js +28 -0
  24. package/dist/task-core/storage.js +159 -0
  25. package/dist/task-core/types.js +1 -0
  26. package/dist/utils/frontmatter.js +40 -0
  27. package/dist/utils/fs.js +37 -0
  28. package/package.json +29 -0
  29. package/src/cli.ts +105 -0
  30. package/src/config/default.ts +51 -0
  31. package/src/config/loader.ts +37 -0
  32. package/src/config/path.ts +13 -0
  33. package/src/config/types.ts +22 -0
  34. package/src/skill-core/adapters/claudecode-adapter.ts +45 -0
  35. package/src/skill-core/adapters/codex-adapter.ts +36 -0
  36. package/src/skill-core/adapters/iflow-adapter.ts +49 -0
  37. package/src/skill-core/adapters/index.ts +39 -0
  38. package/src/skill-core/adapters/shared.ts +45 -0
  39. package/src/skill-core/manifest-loader.ts +79 -0
  40. package/src/skill-core/scaffold.ts +192 -0
  41. package/src/skill-core/service.ts +199 -0
  42. package/src/skill-core/tool-interactions.ts +95 -0
  43. package/src/skill-core/types.ts +22 -0
  44. package/src/skill-core/validator.ts +28 -0
  45. package/src/task-core/parser.ts +89 -0
  46. package/src/task-core/service.ts +49 -0
  47. package/src/task-core/storage.ts +177 -0
  48. package/src/task-core/types.ts +15 -0
  49. package/src/types/yaml.d.ts +4 -0
  50. package/src/utils/frontmatter.ts +55 -0
  51. package/src/utils/fs.ts +41 -0
  52. package/templates/design-doc/SKILL.md +99 -0
  53. package/templates/design-doc/agents/openai.yaml +4 -0
  54. package/templates/design-doc/references//345/237/272/347/272/277/346/250/241/346/235/277.md +46 -0
  55. package/templates/design-doc/references//345/242/236/351/207/217/351/234/200/346/261/202/346/250/241/346/235/277.md +32 -0
  56. package/templates/design-doc/references//345/275/222/346/241/243/346/243/200/346/237/245/346/270/205/345/215/225.md +15 -0
  57. package/templates/design-doc/references//347/224/237/344/272/247/345/267/245/345/215/225/345/237/272/347/272/277/347/244/272/344/276/213.md +470 -0
  58. package/templates/design-doc/scripts/validate_doc_layout.sh +49 -0
  59. package/templates/frontend-develop-standard/SKILL.md +63 -0
  60. package/templates/frontend-develop-standard/agents/openai.yaml +4 -0
  61. package/templates/frontend-develop-standard/references/frontend_develop_standard.md +749 -0
  62. package/templates/standards-backend/SKILL.md +55 -0
  63. package/templates/standards-backend/agents/openai.yaml +4 -0
  64. package/templates/standards-backend/references/DDD/346/236/266/346/236/204/347/272/246/346/235/237.md +103 -0
  65. package/templates/standards-backend/references/JUC/345/271/266/345/217/221/350/247/204/350/214/203.md +232 -0
  66. package/templates/standards-backend/references//344/274/240/347/273/237/344/270/211/345/261/202/346/236/266/346/236/204/347/272/246/346/235/237.md +35 -0
  67. package/templates/standards-backend/references//345/220/216/347/253/257/345/274/200/345/217/221/350/247/204/350/214/203.md +49 -0
  68. package/templates/standards-backend/references//346/225/260/346/215/256/345/272/223/350/256/276/350/256/241/350/247/204/350/214/203.md +116 -0
  69. package/templates/standards-backend/references//350/256/276/350/256/241/346/250/241/345/274/217/350/220/275/345/234/260/346/211/213/345/206/214.md +395 -0
  70. package/tests/skill.test.ts +191 -0
  71. package/tests/task.test.ts +55 -0
  72. package/tsconfig.json +16 -0
  73. package/vitest.config.ts +9 -0
@@ -0,0 +1,169 @@
1
+ import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { parseFrontmatter } from "../utils/frontmatter.js";
6
+ import { ensureDir, listDirs, pathExists } from "../utils/fs.js";
7
+ const DEFAULT_SKILL_NAME = "openspec-core";
8
+ const BUILTIN_TEMPLATE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../templates/default-skill");
9
+ export async function cleanupLegacyDefaultSkillDir(cwd) {
10
+ await rm(path.join(cwd, ".codex", DEFAULT_SKILL_NAME), { recursive: true, force: true });
11
+ await rm(path.join(cwd, ".cdspec", "seed-skills"), { recursive: true, force: true });
12
+ }
13
+ export async function loadDefaultSkillManifest(cwd) {
14
+ const projectTemplateManifests = await loadProjectTemplateManifests(cwd);
15
+ if (projectTemplateManifests.length > 0)
16
+ return projectTemplateManifests[0];
17
+ if (await pathExists(path.join(BUILTIN_TEMPLATE_DIR, "SKILL.md"))) {
18
+ return buildManifestFromDir(BUILTIN_TEMPLATE_DIR);
19
+ }
20
+ const tempDir = await buildFallbackTemplateInTemp();
21
+ return buildManifestFromDir(tempDir);
22
+ }
23
+ export async function loadProjectTemplateManifests(cwd) {
24
+ return loadFromProjectTemplates(path.join(cwd, "templates"));
25
+ }
26
+ async function buildManifestFromDir(skillDir) {
27
+ const raw = await readFile(path.join(skillDir, "SKILL.md"), "utf8");
28
+ const parsed = parseFrontmatter(raw);
29
+ const name = parsed.attributes.name?.trim() || DEFAULT_SKILL_NAME;
30
+ const description = parsed.attributes.description?.trim() || "OpenSpec default skill";
31
+ const agents = await collectRelativeFiles(path.join(skillDir, "agents"));
32
+ const references = await collectRelativeFiles(path.join(skillDir, "references"));
33
+ const scripts = await collectRelativeFiles(path.join(skillDir, "scripts"));
34
+ const assets = await collectRelativeFiles(path.join(skillDir, "assets"));
35
+ return {
36
+ name,
37
+ description,
38
+ body: parsed.body.trim(),
39
+ agents,
40
+ resources: [...references, ...scripts, ...assets],
41
+ sourcePath: skillDir
42
+ };
43
+ }
44
+ async function collectRelativeFiles(dirPath) {
45
+ if (!(await pathExists(dirPath)))
46
+ return [];
47
+ const base = path.dirname(dirPath);
48
+ const result = [];
49
+ const dirs = [dirPath];
50
+ while (dirs.length > 0) {
51
+ const current = dirs.pop();
52
+ const children = await listDirs(current);
53
+ for (const child of children) {
54
+ dirs.push(path.join(current, child));
55
+ }
56
+ const entries = await readdir(current, { withFileTypes: true });
57
+ for (const entry of entries) {
58
+ if (!entry.isFile())
59
+ continue;
60
+ const fullPath = path.join(current, entry.name);
61
+ result.push(path.relative(base, fullPath).replaceAll("\\", "/"));
62
+ }
63
+ }
64
+ return result;
65
+ }
66
+ async function buildFallbackTemplateInTemp() {
67
+ const root = await mkdtemp(path.join(os.tmpdir(), "cdspec-skill-"));
68
+ await ensureDir(path.join(root, "agents"));
69
+ await ensureDir(path.join(root, "references"));
70
+ const skillMd = [
71
+ "---",
72
+ "name: openspec-core",
73
+ "description: OpenSpec-style change workflow skill. Use for proposing, exploring, applying, and archiving spec-driven changes.",
74
+ "---",
75
+ "",
76
+ "# OpenSpec Core Skill",
77
+ "",
78
+ "1. /opsx-propose",
79
+ "2. /opsx-explore",
80
+ "3. /opsx-apply",
81
+ "4. /opsx-archive"
82
+ ].join("\n");
83
+ const openaiYaml = [
84
+ "interface:",
85
+ ' display_name: "OpenSpec Core"',
86
+ ' short_description: "Spec-driven change workflow with propose/explore/apply/archive"',
87
+ ' default_prompt: "Use $openspec-core to run OpenSpec-style workflows in this repository."'
88
+ ].join("\n");
89
+ await writeFile(path.join(root, "SKILL.md"), `${skillMd}\n`, "utf8");
90
+ await writeFile(path.join(root, "agents", "openai.yaml"), `${openaiYaml}\n`, "utf8");
91
+ await writeFile(path.join(root, "references", "project_notes.md"), "# OpenSpec Workflow Notes\n");
92
+ return root;
93
+ }
94
+ async function loadFromProjectTemplates(templateRoot) {
95
+ if (!(await pathExists(templateRoot)))
96
+ return [];
97
+ const manifests = [];
98
+ const childDirs = await listDirs(templateRoot);
99
+ for (const child of childDirs) {
100
+ const full = path.join(templateRoot, child);
101
+ if (await pathExists(path.join(full, "SKILL.md"))) {
102
+ manifests.push(await buildManifestFromDir(full));
103
+ }
104
+ }
105
+ if (manifests.length > 0)
106
+ return manifests;
107
+ const markdownFiles = await collectMarkdownFiles(templateRoot);
108
+ for (const selected of markdownFiles.sort()) {
109
+ manifests.push(await buildManifestFromMarkdown(templateRoot, selected));
110
+ }
111
+ return manifests;
112
+ }
113
+ async function buildManifestFromMarkdown(templateRoot, selected) {
114
+ const raw = await readFile(selected, "utf8");
115
+ const parsed = parseFrontmatter(raw);
116
+ const baseName = path.basename(selected, path.extname(selected));
117
+ const skillName = normalizeSkillName(parsed.attributes.name || baseName);
118
+ const description = parsed.attributes.description ||
119
+ `Generated from templates/${path.relative(templateRoot, selected).replaceAll("\\", "/")}`;
120
+ const body = parsed.body.trim() || raw.trim();
121
+ const tmp = await mkdtemp(path.join(os.tmpdir(), "cdspec-template-"));
122
+ await ensureDir(path.join(tmp, "agents"));
123
+ await ensureDir(path.join(tmp, "references"));
124
+ const skillMd = ["---", `name: ${skillName}`, `description: ${description}`, "---", "", body].join("\n");
125
+ const openaiYaml = [
126
+ "interface:",
127
+ ` display_name: "${humanizeName(skillName)}"`,
128
+ ` short_description: "${escapeYaml(description)}"`,
129
+ ` default_prompt: "Use $${skillName} to follow this template skill."`
130
+ ].join("\n");
131
+ await writeFile(path.join(tmp, "SKILL.md"), `${skillMd}\n`, "utf8");
132
+ await writeFile(path.join(tmp, "agents", "openai.yaml"), `${openaiYaml}\n`, "utf8");
133
+ await writeFile(path.join(tmp, "references", path.basename(selected)), raw, "utf8");
134
+ return buildManifestFromDir(tmp);
135
+ }
136
+ async function collectMarkdownFiles(root) {
137
+ const files = [];
138
+ const queue = [root];
139
+ while (queue.length > 0) {
140
+ const current = queue.pop();
141
+ const dirs = await listDirs(current);
142
+ for (const dir of dirs) {
143
+ queue.push(path.join(current, dir));
144
+ }
145
+ const entries = await readdir(current, { withFileTypes: true });
146
+ for (const entry of entries) {
147
+ if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
148
+ files.push(path.join(current, entry.name));
149
+ }
150
+ }
151
+ }
152
+ return files;
153
+ }
154
+ function normalizeSkillName(input) {
155
+ return input
156
+ .toLowerCase()
157
+ .replace(/[^a-z0-9]+/g, "-")
158
+ .replace(/^-+|-+$/g, "") || DEFAULT_SKILL_NAME;
159
+ }
160
+ function humanizeName(input) {
161
+ return input
162
+ .split("-")
163
+ .filter(Boolean)
164
+ .map((part) => part[0].toUpperCase() + part.slice(1))
165
+ .join(" ");
166
+ }
167
+ function escapeYaml(value) {
168
+ return value.replace(/"/g, '\\"');
169
+ }
@@ -0,0 +1,156 @@
1
+ import path from "node:path";
2
+ import { rm } from "node:fs/promises";
3
+ import { loadConfig } from "../config/loader.js";
4
+ import { resolveAgentRoot } from "../config/path.js";
5
+ import { loadAllSkillManifests, loadSkillManifestByName } from "./manifest-loader.js";
6
+ import { expandTargets, getAdapter } from "./adapters/index.js";
7
+ import { installToolInteractionTemplates, writeSharedAgentsStub } from "./tool-interactions.js";
8
+ import { cleanupLegacyDefaultSkillDir, loadDefaultSkillManifest, loadProjectTemplateManifests } from "./scaffold.js";
9
+ import { validateManifest } from "./validator.js";
10
+ export async function listSkills(cwd) {
11
+ const manifests = await loadAllSkillManifests(cwd);
12
+ return manifests.map((manifest) => manifest.name).sort();
13
+ }
14
+ export async function addSkill(cwd, name, targetRaw, force) {
15
+ const config = await loadConfig(cwd);
16
+ let manifest = await loadSkillManifestByName(cwd, name);
17
+ if (!manifest) {
18
+ const all = await loadAllSkillManifests(cwd);
19
+ manifest = all.find((item) => item.name === name) ?? null;
20
+ }
21
+ if (!manifest) {
22
+ throw new Error(`Skill "${name}" not found under .codex/.`);
23
+ }
24
+ const diagnostics = [...validateManifest(manifest)];
25
+ const targets = expandTargets(targetRaw);
26
+ for (const target of targets) {
27
+ diagnostics.push(...getAdapter(target).validate(manifest));
28
+ }
29
+ failIfErrors(diagnostics);
30
+ for (const target of targets) {
31
+ const adapter = getAdapter(target);
32
+ const outDir = path.join(resolveAgentRoot(cwd, config.agents[target].rootDir), "skills", manifest.name);
33
+ await adapter.emit(manifest, outDir, force);
34
+ }
35
+ }
36
+ export async function syncSkills(cwd, targetRaw, force) {
37
+ const config = await loadConfig(cwd);
38
+ const manifests = await loadAllSkillManifests(cwd);
39
+ if (manifests.length === 0) {
40
+ throw new Error("No skills found under .codex/.");
41
+ }
42
+ const targets = expandTargets(targetRaw);
43
+ for (const manifest of manifests) {
44
+ const diagnostics = [...validateManifest(manifest)];
45
+ for (const target of targets) {
46
+ diagnostics.push(...getAdapter(target).validate(manifest));
47
+ }
48
+ failIfErrors(diagnostics);
49
+ }
50
+ for (const manifest of manifests) {
51
+ for (const target of targets) {
52
+ const outDir = path.join(resolveAgentRoot(cwd, config.agents[target].rootDir), "skills", manifest.name);
53
+ await getAdapter(target).emit(manifest, outDir, force);
54
+ }
55
+ }
56
+ }
57
+ export async function initSkills(cwd, agentsRaw, force) {
58
+ await cleanupLegacyDefaultSkillDir(cwd);
59
+ const baseConfig = await loadConfig(cwd);
60
+ const projectTemplateManifests = await loadProjectTemplateManifests(cwd);
61
+ let manifests = projectTemplateManifests.length > 0
62
+ ? projectTemplateManifests
63
+ : await loadAllSkillManifests(cwd);
64
+ if (manifests.length === 0)
65
+ manifests = [await loadDefaultSkillManifest(cwd)];
66
+ const config = buildInitConfig(baseConfig, manifests);
67
+ const targets = expandTargets(agentsRaw);
68
+ if (force) {
69
+ for (const target of targets) {
70
+ const root = resolveAgentRoot(cwd, config.agents[target].rootDir);
71
+ await rm(path.join(root, "skills"), {
72
+ recursive: true,
73
+ force: true
74
+ });
75
+ await rm(path.join(root, config.agents[target].commandsDir), {
76
+ recursive: true,
77
+ force: true
78
+ });
79
+ const guide = config.agents[target].guideAtProjectRoot
80
+ ? path.join(cwd, config.agents[target].guideFile)
81
+ : path.join(root, config.agents[target].guideFile);
82
+ await rm(guide, { force: true });
83
+ }
84
+ }
85
+ for (const manifest of manifests) {
86
+ const diagnostics = [...validateManifest(manifest)];
87
+ for (const target of targets) {
88
+ diagnostics.push(...getAdapter(target).validate(manifest));
89
+ }
90
+ failIfErrors(diagnostics);
91
+ }
92
+ for (const manifest of manifests) {
93
+ for (const target of targets) {
94
+ const outDir = path.join(resolveAgentRoot(cwd, config.agents[target].rootDir), "skills", manifest.name);
95
+ await getAdapter(target).emit(manifest, outDir, force);
96
+ }
97
+ }
98
+ const files = [];
99
+ for (const target of targets) {
100
+ files.push(...(await installToolInteractionTemplates(cwd, target, manifests, config)));
101
+ }
102
+ files.push(await writeSharedAgentsStub(cwd, targets, manifests, config));
103
+ return files;
104
+ }
105
+ function buildInitConfig(config, manifests) {
106
+ const available = new Set(manifests.map((item) => item.name));
107
+ const skillDrivenBindings = buildSkillDrivenBindings(manifests);
108
+ return {
109
+ ...config,
110
+ commandBindings: skillDrivenBindings.length > 0
111
+ ? skillDrivenBindings
112
+ : config.commandBindings.map((item) => available.has(item.skill) ? item : { ...item, skill: manifests[0]?.name || item.skill }),
113
+ agents: {
114
+ codex: { ...config.agents.codex, commandFilePattern: "{id}.md", slashPattern: "/{id}" },
115
+ claudecode: {
116
+ ...config.agents.claudecode,
117
+ commandFilePattern: "{id}.md",
118
+ slashPattern: "/{id}"
119
+ },
120
+ iflow: { ...config.agents.iflow, commandFilePattern: "{id}.md", slashPattern: "/{id}" }
121
+ }
122
+ };
123
+ }
124
+ function buildSkillDrivenBindings(manifests) {
125
+ const used = new Set();
126
+ return manifests.map((manifest) => {
127
+ const base = commandIdForSkillName(manifest.name);
128
+ let id = base;
129
+ let i = 2;
130
+ while (used.has(id)) {
131
+ id = `${base}${i}`;
132
+ i += 1;
133
+ }
134
+ used.add(id);
135
+ return {
136
+ id,
137
+ skill: manifest.name,
138
+ description: `Run skill ${manifest.name}`
139
+ };
140
+ });
141
+ }
142
+ function commandIdForSkillName(skillName) {
143
+ const normalizedName = skillName
144
+ .toLowerCase()
145
+ .replace(/[^a-z0-9]+/g, "-")
146
+ .replace(/^-+|-+$/g, "");
147
+ return `cd-${normalizedName || "skill"}`;
148
+ }
149
+ function failIfErrors(diagnostics) {
150
+ const warnings = diagnostics.filter((item) => item.level === "warning");
151
+ warnings.forEach((warning) => console.warn(`[warn] ${warning.message}`));
152
+ const errors = diagnostics.filter((item) => item.level === "error");
153
+ if (errors.length > 0) {
154
+ throw new Error(errors.map((error) => error.message).join("\n"));
155
+ }
156
+ }
@@ -0,0 +1,70 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { resolveAgentRoot } from "../config/path.js";
4
+ import { ensureDir } from "../utils/fs.js";
5
+ export async function installToolInteractionTemplates(cwd, target, skills, config) {
6
+ const agent = config.agents[target];
7
+ const root = resolveAgentRoot(cwd, agent.rootDir);
8
+ const commandsDir = path.join(root, agent.commandsDir);
9
+ await ensureDir(commandsDir);
10
+ const created = [];
11
+ for (const binding of config.commandBindings) {
12
+ const fileName = agent.commandFilePattern.replace("{id}", binding.id);
13
+ const file = path.join(commandsDir, fileName);
14
+ await writeFile(file, renderCommandTemplate(target, binding.id, binding.skill, binding.description), "utf8");
15
+ created.push(file);
16
+ }
17
+ const guidePath = agent.guideAtProjectRoot
18
+ ? path.join(cwd, agent.guideFile)
19
+ : path.join(root, agent.guideFile);
20
+ await writeFile(guidePath, renderGuide(target, skills, config), "utf8");
21
+ created.push(guidePath);
22
+ return created;
23
+ }
24
+ export async function writeSharedAgentsStub(cwd, targets, skills, config) {
25
+ const file = path.join(cwd, "AGENTS.md");
26
+ const lines = [
27
+ "# AGENTS instructions",
28
+ "",
29
+ "<INSTRUCTIONS>",
30
+ "## OpenSpec Setup",
31
+ `Enabled targets: ${targets.join(", ")}`,
32
+ "### Skills",
33
+ ...skills.map((skill) => `- ${skill.name}: ${skill.description || "No description"}`),
34
+ "### Command Bindings",
35
+ ...config.commandBindings.map((binding) => `- ${binding.id} -> ${binding.skill}`),
36
+ "</INSTRUCTIONS>",
37
+ ""
38
+ ];
39
+ await writeFile(file, lines.join("\n"), "utf8");
40
+ return file;
41
+ }
42
+ function renderCommandTemplate(target, commandId, skillName, description) {
43
+ return [
44
+ `# ${commandId} (${target})`,
45
+ "",
46
+ description,
47
+ "",
48
+ "## Skill binding",
49
+ `- skill: ${skillName}`,
50
+ "",
51
+ "## Required output",
52
+ "- Change ID",
53
+ "- Updated files",
54
+ "- Validation summary"
55
+ ].join("\n");
56
+ }
57
+ function renderGuide(target, skills, config) {
58
+ const agent = config.agents[target];
59
+ const commandList = config.commandBindings.map((binding) => `- ${agent.slashPattern.replace("{id}", binding.id)} -> ${binding.skill}`);
60
+ return [
61
+ `# ${target} OpenSpec-style setup`,
62
+ "",
63
+ "## Installed skills",
64
+ ...skills.map((skill) => `- ${skill.name}: ${skill.description || "No description"}`),
65
+ "",
66
+ "## Commands",
67
+ ...commandList,
68
+ ""
69
+ ].join("\n");
70
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ export function validateManifest(manifest) {
2
+ const diagnostics = [];
3
+ if (!manifest.name) {
4
+ diagnostics.push({ level: "error", message: "Skill name is required." });
5
+ }
6
+ if (!manifest.description) {
7
+ diagnostics.push({
8
+ level: "error",
9
+ message: `Skill "${manifest.name}" is missing frontmatter description.`
10
+ });
11
+ }
12
+ if (!manifest.body) {
13
+ diagnostics.push({
14
+ level: "warning",
15
+ message: `Skill "${manifest.name}" has empty SKILL.md body.`
16
+ });
17
+ }
18
+ if (!/^[a-z0-9-]+$/.test(manifest.name)) {
19
+ diagnostics.push({
20
+ level: "warning",
21
+ message: `Skill "${manifest.name}" should use lowercase letters, numbers, and hyphens.`
22
+ });
23
+ }
24
+ return diagnostics;
25
+ }
@@ -0,0 +1,70 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+ export function splitMarkdownToTasks(markdown, sourcePath, rootTitle) {
4
+ const normalized = markdown.replace(/\r\n/g, "\n");
5
+ const lines = normalized.split("\n");
6
+ const headingStack = [];
7
+ const extracted = [];
8
+ for (const line of lines) {
9
+ const headingMatch = line.match(/^(#{1,6})\s+(.+?)\s*$/);
10
+ if (headingMatch) {
11
+ const level = headingMatch[1].length;
12
+ const title = headingMatch[2].trim();
13
+ headingStack.splice(level - 1);
14
+ headingStack[level - 1] = title;
15
+ continue;
16
+ }
17
+ const listMatch = line.match(/^\s*(?:[-*+]|\d+\.)\s+(.+?)\s*$/);
18
+ if (!listMatch)
19
+ continue;
20
+ const item = listMatch[1].trim();
21
+ const prefix = headingStack.filter(Boolean).join(" / ");
22
+ extracted.push(prefix ? `${prefix} - ${item}` : item);
23
+ }
24
+ if (extracted.length === 0) {
25
+ for (const line of lines) {
26
+ const headingMatch = line.match(/^#{2,6}\s+(.+?)\s*$/);
27
+ if (headingMatch)
28
+ extracted.push(headingMatch[1].trim());
29
+ }
30
+ }
31
+ if (extracted.length === 0) {
32
+ extracted.push(rootTitle);
33
+ }
34
+ return dedupe(extracted).map((title, idx) => createTaskItem({
35
+ sourcePath,
36
+ title,
37
+ order: idx + 1
38
+ }));
39
+ }
40
+ function createTaskItem(input) {
41
+ const now = new Date().toISOString();
42
+ return {
43
+ id: buildStableId(input.sourcePath, input.title, input.order),
44
+ title: input.title,
45
+ status: "todo",
46
+ source: normalizePath(input.sourcePath),
47
+ createdAt: now,
48
+ updatedAt: now
49
+ };
50
+ }
51
+ function buildStableId(sourcePath, title, order) {
52
+ const digest = createHash("sha1")
53
+ .update(`${normalizePath(sourcePath)}::${title}::${order}`)
54
+ .digest("hex");
55
+ return digest.slice(0, 10);
56
+ }
57
+ function normalizePath(input) {
58
+ return input.replaceAll("\\", "/").replaceAll(path.sep, "/");
59
+ }
60
+ function dedupe(items) {
61
+ const seen = new Set();
62
+ const result = [];
63
+ for (const item of items) {
64
+ if (seen.has(item))
65
+ continue;
66
+ seen.add(item);
67
+ result.push(item);
68
+ }
69
+ return result;
70
+ }
@@ -0,0 +1,28 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { pathExists } from "../utils/fs.js";
4
+ import { splitMarkdownToTasks } from "./parser.js";
5
+ import { archiveTask, initTaskWorkspace, refreshArchiveIndex, refreshTaskIndex, saveTask, updateTaskStatus } from "./storage.js";
6
+ export async function splitTasks(cwd, fromFile, title) {
7
+ const absoluteInput = path.resolve(cwd, fromFile);
8
+ if (!(await pathExists(absoluteInput))) {
9
+ throw new Error(`Input file not found: ${fromFile}`);
10
+ }
11
+ const markdown = await readFile(absoluteInput, "utf8");
12
+ const tasks = splitMarkdownToTasks(markdown, fromFile, title);
13
+ await initTaskWorkspace(cwd);
14
+ for (const task of tasks) {
15
+ await saveTask(cwd, task);
16
+ }
17
+ await refreshTaskIndex(cwd);
18
+ return tasks.length;
19
+ }
20
+ export async function archiveTaskById(cwd, id) {
21
+ await archiveTask(cwd, id);
22
+ await refreshTaskIndex(cwd);
23
+ await refreshArchiveIndex(cwd);
24
+ }
25
+ export async function updateTask(cwd, id, status) {
26
+ await updateTaskStatus(cwd, id, status);
27
+ await refreshTaskIndex(cwd);
28
+ }
@@ -0,0 +1,159 @@
1
+ import { readFile, rm, writeFile, readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { ensureDir, pathExists } from "../utils/fs.js";
4
+ import { parseFrontmatter, stringifyFrontmatter } from "../utils/frontmatter.js";
5
+ const ROOT_DIR = ".cdspec";
6
+ const TASKS_DIR = "tasks";
7
+ const ARCHIVE_DIR = "archive";
8
+ export function taskDir(cwd) {
9
+ return path.join(cwd, ROOT_DIR, TASKS_DIR);
10
+ }
11
+ export function archiveDir(cwd) {
12
+ return path.join(cwd, ROOT_DIR, ARCHIVE_DIR);
13
+ }
14
+ export async function initTaskWorkspace(cwd) {
15
+ await ensureDir(taskDir(cwd));
16
+ await ensureDir(archiveDir(cwd));
17
+ }
18
+ export async function saveTask(cwd, task) {
19
+ await initTaskWorkspace(cwd);
20
+ const targetFile = path.join(taskDir(cwd), `${task.id}.md`);
21
+ const body = `# ${task.title}\n\n- status: ${task.status}\n- source: ${task.source}\n`;
22
+ const content = stringifyFrontmatter({
23
+ id: task.id,
24
+ title: task.title,
25
+ status: task.status,
26
+ source: task.source,
27
+ created_at: task.createdAt,
28
+ updated_at: task.updatedAt
29
+ }, body);
30
+ await writeFile(targetFile, content, "utf8");
31
+ }
32
+ export async function loadTask(cwd, id) {
33
+ const file = path.join(taskDir(cwd), `${id}.md`);
34
+ if (!(await pathExists(file)))
35
+ return null;
36
+ const raw = await readFile(file, "utf8");
37
+ const parsed = parseFrontmatter(raw);
38
+ return {
39
+ id: parsed.attributes.id,
40
+ title: parsed.attributes.title,
41
+ status: parseStatus(parsed.attributes.status),
42
+ source: parsed.attributes.source,
43
+ createdAt: parsed.attributes.created_at,
44
+ updatedAt: parsed.attributes.updated_at
45
+ };
46
+ }
47
+ export async function updateTaskStatus(cwd, id, next) {
48
+ const current = await loadTask(cwd, id);
49
+ if (!current) {
50
+ throw new Error(`Task "${id}" not found.`);
51
+ }
52
+ if (!canTransition(current.status, next)) {
53
+ throw new Error(`Invalid status transition: ${current.status} -> ${next}.`);
54
+ }
55
+ const updated = {
56
+ ...current,
57
+ status: next,
58
+ updatedAt: new Date().toISOString()
59
+ };
60
+ await saveTask(cwd, updated);
61
+ return updated;
62
+ }
63
+ export async function archiveTask(cwd, id) {
64
+ const current = await loadTask(cwd, id);
65
+ if (!current) {
66
+ throw new Error(`Task "${id}" not found.`);
67
+ }
68
+ if (current.status !== "done") {
69
+ throw new Error(`Task "${id}" must be done before archive.`);
70
+ }
71
+ const archived = {
72
+ ...current,
73
+ archivedAt: new Date().toISOString()
74
+ };
75
+ const sourceFile = path.join(taskDir(cwd), `${id}.md`);
76
+ const archiveFile = path.join(archiveDir(cwd), `${id}.md`);
77
+ const body = `# ${archived.title}\n\nArchived from ${sourceFile.replaceAll("\\", "/")}.\n`;
78
+ const content = stringifyFrontmatter({
79
+ id: archived.id,
80
+ title: archived.title,
81
+ status: archived.status,
82
+ source: archived.source,
83
+ created_at: archived.createdAt,
84
+ updated_at: archived.updatedAt,
85
+ archived_at: archived.archivedAt
86
+ }, body);
87
+ await writeFile(archiveFile, content, "utf8");
88
+ await rm(sourceFile, { force: true });
89
+ return archived;
90
+ }
91
+ export async function refreshTaskIndex(cwd) {
92
+ await initTaskWorkspace(cwd);
93
+ const tasks = await loadByDir(taskDir(cwd));
94
+ const grouped = {
95
+ todo: tasks.filter((task) => task.status === "todo"),
96
+ in_progress: tasks.filter((task) => task.status === "in_progress"),
97
+ done: tasks.filter((task) => task.status === "done")
98
+ };
99
+ const lines = [
100
+ "# Task Index",
101
+ "",
102
+ "## todo",
103
+ ...grouped.todo.map((task) => `- [${task.id}] ${task.title}`),
104
+ "",
105
+ "## in_progress",
106
+ ...grouped.in_progress.map((task) => `- [${task.id}] ${task.title}`),
107
+ "",
108
+ "## done",
109
+ ...grouped.done.map((task) => `- [${task.id}] ${task.title}`),
110
+ ""
111
+ ];
112
+ await writeFile(path.join(taskDir(cwd), "index.md"), lines.join("\n"), "utf8");
113
+ }
114
+ export async function refreshArchiveIndex(cwd) {
115
+ await initTaskWorkspace(cwd);
116
+ const records = await loadByDir(archiveDir(cwd));
117
+ const lines = [
118
+ "# Archive Index",
119
+ "",
120
+ ...records.map((record) => `- [${record.id}] ${record.title}`),
121
+ ""
122
+ ];
123
+ await writeFile(path.join(archiveDir(cwd), "index.md"), lines.join("\n"), "utf8");
124
+ }
125
+ async function loadByDir(dirPath) {
126
+ if (!(await pathExists(dirPath)))
127
+ return [];
128
+ const entries = await readdir(dirPath, { withFileTypes: true });
129
+ const result = [];
130
+ for (const entry of entries) {
131
+ if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name === "index.md")
132
+ continue;
133
+ const raw = await readFile(path.join(dirPath, entry.name), "utf8");
134
+ const parsed = parseFrontmatter(raw);
135
+ result.push({
136
+ id: parsed.attributes.id,
137
+ title: parsed.attributes.title,
138
+ status: parseStatus(parsed.attributes.status),
139
+ source: parsed.attributes.source,
140
+ createdAt: parsed.attributes.created_at,
141
+ updatedAt: parsed.attributes.updated_at
142
+ });
143
+ }
144
+ return result;
145
+ }
146
+ function parseStatus(input) {
147
+ if (input === "todo" || input === "in_progress" || input === "done")
148
+ return input;
149
+ return "todo";
150
+ }
151
+ function canTransition(current, next) {
152
+ if (current === next)
153
+ return true;
154
+ if (current === "todo" && next === "in_progress")
155
+ return true;
156
+ if (current === "in_progress" && next === "done")
157
+ return true;
158
+ return false;
159
+ }
@@ -0,0 +1 @@
1
+ export {};