cdspec 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,55 +1,108 @@
1
- # cdspec
1
+ # CDSpec
2
2
 
3
- CLI for:
3
+ `cdspec` 是一个 Skill 编排 CLI,用来把项目里的模板/技能一键安装到不同智能体目录(Codex / ClaudeCode / iFlow),并自动生成可调用命令。
4
4
 
5
- - Exporting project-local skills from `.codex/*` to `.codex/.claude/.iflow` tool layouts.
6
- - Installing OpenSpec-style interaction commands (`opsx-*`).
7
- - Splitting markdown into task cards and archiving completed tasks.
8
- - Config-driven command-skill bindings via `cdspec.config.yaml`.
5
+ ## 功能
9
6
 
10
- ## Quick start
7
+ - `templates/` 批量生成并安装 skills
8
+ - 自动为每个 skill 生成命令文件
9
+ - 生成命令与 skill 的绑定说明(AGENTS/CLAUDE/IFLOW)
10
+ - 支持任务拆分与归档(`task split/status/archive`)
11
+
12
+ ## 安装
13
+
14
+ 全局安装(已发布到 npm):
15
+
16
+ ```bash
17
+ npm i -g cdspec
18
+ ```
19
+
20
+ 验证安装:
21
+
22
+ ```bash
23
+ cdspec --help
24
+ ```
25
+
26
+ 本地开发方式:
11
27
 
12
28
  ```bash
13
29
  npm install
14
30
  npm run build
15
- node dist/cli.js --help
31
+ npm link
32
+ ```
33
+
34
+ ## 快速开始
35
+
36
+ 在项目根目录准备 `templates/`,每个技能一个目录,目录内至少有 `SKILL.md`。
37
+
38
+ 示例:
39
+
40
+ ```text
41
+ templates/
42
+ design-doc/
43
+ SKILL.md
44
+ agents/openai.yaml
45
+ references/*
46
+ frontend-standards/
47
+ SKILL.md
16
48
  ```
17
49
 
18
- ## Commands
50
+ 执行初始化:
19
51
 
20
52
  ```bash
21
- cdspec init --agents codex,claudecode,iflow
53
+ cdspec init
54
+ ```
22
55
 
23
- cdspec skill list
24
- cdspec skill add <name> --target all
25
- cdspec skill sync --target all
56
+ 或指定智能体:
26
57
 
27
- cdspec task split --from plan.md --title "My Task"
28
- cdspec task status --id <task-id> --to in_progress
29
- cdspec task status --id <task-id> --to done
30
- cdspec task archive --id <task-id>
58
+ ```bash
59
+ cdspec init --agents codex,claudecode,iflow
31
60
  ```
32
61
 
33
- `cdspec init` generates tool-local files:
62
+ ## 生成规则(当前版本)
63
+
64
+ 1. `templates/*` 下每个 skill 目录会被安装到目标智能体的 `skills/`。
65
+ 2. 每个 skill 自动生成一个命令文件,命令名规则为:
66
+ `cd-<skill-name>`
67
+ 3. `init` 默认覆盖旧文件(不需要 `--force`)。
68
+
69
+ 示例:
70
+
71
+ - skill: `frontend-develop-standard`
72
+ - 命令文件:`cd-frontend-develop-standard.md`
73
+
74
+ ## 输出目录
75
+
76
+ 默认配置下:
77
+
78
+ - Codex: `~/.codex/skills/*` 和 `~/.codex/prompts/*`
79
+ - ClaudeCode: `.claude/skills/*` 和 `.claude/commands/opsx/*`
80
+ - iFlow: `.iflow/skills/*` 和 `.iflow/commands/*`
81
+
82
+ 附加说明文件:
83
+
84
+ - `~/.codex/AGENTS.md`
85
+ - 项目根 `AGENTS.md`
86
+ - 项目根 `CLAUDE.md`(当启用 claudecode)
87
+ - `.iflow/IFLOW.md`(当启用 iflow)
88
+
89
+ ## 配置
34
90
 
35
- - `.codex/skills/*`, `.codex/prompts/opsx-*.md`, `.codex/AGENTS.md`
36
- - `.claude/skills/*`, `.claude/commands/opsx/*.md`, `CLAUDE.md`
37
- - `.iflow/skills/*`, `.iflow/commands/opsx-*.md`, `.iflow/IFLOW.md`
91
+ 项目根配置文件:`cdspec.config.yaml`
38
92
 
39
- If `.codex` has no skills, `cdspec init` exports template skill directly into target `skills/` folders.
40
- It does not create extra source folders in your project tree.
41
- When `templates/*.md` exists, the exported skill name is derived from template filename, and
42
- command bindings are auto-pointed to that generated skill.
93
+ 可配置项包括:
43
94
 
44
- ## Output
95
+ - 各智能体根目录(如 `~/.codex`)
96
+ - 命令目录位置(`prompts` / `commands`)
97
+ - 命令文件命名规则与 slash 展示规则
45
98
 
46
- - Skill output: `.{tool}/skills/{skill-name}`
47
- - Task workspace: `.cdspec/tasks` and `.cdspec/archive`
99
+ ## 常见问题
48
100
 
49
- ## Config
101
+ 1. `init` 后看不到新命令
102
+ 重启 Codex 会话;部分客户端启动时才扫描 prompt 列表。
50
103
 
51
- `cdspec.config.yaml` controls:
104
+ 2. 命令重复(Team/个人各一份)
105
+ 这是不同来源同时存在,清理个人或团队同名 skill 即可。
52
106
 
53
- - command -> skill bindings
54
- - per-agent root directory and command file pattern
55
- - slash command display pattern
107
+ 3. 发布 npm 401/404
108
+ `npm login`,再检查包名与权限后发布。
package/dist/cli.js CHANGED
@@ -1,74 +1,21 @@
1
1
  #!/usr/bin/env node
2
- import checkbox from "@inquirer/checkbox";
3
- import { Command } from "commander";
4
- import path from "node:path";
5
- import process from "node:process";
6
- import { addSkill, initSkills, listSkills, syncSkills } from "./skill-core/service.js";
7
- import { archiveTaskById, splitTasks, updateTask } from "./task-core/service.js";
2
+ import checkbox from '@inquirer/checkbox';
3
+ import { Command } from 'commander';
4
+ import path from 'node:path';
5
+ import process from 'node:process';
6
+ import { initSkills } from './skill-core/service.js';
8
7
  const program = new Command();
