@fission-ai/openspec 1.7.0 → 1.8.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 (95) hide show
  1. package/README.md +3 -1
  2. package/dist/cli/index.js +6 -1
  3. package/dist/commands/change.js +15 -1
  4. package/dist/commands/schema.js +89 -20
  5. package/dist/commands/spec.js +30 -6
  6. package/dist/commands/validate.js +2 -2
  7. package/dist/commands/workflow/instructions.js +32 -27
  8. package/dist/commands/workflow/status.js +2 -2
  9. package/dist/commands/workflow/templates.js +15 -5
  10. package/dist/core/archive.d.ts +14 -0
  11. package/dist/core/archive.js +1265 -151
  12. package/dist/core/artifact-graph/index.d.ts +1 -1
  13. package/dist/core/artifact-graph/index.js +1 -1
  14. package/dist/core/artifact-graph/instruction-loader.d.ts +3 -1
  15. package/dist/core/artifact-graph/instruction-loader.js +15 -5
  16. package/dist/core/artifact-graph/outputs.d.ts +1 -0
  17. package/dist/core/artifact-graph/outputs.js +67 -5
  18. package/dist/core/artifact-graph/resolver.js +35 -9
  19. package/dist/core/artifact-graph/types.js +21 -3
  20. package/dist/core/available-tools.d.ts +2 -2
  21. package/dist/core/available-tools.js +14 -3
  22. package/dist/core/change-metadata/schema.d.ts +1 -0
  23. package/dist/core/change-metadata/schema.js +7 -0
  24. package/dist/core/change-status-policy.js +2 -2
  25. package/dist/core/completions/command-registry.js +8 -0
  26. package/dist/core/config-schema.d.ts +3 -0
  27. package/dist/core/config-schema.js +28 -1
  28. package/dist/core/config.d.ts +3 -0
  29. package/dist/core/config.js +24 -2
  30. package/dist/core/file-state.js +56 -24
  31. package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
  32. package/dist/core/github-copilot/cloud-agent.js +497 -0
  33. package/dist/core/global-config.d.ts +11 -0
  34. package/dist/core/init.d.ts +23 -0
  35. package/dist/core/init.js +234 -39
  36. package/dist/core/legacy-cleanup.js +4 -2
  37. package/dist/core/migration.d.ts +6 -4
  38. package/dist/core/migration.js +78 -22
  39. package/dist/core/parsers/requirement-blocks.d.ts +10 -0
  40. package/dist/core/parsers/requirement-blocks.js +60 -0
  41. package/dist/core/parsers/spec-structure.d.ts +1 -1
  42. package/dist/core/parsers/spec-structure.js +17 -1
  43. package/dist/core/profile-sync-drift.js +40 -5
  44. package/dist/core/project-config.d.ts +3 -0
  45. package/dist/core/project-config.js +31 -1
  46. package/dist/core/shared/index.d.ts +1 -0
  47. package/dist/core/shared/index.js +1 -0
  48. package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
  49. package/dist/core/shared/skill-content-equivalence.js +41 -0
  50. package/dist/core/shared/skill-paths.d.ts +13 -0
  51. package/dist/core/shared/skill-paths.js +23 -0
  52. package/dist/core/shared/tool-detection.js +66 -35
  53. package/dist/core/shared-skill-target.d.ts +16 -0
  54. package/dist/core/shared-skill-target.js +156 -0
  55. package/dist/core/specs-apply.d.ts +72 -0
  56. package/dist/core/specs-apply.js +448 -55
  57. package/dist/core/templates/skill-templates.d.ts +1 -1
  58. package/dist/core/templates/skill-templates.js +1 -1
  59. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  60. package/dist/core/templates/workflows/apply-change.js +22 -182
  61. package/dist/core/templates/workflows/archive-change.js +8 -4
  62. package/dist/core/templates/workflows/bulk-archive-change.js +30 -26
  63. package/dist/core/templates/workflows/continue-change.js +6 -6
  64. package/dist/core/templates/workflows/explore.js +42 -18
  65. package/dist/core/templates/workflows/onboard.js +9 -3
  66. package/dist/core/templates/workflows/propose.js +72 -20
  67. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  68. package/dist/core/templates/workflows/store-selection.js +1 -1
  69. package/dist/core/templates/workflows/sync-specs.js +96 -18
  70. package/dist/core/templates/workflows/update-change.js +10 -8
  71. package/dist/core/update.d.ts +1 -0
  72. package/dist/core/update.js +103 -24
  73. package/dist/core/validation/validator.d.ts +23 -3
  74. package/dist/core/validation/validator.js +134 -18
  75. package/dist/core/version-check.js +6 -11
  76. package/dist/telemetry/config.d.ts +3 -4
  77. package/dist/telemetry/index.d.ts +9 -4
  78. package/dist/telemetry/index.js +20 -8
  79. package/dist/utils/change-metadata.d.ts +17 -4
  80. package/dist/utils/change-metadata.js +26 -4
  81. package/dist/utils/ci.d.ts +12 -0
  82. package/dist/utils/ci.js +16 -0
  83. package/dist/utils/command-references.d.ts +14 -2
  84. package/dist/utils/command-references.js +45 -3
  85. package/dist/utils/file-system.d.ts +10 -0
  86. package/dist/utils/file-system.js +70 -0
  87. package/dist/utils/interactive.d.ts +23 -0
  88. package/dist/utils/interactive.js +32 -0
  89. package/dist/utils/spec-discovery.d.ts +4 -4
  90. package/dist/utils/spec-discovery.js +19 -6
  91. package/dist/utils/task-progress.d.ts +17 -0
  92. package/dist/utils/task-progress.js +41 -13
  93. package/package.json +1 -1
  94. package/schemas/spec-driven/schema.yaml +8 -6
  95. package/schemas/spec-driven/templates/proposal.md +6 -4
@@ -59,12 +59,47 @@ const SKILL_INVOCATION_PREFIX = {
59
59
  kimi: '/skill:',
60
60
  codex: '$',
61
61
  };
62
+ /**
63
+ * Tools that have no slash-command surface at all: skills are matched
64
+ * automatically or invoked by natural-language prompts, never by typing a
65
+ * `/<name>` command. Rovo Dev CLI is such a tool — `/skills` only manages
66
+ * skills, and any `/openspec-*` form would be a dead command (see
67
+ * docs/supported-tools.md). References for these tools are spelled as prose
68
+ * ("the openspec-propose skill") so generated content never tells the user to
69
+ * type a command their CLI does not register.
70
+ */
71
+ const NATURAL_LANGUAGE_SKILL_TOOLS = new Set(['rovodev']);
72
+ /**
73
+ * Whether a tool references skills by natural language rather than a slash
74
+ * command (see NATURAL_LANGUAGE_SKILL_TOOLS).
75
+ */
76
+ export function usesNaturalLanguageSkillReferences(toolId) {
77
+ return NATURAL_LANGUAGE_SKILL_TOOLS.has(toolId);
78
+ }
79
+ function replaceCommandsWithNaturalLanguageSkillReferences(text) {
80
+ return text.replace(/\/opsx:([a-z-]+)/g, (match, commandId) => {
81
+ const skillName = COMMAND_TO_SKILL_NAME[commandId];
82
+ return skillName === undefined ? match : `the ${skillName} skill`;
83
+ });
84
+ }
62
85
  function replaceCommandsWithSkillReferences(text, prefix) {
63
86
  return text.replace(/\/opsx:([a-z-]+)/g, (match, commandId) => {
64
87
  const skillName = COMMAND_TO_SKILL_NAME[commandId];
65
88
  return skillName === undefined ? match : `${prefix}${skillName}`;
66
89
  });
67
90
  }