8
+ program.name('cdspec').description('Skill init CLI').version('0.1.0');
9
9
  program
10
- .name("cdspec")
11
- .description("Skill sync + task split/archive workflow CLI")
12
- .version("0.1.0");
13
- program
14
- .command("init")
15
- .description("Initialize skills for selected coding agents")
16
- .option("--agents <agents>", "codex|claudecode|iflow|all or comma-separated")
10
+ .command('init')
11
+ .description('Initialize skills for selected coding agents')
12
+ .option('--agents <agents>', 'codex|claudecode|iflow|all or comma-separated')
17
13
  .action(async (options) => {
18
14
  const selected = options.agents ?? (await askAgentsSelection());
19
15
  const files = await initSkills(process.cwd(), selected, true);
20
- console.log(`Initialized OpenSpec-style setup for "${selected}".`);
21
- console.log(`Generated files: ${files.map((x) => path.relative(process.cwd(), x)).join(", ")}`);
16
+ console.log(`Initialized setup for "${selected}".`);
17
+ console.log(`Generated files: ${files.map(x => path.relative(process.cwd(), x)).join(', ')}`);
22
18
  });
23
- program
24
- .command("skill")
25
- .description("Skill operations")
26
- .addCommand(new Command("list").action(async () => {
27
- const names = await listSkills(process.cwd());
28
- if (names.length === 0) {
29
- console.log("No skill found under .codex/.");
30
- return;
31
- }
32
- names.forEach((name) => console.log(name));
33
- }))
34
- .addCommand(new Command("add")
35
- .argument("<name>", "skill folder name under .codex")
36
- .option("--target <target>", "codex|claudecode|iflow|all or comma-separated", "all")
37
- .option("--force", "overwrite existing output", false)
38
- .action(async (name, options) => {
39
- await addSkill(process.cwd(), name, options.target, options.force);
40
- console.log(`Skill "${name}" exported to tool folders for target "${options.target}".`);
41
- }))
42
- .addCommand(new Command("sync")
43
- .option("--target <target>", "codex|claudecode|iflow|all or comma-separated", "all")
44
- .option("--force", "overwrite existing output", false)
45
- .action(async (options) => {
46
- await syncSkills(process.cwd(), options.target, options.force);
47
- console.log(`All skills synced to tool folders for target "${options.target}".`);
48
- }));
49
- program
50
- .command("task")
51
- .description("Task split/archive operations")
52
- .addCommand(new Command("split")
53
- .requiredOption("--from <file>", "source markdown file")
54
- .requiredOption("--title <title>", "task group title")
55
- .action(async (options) => {
56
- const count = await splitTasks(process.cwd(), options.from, options.title);
57
- console.log(`Generated ${count} tasks from "${options.from}".`);
58
- }))
59
- .addCommand(new Command("status")
60
- .requiredOption("--id <id>", "task id")
61
- .requiredOption("--to <status>", "todo|in_progress|done")
62
- .action(async (options) => {
63
- await updateTask(process.cwd(), options.id, options.to);
64
- console.log(`Task "${options.id}" updated to "${options.to}".`);
65
- }))
66
- .addCommand(new Command("archive")
67
- .requiredOption("--id <id>", "task id")
68
- .action(async (options) => {
69
- await archiveTaskById(process.cwd(), options.id);
70
- console.log(`Task "${options.id}" archived.`);
71
- }));
72
19
  program.parseAsync(process.argv).catch((error) => {
73
20
  const message = error instanceof Error ? error.message : String(error);
74
21
  console.error(message);
@@ -76,19 +23,19 @@ program.parseAsync(process.argv).catch((error) => {
76
23
  });
77
24
  async function askAgentsSelection() {
78
25
  if (!process.stdin.isTTY)
79
- return "all";
26
+ return 'all';
80
27
  const values = await checkbox({
81
- message: "Select coding agents",
28
+ message: 'Select coding agents',
82
29
  choices: [
83
- { name: "codex", value: "codex", checked: true },
84
- { name: "claudecode", value: "claudecode" },
85
- { name: "iflow", value: "iflow" },
86
- { name: "all", value: "all" }
30
+ { name: 'codex', value: 'codex', checked: true },
31
+ { name: 'claudecode', value: 'claudecode' },
32
+ { name: 'iflow', value: 'iflow' },
33
+ { name: 'all', value: 'all' }
87
34
  ]
88
35
  });
89
36
  if (values.length === 0)
90
- return "all";
91
- if (values.includes("all"))
92
- return "all";
93
- return values.join(",");
37
+ return 'all';
38
+ if (values.includes('all'))
39
+ return 'all';
40
+ return values.join(',');
94
41
  }
@@ -15,9 +15,6 @@ export async function loadAllSkillManifests(cwd) {
15
15
  }
16
16
  return manifests;
17
17
  }
18
- export async function loadSkillManifestByName(cwd, name) {
19
- return loadSkillManifest(path.join(cwd, CODEX_DIR, name));
20
- }
21
18
  async function loadSkillManifest(skillDir) {
22
19
  const skillMdPath = path.join(skillDir, "SKILL.md");
23
20
  if (!(await pathExists(skillMdPath)))
@@ -2,58 +2,11 @@ import path from "node:path";
2
2
  import { rm } from "node:fs/promises";
3
3
  import { loadConfig } from "../config/loader.js";
4
4
  import { resolveAgentRoot } from "../config/path.js";
5
- import { loadAllSkillManifests, loadSkillManifestByName } from "./manifest-loader.js";
5
+ import { loadAllSkillManifests } from "./manifest-loader.js";
6
6
  import { expandTargets, getAdapter } from "./adapters/index.js";
7
7
  import { installToolInteractionTemplates, writeSharedAgentsStub } from "./tool-interactions.js";
8
8
  import { cleanupLegacyDefaultSkillDir, loadDefaultSkillManifest, loadProjectTemplateManifests } from "./scaffold.js";
9
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
10
  export async function initSkills(cwd, agentsRaw, force) {
58
11
  await cleanupLegacyDefaultSkillDir(cwd);
59
12
  const baseConfig = await loadConfig(cwd);
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "cdspec",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
+ "author": "liyanwei",
4
5
  "description": "CDSpec CLI for skill sync and task split/archive workflow",
5
6
  "type": "module",
6
7
  "bin": {
package/src/cli.ts CHANGED
@@ -3,12 +3,11 @@ import checkbox from '@inquirer/checkbox'
3
3
  import { Command } from 'commander'
4
4
  import path from 'node:path'
5
5
  import process from 'node:process'
6
- import { addSkill, initSkills, listSkills, syncSkills } from './skill-core/service.js'
7
- import { archiveTaskById, splitTasks, updateTask } from './task-core/service.js'
6
+ import { initSkills } from './skill-core/service.js'
8
7
 
9
8
  const program = new Command()
10
9
 
11
- program.name('cdspec').description('Skill sync + task split/archive workflow CLI').version('0.1.0')
10
+ program.name('cdspec').description('Skill init CLI').version('0.1.0')
12
11
 
13
12
  program
14
13
  .command('init')
@@ -17,71 +16,10 @@ program
17
16
  .action(async (options: { agents?: string }) => {
18
17
  const selected = options.agents ?? (await askAgentsSelection())
19
18
  const files = await initSkills(process.cwd(), selected, true)
20
- console.log(`Initialized OpenSpec-style setup for "${selected}".`)
19
+ console.log(`Initialized setup for "${selected}".`)
21
20
  console.log(`Generated files: ${files.map(x => path.relative(process.cwd(), x)).join(', ')}`)
22
21
  })
23
22
 
24
- program
25
- .command('skill')
26
- .description('Skill operations')
27
- .addCommand(
28
- new Command('list').action(async () => {
29
- const names = await listSkills(process.cwd())
30
- if (names.length === 0) {
31
- console.log('No skill found under .codex/.')
32
- return
33
- }
34
- names.forEach(name => console.log(name))
35
- })
36
- )
37
- .addCommand(
38
- new Command('add')
39
- .argument('<name>', 'skill folder name under .codex')
40
- .option('--target <target>', 'codex|claudecode|iflow|all or comma-separated', 'all')
41
- .option('--force', 'overwrite existing output', false)
42
- .action(async (name: string, options: { target: string; force: boolean }) => {
43
- await addSkill(process.cwd(), name, options.target, options.force)
44
- console.log(`Skill "${name}" exported to tool folders for target "${options.target}".`)
45
- })
46
- )
47
- .addCommand(
48
- new Command('sync')
49
- .option('--target <target>', 'codex|claudecode|iflow|all or comma-separated', 'all')
50
- .option('--force', 'overwrite existing output', false)
51
- .action(async (options: { target: string; force: boolean }) => {
52
- await syncSkills(process.cwd(), options.target, options.force)
53
- console.log(`All skills synced to tool folders for target "${options.target}".`)
54
- })
55
- )
56
-
57
- program
58
- .command('task')
59
- .description('Task split/archive operations')
60
- .addCommand(
61
- new Command('split')
62
- .requiredOption('--from <file>', 'source markdown file')
63
- .requiredOption('--title <title>', 'task group title')
64
- .action(async (options: { from: string; title: string }) => {
65
- const count = await splitTasks(process.cwd(), options.from, options.title)
66
- console.log(`Generated ${count} tasks from "${options.from}".`)
67
- })
68
- )
69
- .addCommand(
70
- new Command('status')
71
- .requiredOption('--id <id>', 'task id')
72
- .requiredOption('--to <status>', 'todo|in_progress|done')
73
- .action(async (options: { id: string; to: 'todo' | 'in_progress' | 'done' }) => {
74
- await updateTask(process.cwd(), options.id, options.to)
75
- console.log(`Task "${options.id}" updated to "${options.to}".`)
76
- })
77
- )
78
- .addCommand(
79
- new Command('archive').requiredOption('--id <id>', 'task id').action(async (options: { id: string }) => {
80
- await archiveTaskById(process.cwd(), options.id)
81
- console.log(`Task "${options.id}" archived.`)
82
- })
83
- )
84
-
85
23
  program.parseAsync(process.argv).catch((error: unknown) => {
86
24
  const message = error instanceof Error ? error.message : String(error)
87
25
  console.error(message)
@@ -103,3 +41,4 @@ async function askAgentsSelection(): Promise<string> {
103
41
  if (values.includes('all')) return 'all'
104
42
  return values.join(',')
105
43
  }
44
+
@@ -18,13 +18,6 @@ export async function loadAllSkillManifests(cwd: string): Promise<SkillManifest[
18
18
  return manifests;
19
19
  }
20
20
 
21
- export async function loadSkillManifestByName(
22
- cwd: string,
23
- name: string
24
- ): Promise<SkillManifest | null> {
25
- return loadSkillManifest(path.join(cwd, CODEX_DIR, name));
26
- }
27
-
28
21
  async function loadSkillManifest(skillDir: string): Promise<SkillManifest | null> {
29
22
  const skillMdPath = path.join(skillDir, "SKILL.md");
30
23
  if (!(await pathExists(skillMdPath))) return null;
@@ -3,7 +3,7 @@ import { rm } from "node:fs/promises";
3
3
  import { loadConfig } from "../config/loader.js";
4
4
  import { resolveAgentRoot } from "../config/path.js";
5
5
  import { CDSpecConfig } from "../config/types.js";
6
- import { loadAllSkillManifests, loadSkillManifestByName } from "./manifest-loader.js";
6
+ import { loadAllSkillManifests } from "./manifest-loader.js";
7
7
  import { expandTargets, getAdapter } from "./adapters/index.js";
8
8
  import {
9
9
  installToolInteractionTemplates,
@@ -17,68 +17,6 @@ import {
17
17
  import { SkillManifest } from "./types.js";
18
18
  import { validateManifest } from "./validator.js";
19
19
 
20
- export async function listSkills(cwd: string): Promise<string[]> {
21
- const manifests = await loadAllSkillManifests(cwd);
22
- return manifests.map((manifest) => manifest.name).sort();
23
- }
24
-
25
- export async function addSkill(
26
- cwd: string,
27
- name: string,
28
- targetRaw: string,
29
- force: boolean
30
- ): Promise<void> {
31
- const config = await loadConfig(cwd);
32
- let manifest = await loadSkillManifestByName(cwd, name);
33
- if (!manifest) {
34
- const all = await loadAllSkillManifests(cwd);
35
- manifest = all.find((item) => item.name === name) ?? null;
36
- }
37
- if (!manifest) {
38
- throw new Error(`Skill "${name}" not found under .codex/.`);
39
- }
40
- const diagnostics = [...validateManifest(manifest)];
41
- const targets = expandTargets(targetRaw);
42
- for (const target of targets) {
43
- diagnostics.push(...getAdapter(target).validate(manifest));
44
- }
45
- failIfErrors(diagnostics);
46
-
47
- for (const target of targets) {
48
- const adapter = getAdapter(target);
49
- const outDir = path.join(resolveAgentRoot(cwd, config.agents[target].rootDir), "skills", manifest.name);
50
- await adapter.emit(manifest, outDir, force);
51
- }
52
- }
53
-
54
- export async function syncSkills(
55
- cwd: string,
56
- targetRaw: string,
57
- force: boolean
58
- ): Promise<void> {
59
- const config = await loadConfig(cwd);
60
- const manifests = await loadAllSkillManifests(cwd);
61
- if (manifests.length === 0) {
62
- throw new Error("No skills found under .codex/.");
63
- }
64
- const targets = expandTargets(targetRaw);
65
-
66
- for (const manifest of manifests) {
67
- const diagnostics = [...validateManifest(manifest)];
68
- for (const target of targets) {
69
- diagnostics.push(...getAdapter(target).validate(manifest));
70
- }
71
- failIfErrors(diagnostics);
72
- }
73
-
74
- for (const manifest of manifests) {
75
- for (const target of targets) {
76
- const outDir = path.join(resolveAgentRoot(cwd, config.agents[target].rootDir), "skills", manifest.name);
77
- await getAdapter(target).emit(manifest, outDir, force);
78
- }
79
- }
80
- }
81
-
82
20
  export async function initSkills(
83
21
  cwd: string,
84
22
  agentsRaw: string,
@@ -0,0 +1,63 @@
1
+ import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { initSkills } from "../src/skill-core/service.js";
6
+
7
+ describe("init command core flow", () => {
8
+ it("exports all template skills and creates cd-full-name prompts", async () => {
9
+ const root = await mkdtemp(path.join(os.tmpdir(), "cdspec-init-"));
10
+ await seedTemplateSkill(root, "design-doc");
11
+ await seedTemplateSkill(root, "frontend-standards");
12
+ await initSkills(root, "codex", true);
13
+
14
+ const skills = await readDirNames(path.join(root, ".codex", "skills"));
15
+ expect(skills).toEqual(["design-doc", "frontend-standards"]);
16
+
17
+ const prompts = await readDirNames(path.join(root, ".codex", "prompts"));
18
+ expect(prompts).toEqual(["cd-design-doc.md", "cd-frontend-standards.md"]);
19
+
20
+ const promptContent = await readFile(
21
+ path.join(root, ".codex", "prompts", "cd-frontend-standards.md"),
22
+ "utf8"
23
+ );
24
+ expect(promptContent).toContain("skill: frontend-standards");
25
+ });
26
+
27
+ it("overwrites old prompts/skills when force=true", async () => {
28
+ const root = await mkdtemp(path.join(os.tmpdir(), "cdspec-force-"));
29
+ await seedTemplateSkill(root, "design-doc");
30
+ await initSkills(root, "codex", true);
31
+
32
+ await writeFile(path.join(root, ".codex", "prompts", "old.md"), "old");
33
+ await writeFile(path.join(root, ".codex", "skills", "old", "SKILL.md"), "x", "utf8").catch(
34
+ async () => {
35
+ await mkdir(path.join(root, ".codex", "skills", "old"), { recursive: true });
36
+ await writeFile(path.join(root, ".codex", "skills", "old", "SKILL.md"), "x", "utf8");
37
+ }
38
+ );
39
+
40
+ await initSkills(root, "codex", true);
41
+ const prompts = await readDirNames(path.join(root, ".codex", "prompts"));
42
+ const skills = await readDirNames(path.join(root, ".codex", "skills"));
43
+ expect(prompts).toEqual(["cd-design-doc.md"]);
44
+ expect(skills).toEqual(["design-doc"]);
45
+ });
46
+ });
47
+
48
+ async function seedTemplateSkill(root: string, name: string): Promise<void> {
49
+ const dir = path.join(root, "templates", name);
50
+ await mkdir(path.join(dir, "agents"), { recursive: true });
51
+ await writeFile(
52
+ path.join(dir, "SKILL.md"),
53
+ ["---", `name: ${name}`, "description: desc", "---", "", `# ${name}`].join("\n"),
54
+ "utf8"
55
+ );
56
+ await writeFile(path.join(dir, "agents", "openai.yaml"), "interface:\n display_name: x\n");
57
+ }
58
+
59
+ async function readDirNames(dir: string): Promise<string[]> {
60
+ const entries = await (await import("node:fs/promises")).readdir(dir, { withFileTypes: true });
61
+ return entries.map((e) => e.name).sort();
62
+ }
63
+
package/AGENTS.md DELETED
@@ -1,14 +0,0 @@
1
- # AGENTS instructions
2
-
3
- <INSTRUCTIONS>
4
- ## OpenSpec Setup
5
- Enabled targets: codex
6
- ### Skills
7
- - design-doc: 管理业务详细设计文档的全生命周期:首次基线、增量 feature 文档、归档合并。适用于首次开发与迭代改造,避免小改动频繁新建基线版本文件,同时保留 feature 变更历史。
8
- - frontend-standards: 部门级前端开发规范执行技能,覆盖 Vue3 编码规范、工程配置规范、Git 提交规范与前端最佳实践。用于前端需求开发、重构、代码评审、工程初始化与联调前自检;当任务涉及 Vue3/TypeScript 页面与组件实现、样式规范、ESLint/Prettier/Husky/Commitlint 配置时触发。
9
- - standards-backend: 部门级后端开发规范执行技能,覆盖后端编码规范、数据库设计规范、传统三层架构约束、DDD架构约束、常用设计模式落地与JUC并发编排规范,并内置数据表基线字段与关联表例外规则。用于需求开发、重构、代码评审、建表SQL设计、接口设计、并发优化与联调前自检等场景;当任务涉及 Java 后端实现、SQL/表结构、分层落位、架构选型(三层或DDD)、可扩展性设计(策略/工厂/责任链/适配器/模板/单例)或线程池/CompletableFuture/CountDownLatch/Semaphore/ReentrantLock/Atomic* 等并发处理时触发。
10
- ### Command Bindings
11
- - cd-design-doc -> design-doc
12
- - cd-frontend-standards -> frontend-standards
13
- - cd-standards-backend -> standards-backend
14
- </INSTRUCTIONS>
package/CLAUDE.md DELETED
@@ -1,10 +0,0 @@
1
- # claudecode OpenSpec-style setup
2
-
3
- ## Installed skills
4
- - frontend-develop-standard: Generated from templates/frontend_develop_standard.md
5
-
6
- ## Commands
7
- - /opsx:propose -> frontend-develop-standard
8
- - /opsx:explore -> frontend-develop-standard
9
- - /opsx:apply -> frontend-develop-standard
10
- - /opsx:archive -> frontend-develop-standard
@@ -1,89 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import path from "node:path";
3
- import { TaskItem } from "./types.js";
4
-
5
- export function splitMarkdownToTasks(
6
- markdown: string,
7
- sourcePath: string,
8
- rootTitle: string
9
- ): TaskItem[] {
10
- const normalized = markdown.replace(/\r\n/g, "\n");
11
- const lines = normalized.split("\n");
12
- const headingStack: string[] = [];
13
- const extracted: string[] = [];
14
-
15
- for (const line of lines) {
16
- const headingMatch = line.match(/^(#{1,6})\s+(.+?)\s*$/);
17
- if (headingMatch) {
18
- const level = headingMatch[1].length;
19
- const title = headingMatch[2].trim();
20
- headingStack.splice(level - 1);
21
- headingStack[level - 1] = title;
22
- continue;
23
- }
24
-
25
- const listMatch = line.match(/^\s*(?:[-*+]|\d+\.)\s+(.+?)\s*$/);
26
- if (!listMatch) continue;
27
- const item = listMatch[1].trim();
28
- const prefix = headingStack.filter(Boolean).join(" / ");
29
- extracted.push(prefix ? `${prefix} - ${item}` : item);
30
- }
31
-
32
- if (extracted.length === 0) {
33
- for (const line of lines) {
34
- const headingMatch = line.match(/^#{2,6}\s+(.+?)\s*$/);
35
- if (headingMatch) extracted.push(headingMatch[1].trim());
36
- }
37
- }
38
-
39
- if (extracted.length === 0) {
40
- extracted.push(rootTitle);
41
- }
42
-
43
- return dedupe(extracted).map((title, idx) =>
44
- createTaskItem({
45
- sourcePath,
46
- title,
47
- order: idx + 1
48
- })
49
- );
50
- }
51
-
52
- function createTaskItem(input: {
53
- sourcePath: string;
54
- title: string;
55
- order: number;
56
- }): TaskItem {
57
- const now = new Date().toISOString();
58
- return {
59
- id: buildStableId(input.sourcePath, input.title, input.order),
60
- title: input.title,
61
- status: "todo",
62
- source: normalizePath(input.sourcePath),
63
- createdAt: now,
64
- updatedAt: now
65
- };
66
- }
67
-
68
- function buildStableId(sourcePath: string, title: string, order: number): string {
69
- const digest = createHash("sha1")
70
- .update(`${normalizePath(sourcePath)}::${title}::${order}`)
71
- .digest("hex");
72
- return digest.slice(0, 10);
73
- }
74
-
75
- function normalizePath(input: string): string {
76
- return input.replaceAll("\\", "/").replaceAll(path.sep, "/");
77
- }
78
-
79
- function dedupe(items: string[]): string[] {
80
- const seen = new Set<string>();
81
- const result: string[] = [];
82
- for (const item of items) {
83
- if (seen.has(item)) continue;
84
- seen.add(item);
85
- result.push(item);
86
- }
87
- return result;
88
- }
89
-
@@ -1,49 +0,0 @@
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 {
6
- archiveTask,
7
- initTaskWorkspace,
8
- refreshArchiveIndex,
9
- refreshTaskIndex,
10
- saveTask,
11
- updateTaskStatus
12
- } from "./storage.js";
13
- import { TaskStatus } from "./types.js";
14
-
15
- export async function splitTasks(
16
- cwd: string,
17
- fromFile: string,
18
- title: string
19
- ): Promise<number> {
20
- const absoluteInput = path.resolve(cwd, fromFile);
21
- if (!(await pathExists(absoluteInput))) {
22
- throw new Error(`Input file not found: ${fromFile}`);
23
- }
24
-
25
- const markdown = await readFile(absoluteInput, "utf8");
26
- const tasks = splitMarkdownToTasks(markdown, fromFile, title);
27
- await initTaskWorkspace(cwd);
28
- for (const task of tasks) {
29
- await saveTask(cwd, task);
30
- }
31
- await refreshTaskIndex(cwd);
32
- return tasks.length;
33
- }
34
-
35
- export async function archiveTaskById(cwd: string, id: string): Promise<void> {
36
- await archiveTask(cwd, id);
37
- await refreshTaskIndex(cwd);
38
- await refreshArchiveIndex(cwd);
39
- }
40
-
41
- export async function updateTask(
42
- cwd: string,
43
- id: string,
44
- status: TaskStatus
45
- ): Promise<void> {
46
- await updateTaskStatus(cwd, id, status);
47
- await refreshTaskIndex(cwd);
48
- }
49
-
@@ -1,177 +0,0 @@
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
- import { ArchiveRecord, TaskItem, TaskStatus } from "./types.js";
6
-
7
- const ROOT_DIR = ".cdspec";
8
- const TASKS_DIR = "tasks";
9
- const ARCHIVE_DIR = "archive";
10
-
11
- export function taskDir(cwd: string): string {
12
- return path.join(cwd, ROOT_DIR, TASKS_DIR);
13
- }
14
-
15
- export function archiveDir(cwd: string): string {
16
- return path.join(cwd, ROOT_DIR, ARCHIVE_DIR);
17
- }
18
-
19
- export async function initTaskWorkspace(cwd: string): Promise<void> {
20
- await ensureDir(taskDir(cwd));
21
- await ensureDir(archiveDir(cwd));
22
- }
23
-
24
- export async function saveTask(cwd: string, task: TaskItem): Promise<void> {
25
- await initTaskWorkspace(cwd);
26
- const targetFile = path.join(taskDir(cwd), `${task.id}.md`);
27
- const body = `# ${task.title}\n\n- status: ${task.status}\n- source: ${task.source}\n`;
28
- const content = stringifyFrontmatter(
29
- {
30
- id: task.id,
31
- title: task.title,
32
- status: task.status,
33
- source: task.source,
34
- created_at: task.createdAt,
35
- updated_at: task.updatedAt
36
- },
37
- body
38
- );
39
- await writeFile(targetFile, content, "utf8");
40
- }
41
-
42
- export async function loadTask(cwd: string, id: string): Promise<TaskItem | null> {
43
- const file = path.join(taskDir(cwd), `${id}.md`);
44
- if (!(await pathExists(file))) return null;
45
- const raw = await readFile(file, "utf8");
46
- const parsed = parseFrontmatter(raw);
47
- return {
48
- id: parsed.attributes.id,
49
- title: parsed.attributes.title,
50
- status: parseStatus(parsed.attributes.status),
51
- source: parsed.attributes.source,
52
- createdAt: parsed.attributes.created_at,
53
- updatedAt: parsed.attributes.updated_at
54
- };
55
- }
56
-
57
- export async function updateTaskStatus(
58
- cwd: string,
59
- id: string,
60
- next: TaskStatus
61
- ): Promise<TaskItem> {
62
- const current = await loadTask(cwd, id);
63
- if (!current) {
64
- throw new Error(`Task "${id}" not found.`);
65
- }
66
- if (!canTransition(current.status, next)) {
67
- throw new Error(`Invalid status transition: ${current.status} -> ${next}.`);
68
- }
69
- const updated: TaskItem = {
70
- ...current,
71
- status: next,
72
- updatedAt: new Date().toISOString()
73
- };
74
- await saveTask(cwd, updated);
75
- return updated;
76
- }
77
-
78
- export async function archiveTask(cwd: string, id: string): Promise<ArchiveRecord> {
79
- const current = await loadTask(cwd, id);
80
- if (!current) {
81
- throw new Error(`Task "${id}" not found.`);
82
- }
83
- if (current.status !== "done") {
84
- throw new Error(`Task "${id}" must be done before archive.`);
85
- }
86
-
87
- const archived: ArchiveRecord = {
88
- ...current,
89
- archivedAt: new Date().toISOString()
90
- };
91
- const sourceFile = path.join(taskDir(cwd), `${id}.md`);
92
- const archiveFile = path.join(archiveDir(cwd), `${id}.md`);
93
- const body = `# ${archived.title}\n\nArchived from ${sourceFile.replaceAll("\\", "/")}.\n`;
94
- const content = stringifyFrontmatter(
95
- {
96
- id: archived.id,
97
- title: archived.title,
98
- status: archived.status,
99
- source: archived.source,
100
- created_at: archived.createdAt,
101
- updated_at: archived.updatedAt,
102
- archived_at: archived.archivedAt
103
- },
104
- body
105
- );
106
- await writeFile(archiveFile, content, "utf8");
107
- await rm(sourceFile, { force: true });
108
- return archived;
109
- }
110
-
111
- export async function refreshTaskIndex(cwd: string): Promise<void> {
112
- await initTaskWorkspace(cwd);
113
- const tasks = await loadByDir(taskDir(cwd));
114
- const grouped = {
115
- todo: tasks.filter((task) => task.status === "todo"),
116
- in_progress: tasks.filter((task) => task.status === "in_progress"),
117
- done: tasks.filter((task) => task.status === "done")
118
- };
119
- const lines = [
120
- "# Task Index",
121
- "",
122
- "## todo",
123
- ...grouped.todo.map((task) => `- [${task.id}] ${task.title}`),
124
- "",
125
- "## in_progress",
126
- ...grouped.in_progress.map((task) => `- [${task.id}] ${task.title}`),
127
- "",
128
- "## done",
129
- ...grouped.done.map((task) => `- [${task.id}] ${task.title}`),
130
- ""
131
- ];
132
- await writeFile(path.join(taskDir(cwd), "index.md"), lines.join("\n"), "utf8");
133
- }
134
-
135
- export async function refreshArchiveIndex(cwd: string): Promise<void> {
136
- await initTaskWorkspace(cwd);
137
- const records = await loadByDir(archiveDir(cwd));
138
- const lines = [
139
- "# Archive Index",
140
- "",
141
- ...records.map((record) => `- [${record.id}] ${record.title}`),
142
- ""
143
- ];
144
- await writeFile(path.join(archiveDir(cwd), "index.md"), lines.join("\n"), "utf8");
145
- }
146
-
147
- async function loadByDir(dirPath: string): Promise<TaskItem[]> {
148
- if (!(await pathExists(dirPath))) return [];
149
- const entries = await readdir(dirPath, { withFileTypes: true });
150
- const result: TaskItem[] = [];
151
- for (const entry of entries) {
152
- if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name === "index.md") continue;
153
- const raw = await readFile(path.join(dirPath, entry.name), "utf8");
154
- const parsed = parseFrontmatter(raw);
155
- result.push({
156
- id: parsed.attributes.id,
157
- title: parsed.attributes.title,
158
- status: parseStatus(parsed.attributes.status),
159
- source: parsed.attributes.source,
160
- createdAt: parsed.attributes.created_at,
161
- updatedAt: parsed.attributes.updated_at
162
- });
163
- }
164
- return result;
165
- }
166
-
167
- function parseStatus(input: string): TaskStatus {
168
- if (input === "todo" || input === "in_progress" || input === "done") return input;
169
- return "todo";
170
- }
171
-
172
- function canTransition(current: TaskStatus, next: TaskStatus): boolean {
173
- if (current === next) return true;
174
- if (current === "todo" && next === "in_progress") return true;
175
- if (current === "in_progress" && next === "done") return true;
176
- return false;
177
- }
@@ -1,15 +0,0 @@
1
- export type TaskStatus = "todo" | "in_progress" | "done";
2
-
3
- export interface TaskItem {
4
- id: string;
5
- title: string;
6
- status: TaskStatus;
7
- source: string;
8
- createdAt: string;
9
- updatedAt: string;
10
- }
11
-
12
- export interface ArchiveRecord extends TaskItem {
13
- archivedAt: string;
14
- }
15
-
@@ -1,191 +0,0 @@
1
- import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import { describe, expect, it } from "vitest";
5
- import { addSkill, initSkills, listSkills } from "../src/skill-core/service.js";
6
-
7
- async function seedSkillRepo(): Promise<string> {
8
- const root = await mkdtemp(path.join(os.tmpdir(), "cdspec-skill-"));
9
- const skillDir = path.join(root, ".codex", "demo-skill");
10
- await mkdir(path.join(skillDir, "agents"), { recursive: true });
11
- await mkdir(path.join(skillDir, "references"), { recursive: true });
12
-
13
- await writeFile(
14
- path.join(skillDir, "SKILL.md"),
15
- [
16
- "---",
17
- "name: demo-skill",
18
- "description: demo description",
19
- "---",
20
- "",
21
- "# Demo Skill",
22
- "",
23
- "body"
24
- ].join("\n"),
25
- "utf8"
26
- );
27
- await writeFile(path.join(skillDir, "agents", "openai.yaml"), "x: 1\n", "utf8");
28
- await writeFile(path.join(skillDir, "references", "readme.md"), "ref\n", "utf8");
29
- return root;
30
- }
31
-
32
- describe("skill service", () => {
33
- it("lists valid skills", async () => {
34
- const root = await seedSkillRepo();
35
- const names = await listSkills(root);
36
- expect(names).toEqual(["demo-skill"]);
37
- });
38
-
39
- it("exports skill to all targets", async () => {
40
- const root = await seedSkillRepo();
41
- await addSkill(root, "demo-skill", "all", false);
42
-
43
- const codexSkill = await readFile(
44
- path.join(root, ".codex", "skills", "demo-skill", "SKILL.md"),
45
- "utf8"
46
- );
47
- const claudecodeMeta = await readFile(
48
- path.join(root, ".claude", "skills", "demo-skill", "claudecode.skill.json"),
49
- "utf8"
50
- );
51
- const iflowMeta = await readFile(
52
- path.join(root, ".iflow", "skills", "demo-skill", "iflow.skill.yaml"),
53
- "utf8"
54
- );
55
-
56
- expect(codexSkill).toContain("name: demo-skill");
57
- expect(claudecodeMeta).toContain('"name": "demo-skill"');
58
- expect(iflowMeta).toContain('name: "demo-skill"');
59
- });
60
-
61
- it("rejects conflict without force", async () => {
62
- const root = await seedSkillRepo();
63
- await addSkill(root, "demo-skill", "codex", false);
64
- await expect(addSkill(root, "demo-skill", "codex", false)).rejects.toThrow(
65
- /Use --force/
66
- );
67
- });
68
-
69
- it("supports ifow alias", async () => {
70
- const root = await seedSkillRepo();
71
- await addSkill(root, "demo-skill", "ifow", false);
72
- const iflowMeta = await readFile(
73
- path.join(root, ".iflow", "skills", "demo-skill", "iflow.skill.yaml"),
74
- "utf8"
75
- );
76
- expect(iflowMeta).toContain('entry: "prompt.md"');
77
- });
78
-
79
- it("generates agent config files on init", async () => {
80
- const root = await seedSkillRepo();
81
- const files = await initSkills(root, "codex,ifow", false);
82
- expect(files.some((file) => file.endsWith(path.join(".codex", "AGENTS.md")))).toBe(true);
83
- expect(files.some((file) => file.endsWith(path.join(".iflow", "IFLOW.md")))).toBe(true);
84
- expect(files.some((file) => file.endsWith(path.join("AGENTS.md")))).toBe(true);
85
-
86
- const agents = await readFile(path.join(root, ".codex", "AGENTS.md"), "utf8");
87
- const iflow = await readFile(path.join(root, ".iflow", "IFLOW.md"), "utf8");
88
- expect(agents).toContain("demo-skill");
89
- expect(iflow).toContain("/cd-demo-skill -> demo-skill");
90
- const command = await readFile(path.join(root, ".codex", "prompts", "cd-demo-skill.md"), "utf8");
91
- expect(command).toContain("skill: demo-skill");
92
- });
93
-
94
- it("creates default skill when .codex is empty", async () => {
95
- const root = await mkdtemp(path.join(os.tmpdir(), "cdspec-empty-"));
96
- const files = await initSkills(root, "codex", false);
97
- expect(files.some((file) => file.endsWith(path.join(".codex", "AGENTS.md")))).toBe(true);
98
- expect(files.some((file) => file.endsWith(path.join("AGENTS.md")))).toBe(true);
99
- const exported = await readFile(
100
- path.join(root, ".codex", "skills", "openspec-core", "SKILL.md"),
101
- "utf8"
102
- );
103
- await expect(readFile(path.join(root, ".cdspec", "seed-skills", "openspec-core", "SKILL.md"), "utf8")).rejects.toThrow();
104
- await expect(readFile(path.join(root, ".codex", "openspec-core", "SKILL.md"), "utf8")).rejects.toThrow();
105
- expect(exported).toContain("OpenSpec Core Skill");
106
- });
107
-
108
- it("supports configurable agent output paths", async () => {
109
- const root = await seedSkillRepo();
110
- await writeFile(
111
- path.join(root, "cdspec.config.yaml"),
112
- [
113
- "commandBindings:",
114
- " - id: plan",
115
- " skill: demo-skill",
116
- " description: Plan workflow",
117
- "agents:",
118
- " codex:",
119
- " rootDir: .codex",
120
- " commandsDir: prompts",
121
- " commandFilePattern: custom-{id}.md",
122
- " slashPattern: /custom-{id}",
123
- " guideFile: AGENTS.md",
124
- " claudecode:",
125
- " rootDir: .claude",
126
- " commandsDir: commands/opsx",
127
- " commandFilePattern: \"{id}.md\"",
128
- " slashPattern: /opsx:{id}",
129
- " guideFile: CLAUDE.md",
130
- " guideAtProjectRoot: true",
131
- " iflow:",
132
- " rootDir: .iflow",
133
- " commandsDir: commands",
134
- " commandFilePattern: opsx-{id}.md",
135
- " slashPattern: /opsx-{id}",
136
- " guideFile: IFLOW.md"
137
- ].join("\n"),
138
- "utf8"
139
- );
140
-
141
- await addSkill(root, "demo-skill", "codex", false);
142
- const exported = await readFile(path.join(root, ".codex", "skills", "demo-skill", "SKILL.md"), "utf8");
143
- expect(exported).toContain("name: demo-skill");
144
- });
145
-
146
- it("binds commands to template-derived skill when no .codex skills exist", async () => {
147
- const root = await mkdtemp(path.join(os.tmpdir(), "cdspec-template-bind-"));
148
- await mkdir(path.join(root, "templates"), { recursive: true });
149
- await writeFile(
150
- path.join(root, "templates", "frontend_develop_standard.md"),
151
- "# Frontend Develop Standard\n\nTemplate body",
152
- "utf8"
153
- );
154
- await initSkills(root, "codex", false);
155
-
156
- const exported = await readFile(
157
- path.join(root, ".codex", "skills", "frontend-develop-standard", "SKILL.md"),
158
- "utf8"
159
- );
160
- expect(exported).toContain("name: frontend-develop-standard");
161
-
162
- const prompt = await readFile(path.join(root, ".codex", "prompts", "cd-frontend-develop-standard.md"), "utf8");
163
- expect(prompt).toContain("skill: frontend-develop-standard");
164
- });
165
-
166
- it("prioritizes templates over existing .codex skills for init binding", async () => {
167
- const root = await seedSkillRepo();
168
- await mkdir(path.join(root, "templates"), { recursive: true });
169
- await writeFile(path.join(root, "templates", "my_template.md"), "# My Template\n", "utf8");
170
-
171
- await initSkills(root, "codex", true);
172
- const prompt = await readFile(path.join(root, ".codex", "prompts", "cd-my-template.md"), "utf8");
173
- expect(prompt).toContain("skill: my-template");
174
- const exported = await readFile(path.join(root, ".codex", "skills", "my-template", "SKILL.md"), "utf8");
175
- expect(exported).toContain("name: my-template");
176
- });
177
-
178
- it("uses full skill name for command ids", async () => {
179
- const root = await mkdtemp(path.join(os.tmpdir(), "cdspec-fullname-"));
180
- await mkdir(path.join(root, "templates", "design-doc", "agents"), { recursive: true });
181
- await writeFile(
182
- path.join(root, "templates", "design-doc", "SKILL.md"),
183
- ["---", "name: design-doc", "description: d", "---", "", "# d"].join("\n"),
184
- "utf8"
185
- );
186
- await writeFile(path.join(root, "templates", "design-doc", "agents", "openai.yaml"), "x: 1\n");
187
- await initSkills(root, "codex", false);
188
- const prompt = await readFile(path.join(root, ".codex", "prompts", "cd-design-doc.md"), "utf8");
189
- expect(prompt).toContain("skill: design-doc");
190
- });
191
- });
@@ -1,55 +0,0 @@
1
- import { mkdtemp, readFile, writeFile } from "node:fs/promises";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import { describe, expect, it } from "vitest";
5
- import { archiveTaskById, splitTasks, updateTask } from "../src/task-core/service.js";
6
- import { loadTask } from "../src/task-core/storage.js";
7
-
8
- describe("task workflow", () => {
9
- it("splits markdown into tasks and creates index", async () => {
10
- const root = await mkdtemp(path.join(os.tmpdir(), "cdspec-task-"));
11
- const source = path.join(root, "input.md");
12
- await writeFile(
13
- source,
14
- ["# Feature", "", "## Backend", "- API schema", "## Frontend", "- Build page"].join(
15
- "\n"
16
- ),
17
- "utf8"
18
- );
19
-
20
- const count = await splitTasks(root, "input.md", "Root Task");
21
- expect(count).toBe(2);
22
-
23
- const index = await readFile(path.join(root, ".cdspec", "tasks", "index.md"), "utf8");
24
- expect(index).toContain("## todo");
25
- });
26
-
27
- it("enforces status transitions and archive done tasks", async () => {
28
- const root = await mkdtemp(path.join(os.tmpdir(), "cdspec-task-"));
29
- const source = path.join(root, "plan.md");
30
- await writeFile(source, "- one\n", "utf8");
31
- await splitTasks(root, "plan.md", "Task Plan");
32
-
33
- const ids = await extractTaskIds(root);
34
- const id = ids[0];
35
-
36
- await expect(archiveTaskById(root, id)).rejects.toThrow(/must be done/);
37
-
38
- await expect(updateTask(root, id, "done")).rejects.toThrow(/Invalid status transition/);
39
- await updateTask(root, id, "in_progress");
40
- await updateTask(root, id, "done");
41
- await archiveTaskById(root, id);
42
-
43
- const task = await loadTask(root, id);
44
- expect(task).toBeNull();
45
- const archive = await readFile(path.join(root, ".cdspec", "archive", `${id}.md`), "utf8");
46
- expect(archive).toContain("archived_at");
47
- });
48
- });
49
-
50
- async function extractTaskIds(root: string): Promise<string[]> {
51
- const index = await readFile(path.join(root, ".cdspec", "tasks", "index.md"), "utf8");
52
- const ids = [...index.matchAll(/\[([a-f0-9]{10})\]/g)].map((match) => match[1]);
53
- return ids;
54
- }
55
-