91
+ /**
92
+ * Keeps Codex's `$<name>` spelling first while making its canonical shared
93
+ * `.agents` tree usable by agents that invoke the same skills with `/<name>`.
94
+ */
95
+ export function transformToCodexCompatibleSkillReferences(text) {
96
+ return text.replace(/\/opsx:([a-z-]+)/g, (match, commandId) => {
97
+ const skillName = COMMAND_TO_SKILL_NAME[commandId];
98
+ return skillName === undefined
99
+ ? match
100
+ : `$${skillName} (Codex) or /${skillName} (other agents)`;
101
+ });
102
+ }
68
103
  /**
69
104
  * Transforms command references to skill references using the default `/`
70
105
  * invocation prefix. Converts `/opsx:<command>` patterns to
@@ -88,12 +123,17 @@ export function transformToSkillReferences(text) {
88
123
  /**
89
124
  * Returns the skill-reference transformer for a specific tool, honoring the
90
125
  * tool's documented skill invocation syntax (e.g. Kimi Code's
91
- * `/skill:openspec-propose`). Falls back to the default `/openspec-*` form.
126
+ * `/skill:openspec-propose`). Tools with no slash surface (e.g. Rovo Dev) get
127
+ * natural-language references ("the openspec-propose skill"); everything else
128
+ * falls back to the default `/openspec-*` form.
92
129
  *
93
- * @param toolId - The AI tool identifier (e.g. 'kimi', 'vibe')
130
+ * @param toolId - The AI tool identifier (e.g. 'kimi', 'vibe', 'rovodev')
94
131
  * @returns A transformer converting `/opsx:*` references to skill invocations
95
132
  */
96
133
  export function getSkillReferenceTransformer(toolId) {
134
+ if (usesNaturalLanguageSkillReferences(toolId)) {
135
+ return replaceCommandsWithNaturalLanguageSkillReferences;
136
+ }
97
137
  const prefix = SKILL_INVOCATION_PREFIX[toolId];
98
138
  if (prefix === undefined) {
99
139
  return transformToSkillReferences;
@@ -138,7 +178,9 @@ export function getSkillReferenceTransformer(toolId) {
138
178
  */
139
179
  export function getTransformerForTool(toolId, delivery, capability, invocation) {
140
180
  if (delivery === 'skills' || capability !== 'adapter-backed') {
141
- return getSkillReferenceTransformer(toolId);
181
+ return toolId === 'codex'
182
+ ? transformToCodexCompatibleSkillReferences
183
+ : getSkillReferenceTransformer(toolId);
142
184
  }
143
185
  if (toolId === 'devin' && delivery === 'both') {
144
186
  return getSkillReferenceTransformer(toolId);
@@ -9,6 +9,16 @@ export declare class FileSystemUtils {
9
9
  * Falls back to path.resolve() so callers can still produce a stable absolute path.
10
10
  */
11
11
  static canonicalizeExistingPath(targetPath: string): string;
12
+ /**
13
+ * Refuses a target that leaves an allowed directory, including through an
14
+ * existing symlink in either the target or one of its parent directories.
15
+ * Missing suffixes are resolved from their nearest existing ancestor.
16
+ */
17
+ static assertPathWithin(allowedDirectory: string, targetPath: string): void;
18
+ static resolveProjectArtifactPath(projectPath: string, artifactPath: string): string;
19
+ static assertProjectArtifactPath(projectPath: string, targetPath: string): void;
20
+ private static isPathWithin;
21
+ private static canonicalizePotentialPath;
12
22
  private static isWindowsBasePath;
13
23
  private static normalizeSegments;
14
24
  static joinPath(basePath: string, ...segments: string[]): string;
@@ -87,6 +87,76 @@ export class FileSystemUtils {
87
87
  }
88
88
  }
89
89
  }
90
+ /**
91
+ * Refuses a target that leaves an allowed directory, including through an
92
+ * existing symlink in either the target or one of its parent directories.
93
+ * Missing suffixes are resolved from their nearest existing ancestor.
94
+ */
95
+ static assertPathWithin(allowedDirectory, targetPath) {
96
+ const resolvedDirectory = path.resolve(allowedDirectory);
97
+ const resolvedTarget = path.resolve(targetPath);
98
+ if (!this.isPathWithin(resolvedDirectory, resolvedTarget)) {
99
+ throw new Error(`Path is outside the allowed directory: ${targetPath}`);
100
+ }
101
+ const canonicalDirectory = this.canonicalizePotentialPath(resolvedDirectory);
102
+ const canonicalTarget = this.canonicalizePotentialPath(resolvedTarget);
103
+ if (!this.isPathWithin(canonicalDirectory, canonicalTarget)) {
104
+ throw new Error(`Path is outside the allowed directory: ${targetPath}`);
105
+ }
106
+ }
107
+ static resolveProjectArtifactPath(projectPath, artifactPath) {
108
+ if (path.isAbsolute(artifactPath)) {
109
+ throw new Error(`Refusing to manage an artifact outside the project: ${artifactPath}`);
110
+ }
111
+ const targetPath = path.join(projectPath, artifactPath);
112
+ this.assertPathWithin(projectPath, targetPath);
113
+ return targetPath;
114
+ }
115
+ static assertProjectArtifactPath(projectPath, targetPath) {
116
+ this.assertPathWithin(projectPath, targetPath);
117
+ }
118
+ static isPathWithin(allowedDirectory, targetPath) {
119
+ const relative = path.relative(allowedDirectory, targetPath);
120
+ return (relative === '' ||
121
+ (relative !== '..' &&
122
+ !relative.startsWith(`..${path.sep}`) &&
123
+ !path.isAbsolute(relative)));
124
+ }
125
+ static canonicalizePotentialPath(targetPath) {
126
+ let existingPath = targetPath;
127
+ const missingSegments = [];
128
+ while (true) {
129
+ try {
130
+ // lstat distinguishes a missing path from a dangling symlink. A
131
+ // dangling link cannot be proven confined, so realpath must fail it.
132
+ nodeFs.lstatSync(existingPath);
133
+ const canonicalExisting = nodeFs.realpathSync.native(existingPath);
134
+ return path.resolve(canonicalExisting, ...missingSegments);
135
+ }
136
+ catch (error) {
137
+ const code = error.code;
138
+ if (code !== 'ENOENT') {
139
+ throw error;
140
+ }
141
+ try {
142
+ if (nodeFs.lstatSync(existingPath).isSymbolicLink()) {
143
+ throw new Error(`Cannot verify dangling symbolic link: ${existingPath}`);
144
+ }
145
+ }
146
+ catch (lstatError) {
147
+ if (lstatError.code !== 'ENOENT') {
148
+ throw lstatError;
149
+ }
150
+ }
151
+ const parent = path.dirname(existingPath);
152
+ if (parent === existingPath) {
153
+ throw new Error(`Cannot resolve an existing parent for ${targetPath}`);
154
+ }
155
+ missingSegments.unshift(path.basename(existingPath));
156
+ existingPath = parent;
157
+ }
158
+ }
159
+ }
90
160
  static isWindowsBasePath(basePath) {
91
161
  return /^[A-Za-z]:[\\/]/.test(basePath) || basePath.startsWith('\\');
92
162
  }
@@ -15,4 +15,27 @@ export type InteractiveOptions = {
15
15
  */
16
16
  export declare function resolveNoInteractive(value?: boolean | InteractiveOptions): boolean;
17
17
  export declare function isInteractive(value?: boolean | InteractiveOptions): boolean;
18
+ /**
19
+ * True when a prompt failed because no answer could be read — an agent or a
20
+ * script that ran the command with stdin closed, a CI job, or a shell whose
21
+ * stdin is not a terminal. @inquirer rejects those with `User force closed
22
+ * the prompt with 0 null`, which is accurate and useless: it names no flag
23
+ * and no next step (#1479).
24
+ *
25
+ * Two things it deliberately is not:
26
+ *
27
+ * - It is not a substitute for `isInteractive()`. This classifies a prompt
28
+ * that has *already failed*, so piped answers are unaffected: an answer
29
+ * that arrives resolves the prompt and never reaches this check. Refusing
30
+ * to prompt up front would break `printf 'y\n' | openspec archive ...`,
31
+ * which works today.
32
+ * - It is not a cancellation check. Ctrl-C raises the same error class, and
33
+ * it reaches a process whose stdin is a pipe just as easily as one at a
34
+ * terminal, so the SIGINT signal - not the terminal - is what proves
35
+ * somebody was there and chose to quit.
36
+ *
37
+ * Beyond that it defers to `isInteractive()`, so `CI`, `OPEN_SPEC_INTERACTIVE=0`
38
+ * and `--no-interactive` count even when a runner allocated a pty.
39
+ */
40
+ export declare function isNonInteractivePromptError(error: unknown, value?: boolean | InteractiveOptions): boolean;
18
41
  //# sourceMappingURL=interactive.d.ts.map
@@ -18,4 +18,36 @@ export function isInteractive(value) {
18
18
  return false;
19
19
  return !!process.stdin.isTTY;
20
20
  }
21
+ /**
22
+ * True when a prompt failed because no answer could be read — an agent or a
23
+ * script that ran the command with stdin closed, a CI job, or a shell whose
24
+ * stdin is not a terminal. @inquirer rejects those with `User force closed
25
+ * the prompt with 0 null`, which is accurate and useless: it names no flag
26
+ * and no next step (#1479).
27
+ *
28
+ * Two things it deliberately is not:
29
+ *
30
+ * - It is not a substitute for `isInteractive()`. This classifies a prompt
31
+ * that has *already failed*, so piped answers are unaffected: an answer
32
+ * that arrives resolves the prompt and never reaches this check. Refusing
33
+ * to prompt up front would break `printf 'y\n' | openspec archive ...`,
34
+ * which works today.
35
+ * - It is not a cancellation check. Ctrl-C raises the same error class, and
36
+ * it reaches a process whose stdin is a pipe just as easily as one at a
37
+ * terminal, so the SIGINT signal - not the terminal - is what proves
38
+ * somebody was there and chose to quit.
39
+ *
40
+ * Beyond that it defers to `isInteractive()`, so `CI`, `OPEN_SPEC_INTERACTIVE=0`
41
+ * and `--no-interactive` count even when a runner allocated a pty.
42
+ */
43
+ export function isNonInteractivePromptError(error, value) {
44
+ if (!(error instanceof Error))
45
+ return false;
46
+ const failedPrompt = error.name === 'ExitPromptError' || error.message.includes('force closed the prompt');
47
+ if (!failedPrompt)
48
+ return false;
49
+ if (error.message.includes('SIGINT'))
50
+ return false;
51
+ return !isInteractive(value);
52
+ }
21
53
  //# sourceMappingURL=interactive.js.map
@@ -10,10 +10,10 @@ export interface DiscoveredSpec {
10
10
  * are found (#1353). A `spec.md` sitting directly in the root is ignored,
11
11
  * matching the historical requirement that specs live in a capability folder.
12
12
  * Dot-directories are skipped and symlinked directories are not followed.
13
- * A symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the artifact
14
- * graph's globs both count it as content, so dropping it here would silently
15
- * lose the delta on archive; a dangling link is skipped. Results are sorted
16
- * by id for deterministic output.
13
+ * An in-capability symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the
14
+ * artifact graph's globs both count it as content, so dropping it here would
15
+ * silently lose the delta on archive. A link outside its capability is
16
+ * rejected and a dangling link is skipped. Results are sorted by id.
17
17
  *
18
18
  * A missing root (ENOENT) yields an empty list, but any other read failure
19
19
  * (EACCES, EIO, ...) is thrown rather than swallowed: since this feeds the
@@ -1,15 +1,26 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
+ import { FileSystemUtils } from './file-system.js';
4
+ function assertDiscoveredSpecPath(specsRoot, capabilityDir, specFile) {
5
+ try {
6
+ FileSystemUtils.assertPathWithin(specsRoot, specFile);
7
+ }
8
+ catch {
9
+ // Direct capability directories may intentionally be external monorepo
10
+ // links. In that case, confine the file to the capability itself.
11
+ FileSystemUtils.assertPathWithin(capabilityDir, specFile);
12
+ }
13
+ }
3
14
  /**
4
15
  * Recursively discover every `spec.md` under a specs root, so both the flat
5
16
  * `specs/<id>/spec.md` layout and nested `specs/<area>/<id>/spec.md` layouts
6
17
  * are found (#1353). A `spec.md` sitting directly in the root is ignored,
7
18
  * matching the historical requirement that specs live in a capability folder.
8
19
  * Dot-directories are skipped and symlinked directories are not followed.
9
- * A symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the artifact
10
- * graph's globs both count it as content, so dropping it here would silently
11
- * lose the delta on archive; a dangling link is skipped. Results are sorted
12
- * by id for deterministic output.
20
+ * An in-capability symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the
21
+ * artifact graph's globs both count it as content, so dropping it here would
22
+ * silently lose the delta on archive. A link outside its capability is
23
+ * rejected and a dangling link is skipped. Results are sorted by id.
13
24
  *
14
25
  * A missing root (ENOENT) yields an empty list, but any other read failure
15
26
  * (EACCES, EIO, ...) is thrown rather than swallowed: since this feeds the
@@ -35,13 +46,15 @@ export async function discoverSpecFiles(specsRoot) {
35
46
  await walk(path.join(dir, entry.name), [...segments, entry.name]);
36
47
  }
37
48
  else if (entry.name === 'spec.md' && segments.length > 0) {
49
+ const specFile = path.join(dir, entry.name);
38
50
  if (entry.isFile()) {
39
- results.push({ id: segments.join('/'), specFile: path.join(dir, entry.name) });
51
+ assertDiscoveredSpecPath(specsRoot, dir, specFile);
52
+ results.push({ id: segments.join('/'), specFile });
40
53
  }
41
54
  else if (entry.isSymbolicLink()) {
42
- const specFile = path.join(dir, entry.name);
43
55
  try {
44
56
  if ((await fs.stat(specFile)).isFile()) {
57
+ assertDiscoveredSpecPath(specsRoot, dir, specFile);
45
58
  results.push({ id: segments.join('/'), specFile });
46
59
  }
47
60
  }
@@ -1,3 +1,20 @@
1
+ export interface ParsedTask {
2
+ /** Checkbox state: `[x]`/`[X]` is done, anything else is not. */
3
+ done: boolean;
4
+ /** Task text after the checkbox, trimmed (may be empty). */
5
+ description: string;
6
+ }
7
+ /**
8
+ * Parses every task line in a tasks file, in document order.
9
+ *
10
+ * Every line matching the pattern counts, wherever it sits - inside a code
11
+ * fence, an HTML comment or an indented block, as before. Skipping fenced
12
+ * checkboxes was tried and dropped: every rule for deciding which fence is
13
+ * "real" has an input where a stray or unbalanced ``` swallows genuine tasks.
14
+ * Counting a documented example as work is a loud, bypassable false positive;
15
+ * losing a real task is a silent one.
16
+ */
17
+ export declare function parseTaskLines(content: string): ParsedTask[];
1
18
  export interface TaskProgress {
2
19
  total: number;
3
20
  completed: number;
@@ -2,21 +2,49 @@ import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
3
  import { resolveArtifactOutputs, resolveSchema } from '../core/artifact-graph/index.js';
4
4
  import { resolveSchemaForChange } from './change-metadata.js';
5
- const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i;
6
- const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i;
7
- export function countTasksFromContent(content) {
8
- const lines = content.split('\n');
9
- let total = 0;
10
- let completed = 0;
11
- for (const line of lines) {
12
- if (line.match(TASK_PATTERN)) {
13
- total++;
14
- if (line.match(COMPLETED_TASK_PATTERN)) {
15
- completed++;
16
- }
5
+ /**
6
+ * A Markdown task line: a `-`/`*` bullet carrying a `[ ]` or `[x]` checkbox.
7
+ *
8
+ * Leading whitespace is allowed so nested sub-tasks count like their parents.
9
+ * Anchoring at column 0 made ` - [ ] 1.1.1 ...` invisible to progress, to the
10
+ * apply task list, and to archive's incomplete-task check, so a change with
11
+ * unfinished sub-tasks reported "✓ Complete" and archived without a warning.
12
+ *
13
+ * Permissive on purpose, and safe to keep that way: any character class
14
+ * tightened here - the `\s` inside the brackets, which lets a tab or
15
+ * non-breaking space stand for an empty box - drops lines that used to count,
16
+ * and a task this parser drops is a task `openspec archive` stops warning about.
17
+ *
18
+ * Deliberately unanchored at the end: `.` does not match `\r`, so writing the
19
+ * description group as `(.*)$` would reject every line of a CRLF tasks.md.
20
+ */
21
+ const TASK_LINE_PATTERN = /^\s*[-*]\s*\[([\sxX])\]\s*(.*)/;
22
+ /**
23
+ * Parses every task line in a tasks file, in document order.
24
+ *
25
+ * Every line matching the pattern counts, wherever it sits - inside a code
26
+ * fence, an HTML comment or an indented block, as before. Skipping fenced
27
+ * checkboxes was tried and dropped: every rule for deciding which fence is
28
+ * "real" has an input where a stray or unbalanced ``` swallows genuine tasks.
29
+ * Counting a documented example as work is a loud, bypassable false positive;
30
+ * losing a real task is a silent one.
31
+ */
32
+ export function parseTaskLines(content) {
33
+ const tasks = [];
34
+ for (const line of content.split('\n')) {
35
+ const match = line.match(TASK_LINE_PATTERN);
36
+ if (match) {
37
+ tasks.push({ done: match[1].toLowerCase() === 'x', description: match[2].trim() });
17
38
  }
18
39
  }
19
- return { total, completed };
40
+ return tasks;
41
+ }
42
+ export function countTasksFromContent(content) {
43
+ const tasks = parseTaskLines(content);
44
+ return {
45
+ total: tasks.length,
46
+ completed: tasks.filter((task) => task.done).length,
47
+ };
20
48
  }
21
49
  /**
22
50
  * Identifies the change's tracked-tasks artifact: the artifact whose `generates`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fission-ai/openspec",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "openspec",
@@ -13,8 +13,8 @@ artifacts:
13
13
  - **Why**: 1-2 sentences on the problem or opportunity. What problem does this solve? Why now?
14
14
  - **What Changes**: Bullet list of changes. Be specific about new capabilities, modifications, or removals. Mark breaking changes with **BREAKING**.
15
15
  - **Capabilities**: Identify which specs will be created or modified:
16
- - **New Capabilities**: List capabilities being introduced. Each becomes a new `specs/<name>/spec.md`. Use kebab-case names (e.g., `user-auth`, `data-export`).
17
- - **Modified Capabilities**: List existing capabilities whose REQUIREMENTS are changing. Only include if spec-level behavior changes (not just implementation details). Each needs a delta spec file. Check `openspec/specs/` for existing spec names. Leave empty if no requirement changes.
16
+ - **New Capabilities**: List capabilities being introduced. Each becomes a new `specs/<capability-path>/spec.md`. Use kebab-case for path segments you introduce (e.g., `user-auth` or `identity/user-auth`) and follow the project's existing spec organization.
17
+ - **Modified Capabilities**: List existing capabilities whose REQUIREMENTS are changing. Only include if spec-level behavior changes (not just implementation details). Each needs a delta spec file. Use the exact existing path under `openspec/specs/`. Leave empty if no requirement changes.
18
18
  - **Impact**: Affected code, APIs, dependencies, or systems.
19
19
 
20
20
  IMPORTANT: The Capabilities section is critical. It creates the contract between
@@ -60,8 +60,10 @@ artifacts:
60
60
  visible behavior, it likely does not belong in the spec.
61
61
 
62
62
  Create one spec file per capability listed in the proposal's Capabilities section.
63
- - New capabilities: use the exact kebab-case name from the proposal (specs/<capability>/spec.md).
64
- - Modified capabilities: use the existing spec folder name from openspec/specs/<capability>/ when creating the delta spec at specs/<capability>/spec.md.
63
+ `<capability-path>` is the spec directory relative to `specs/` (for example,
64
+ `user-auth` or `identity/user-auth`). Preserve the full path:
65
+ - New capabilities: use the exact path from the proposal at `specs/<capability-path>/spec.md`. Any path segment newly introduced in the proposal must be kebab-case. Follow the project's existing organization; do not add a new domain level when the project uses a flat layout.
66
+ - Modified capabilities: use the exact existing path from `openspec/specs/<capability-path>/` when creating the delta at `specs/<capability-path>/spec.md`. Do not move or rename the capability.
65
67
 
66
68
  There must be at least one spec file unless the change's `.openspec.yaml`
67
69
  sets `skip_specs: true` (no spec-level behavior change) - `openspec validate`
@@ -89,10 +91,10 @@ artifacts:
89
91
  by hand. Do NOT add `## Purpose` to a delta for an existing capability -
90
92
  that spec already has one and the delta's is ignored. To change an
91
93
  existing capability's Purpose - including a leftover `TBD` placeholder -
92
- edit `openspec/specs/<capability>/spec.md` directly.
94
+ edit `openspec/specs/<capability-path>/spec.md` directly.
93
95
 
94
96
  MODIFIED requirements workflow:
95
- 1. Locate the existing requirement in openspec/specs/<capability>/spec.md
97
+ 1. Locate the existing requirement in openspec/specs/<capability-path>/spec.md
96
98
  2. Copy the ENTIRE requirement block (from `### Requirement:` through all scenarios)
97
99
  3. Paste under `## MODIFIED Requirements` and edit to reflect new behavior
98
100
  4. Ensure header text matches exactly (whitespace-insensitive)
@@ -9,18 +9,20 @@
9
9
  ## Capabilities
10
10
 
11
11
  ### New Capabilities
12
- <!-- Capabilities being introduced. Replace <name> with kebab-case identifier (e.g., user-auth, data-export, api-rate-limiting). Each creates specs/<name>/spec.md -->
13
- - `<name>`: <brief description of what this capability covers>
12
+ <!-- Capabilities being introduced. Use kebab-case for path segments you introduce
13
+ (e.g., user-auth or identity/user-auth) that follow the project's existing
14
+ spec organization. Each creates specs/<capability-path>/spec.md. -->
15
+ - `<capability-path>`: <brief description of what this capability covers>
14
16
 
15
17
  ### Modified Capabilities
16
18
  <!-- Existing capabilities whose REQUIREMENTS are changing (not just implementation).
17
19
  Only list here if spec-level behavior changes. Each needs a delta spec file.
18
- Use existing spec names from openspec/specs/. Leave empty if no requirement
20
+ Use the exact existing path under openspec/specs/. Leave empty if no requirement
19
21
  changes. A change with no capabilities at all (pure refactor, tooling, docs)
20
22
  must set `skip_specs: true` in its .openspec.yaml - openspec validate rejects
21
23
  a zero-delta change without that marker. Do not invent a requirement just to
22
24
  satisfy validation. -->
23
- - `<existing-name>`: <what requirement is changing>
25
+ - `<existing-capability-path>`: <what requirement is changing>
24
26
 
25
27
  ## Impact
26
28