@fission-ai/openspec 1.13.0 → 1.13.2

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 (113) hide show
  1. package/README.md +16 -4
  2. package/dist/commands/change.js +12 -1
  3. package/dist/commands/config.d.ts +9 -0
  4. package/dist/commands/config.js +161 -22
  5. package/dist/commands/feedback.js +6 -4
  6. package/dist/commands/schema.js +30 -9
  7. package/dist/commands/store.js +5 -2
  8. package/dist/commands/validate.d.ts +7 -0
  9. package/dist/commands/validate.js +63 -1
  10. package/dist/commands/workflow/instructions.js +69 -22
  11. package/dist/commands/workflow/new-change.js +20 -0
  12. package/dist/commands/workflow/shared.d.ts +5 -0
  13. package/dist/commands/workflow/shared.js +8 -0
  14. package/dist/commands/workflow/status.d.ts +5 -1
  15. package/dist/commands/workflow/status.js +42 -5
  16. package/dist/core/archive.js +260 -77
  17. package/dist/core/artifact-graph/outputs.d.ts +1 -1
  18. package/dist/core/artifact-graph/outputs.js +38 -8
  19. package/dist/core/artifact-graph/schema.d.ts +14 -0
  20. package/dist/core/artifact-graph/schema.js +52 -0
  21. package/dist/core/artifact-graph/types.js +12 -1
  22. package/dist/core/change-status-policy.d.ts +14 -0
  23. package/dist/core/change-status-policy.js +14 -6
  24. package/dist/core/command-generation/adapters/continue.js +4 -0
  25. package/dist/core/command-generation/adapters/kilocode.d.ts +1 -1
  26. package/dist/core/command-generation/adapters/kilocode.js +2 -2
  27. package/dist/core/command-generation/generator.js +2 -0
  28. package/dist/core/completion-tip.js +7 -7
  29. package/dist/core/completions/installers/bash-installer.js +15 -7
  30. package/dist/core/completions/installers/shell-quote.d.ts +11 -0
  31. package/dist/core/completions/installers/shell-quote.js +13 -0
  32. package/dist/core/completions/installers/zsh-installer.js +3 -2
  33. package/dist/core/config-schema.d.ts +1 -1
  34. package/dist/core/config.d.ts +16 -0
  35. package/dist/core/config.js +27 -1
  36. package/dist/core/global-config.d.ts +30 -2
  37. package/dist/core/global-config.js +57 -3
  38. package/dist/core/init.js +22 -6
  39. package/dist/core/legacy-cleanup.d.ts +5 -1
  40. package/dist/core/legacy-cleanup.js +207 -14
  41. package/dist/core/list.js +55 -11
  42. package/dist/core/migration.js +6 -1
  43. package/dist/core/parsers/change-parser.d.ts +12 -1
  44. package/dist/core/parsers/change-parser.js +85 -87
  45. package/dist/core/parsers/markdown-parser.js +4 -3
  46. package/dist/core/parsers/requirement-blocks.d.ts +78 -6
  47. package/dist/core/parsers/requirement-blocks.js +155 -38
  48. package/dist/core/parsers/requirement-text.d.ts +13 -2
  49. package/dist/core/parsers/requirement-text.js +34 -7
  50. package/dist/core/parsers/spec-structure.js +4 -1
  51. package/dist/core/project-config.d.ts +9 -1
  52. package/dist/core/project-config.js +11 -2
  53. package/dist/core/references.d.ts +20 -0
  54. package/dist/core/references.js +56 -0
  55. package/dist/core/shared/skill-content-equivalence.js +5 -1
  56. package/dist/core/shared/skill-generation.js +36 -8
  57. package/dist/core/shared/tool-detection.js +9 -3
  58. package/dist/core/specs-apply.d.ts +20 -0
  59. package/dist/core/specs-apply.js +101 -17
  60. package/dist/core/store/git.d.ts +33 -0
  61. package/dist/core/store/git.js +60 -6
  62. package/dist/core/store/operations.d.ts +1 -1
  63. package/dist/core/store/operations.js +32 -10
  64. package/dist/core/store/registry.d.ts +2 -1
  65. package/dist/core/store/registry.js +5 -1
  66. package/dist/core/templates/optional-workflow.d.ts +69 -0
  67. package/dist/core/templates/optional-workflow.js +140 -0
  68. package/dist/core/templates/workflows/apply-change.js +18 -3
  69. package/dist/core/templates/workflows/archive-change.js +70 -21
  70. package/dist/core/templates/workflows/bulk-archive-change.js +85 -21
  71. package/dist/core/templates/workflows/continue-change.js +14 -4
  72. package/dist/core/templates/workflows/explore.js +34 -16
  73. package/dist/core/templates/workflows/ff-change.js +21 -5
  74. package/dist/core/templates/workflows/new-change.js +15 -3
  75. package/dist/core/templates/workflows/onboard.js +76 -41
  76. package/dist/core/templates/workflows/project-root.d.ts +29 -0
  77. package/dist/core/templates/workflows/project-root.js +38 -0
  78. package/dist/core/templates/workflows/propose.js +22 -5
  79. package/dist/core/templates/workflows/sync-specs.js +40 -1
  80. package/dist/core/templates/workflows/update-change.js +66 -27
  81. package/dist/core/templates/workflows/verify-change.js +121 -49
  82. package/dist/core/update.d.ts +12 -0
  83. package/dist/core/update.js +78 -10
  84. package/dist/core/validation/purpose-placeholder.js +24 -9
  85. package/dist/core/validation/task-checkboxes.d.ts +34 -0
  86. package/dist/core/validation/task-checkboxes.js +181 -0
  87. package/dist/core/validation/validator.d.ts +53 -1
  88. package/dist/core/validation/validator.js +160 -45
  89. package/dist/core/version-check.d.ts +14 -7
  90. package/dist/core/version-check.js +63 -17
  91. package/dist/core/worksets.js +6 -4
  92. package/dist/prompts/searchable-multi-select.d.ts +7 -0
  93. package/dist/prompts/searchable-multi-select.js +35 -6
  94. package/dist/telemetry/config.js +14 -2
  95. package/dist/telemetry/index.d.ts +4 -3
  96. package/dist/telemetry/index.js +22 -10
  97. package/dist/telemetry/opt-out.d.ts +17 -0
  98. package/dist/telemetry/opt-out.js +27 -0
  99. package/dist/utils/file-system.js +20 -3
  100. package/dist/utils/line-endings.d.ts +34 -0
  101. package/dist/utils/line-endings.js +46 -0
  102. package/dist/utils/nested-change.d.ts +35 -0
  103. package/dist/utils/nested-change.js +186 -0
  104. package/dist/utils/spec-discovery.d.ts +20 -0
  105. package/dist/utils/spec-discovery.js +59 -0
  106. package/dist/utils/task-progress.d.ts +1 -1
  107. package/dist/utils/task-progress.js +38 -6
  108. package/package.json +5 -5
  109. package/schemas/spec-driven/schema.yaml +18 -3
  110. package/schemas/spec-driven/templates/design.md +2 -0
  111. package/schemas/spec-driven/templates/proposal.md +2 -0
  112. package/schemas/spec-driven/templates/spec.md +2 -0
  113. package/schemas/spec-driven/templates/tasks.md +2 -0
@@ -0,0 +1,186 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import { artifactOutputExists } from '../core/artifact-graph/outputs.js';
4
+ import { resolveSchema } from '../core/artifact-graph/resolver.js';
5
+ import { METADATA_FILENAME, resolveSchemaForChange } from './change-metadata.js';
6
+ import { hasAnyFileUnder } from './spec-discovery.js';
7
+ /**
8
+ * Files that only ever sit at the root of a change directory. Their presence
9
+ * inside a directory that is *not* directly under `changes/` is one of the two
10
+ * signals that someone laid a change out in a namespace folder
11
+ * (`changes/<area>/<name>/`), which OpenSpec does not support: a change is a
12
+ * directory directly under `changes/` and nothing else (#1846).
13
+ *
14
+ * The list is deliberately short. Every entry is written by OpenSpec itself for
15
+ * the default schema - `.openspec.yaml` by `openspec new change` for *every*
16
+ * schema. A custom schema whose root artifacts are named differently can still
17
+ * be missed when the directory was created by hand rather than by the CLI; that
18
+ * misses the diagnostic and behaves exactly as it did before, which is the safe
19
+ * way round.
20
+ */
21
+ const CHANGE_ROOT_MARKERS = [
22
+ METADATA_FILENAME,
23
+ 'proposal.md',
24
+ 'tasks.md',
25
+ 'design.md',
26
+ ];
27
+ /** Delta specs live here inside a change, at any depth below it. */
28
+ const DELTA_SPECS_DIR = 'specs';
29
+ /**
30
+ * How far below a candidate directory to look for a nested change. One level
31
+ * covers the layout the issue describes (`changes/mobile/refresh-token/`); the
32
+ * extra levels cover a store namespaced by more than one axis
33
+ * (`changes/mobile/ios/refresh-token/`) without turning this into an unbounded
34
+ * walk of every change's spec tree.
35
+ */
36
+ const MAX_NESTING_DEPTH = 3;
37
+ async function hasChangeRootMarker(dir) {
38
+ for (const marker of CHANGE_ROOT_MARKERS) {
39
+ const stats = await fs.stat(path.join(dir, marker)).catch(() => undefined);
40
+ if (stats?.isFile())
41
+ return true;
42
+ }
43
+ return false;
44
+ }
45
+ /**
46
+ * Whether `dir` is a change rather than a folder wrapping one.
47
+ *
48
+ * Two independent signals, because either alone leaves a hole. A root artifact
49
+ * covers the CLI-created change and the hand-made one that starts from a
50
+ * proposal. A populated `specs/` covers the change that starts from its delta
51
+ * specs - the case that arrives here most often, since a nested change is
52
+ * always hand-made (`openspec new change` rejects a name with a separator) and
53
+ * "mkdir the tree, write the deltas" is how someone gets there.
54
+ *
55
+ * The `specs/` signal is what keeps a custom schema safe. A schema may generate
56
+ * every root artifact into a subdirectory (`generates: rfc/proposal.md`), and
57
+ * such a change, created by hand so it has no `.openspec.yaml`, would otherwise
58
+ * look exactly like a namespace folder wrapping a change called `rfc`. Its
59
+ * delta specs still live under `specs/`, so it is recognised as the change it
60
+ * is. `specs/**\/*.md` is fixed by the delta format itself, not by the schema.
61
+ */
62
+ async function looksLikeChange(dir, projectRoot) {
63
+ if (await hasChangeRootMarker(dir))
64
+ return true;
65
+ if (await hasAnyFileUnder(path.join(dir, DELTA_SPECS_DIR)).catch(() => false))
66
+ return true;
67
+ return hasSchemaOutput(dir, projectRoot);
68
+ }
69
+ /**
70
+ * Whether `dir` already holds a file exactly where the schema it resolves to
71
+ * would generate one - the signal `openspec status` itself uses to call an
72
+ * artifact done. It covers the subdirectory-rooted custom schema before any
73
+ * delta spec exists, when neither other signal can. A schema that cannot be
74
+ * resolved gives no signal, which leaves the other two in charge.
75
+ */
76
+ function hasSchemaOutput(dir, projectRoot) {
77
+ try {
78
+ const schema = resolveSchema(resolveSchemaForChange(dir, undefined, projectRoot), projectRoot);
79
+ return schema.artifacts.some((artifact) => artifactOutputExists(dir, artifact.generates));
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ }
85
+ /**
86
+ * Whether `dir` holds any file of its own. A change directory with content at
87
+ * its root - a custom schema's artifact under a name this module does not know,
88
+ * a README, a note - is never reported as a namespace folder. A namespace
89
+ * folder someone created with `mkdir` holds nothing but directories.
90
+ */
91
+ async function hasOwnFile(dir) {
92
+ const entries = await fs
93
+ .readdir(dir, { withFileTypes: true })
94
+ .catch(() => []);
95
+ return entries.some((entry) => !entry.name.startsWith('.') && !entry.isDirectory());
96
+ }
97
+ /**
98
+ * Subdirectories of `dir`, or none when it cannot be read. This probe only ever
99
+ * adds a diagnostic, so an unreadable directory falls back to the behaviour
100
+ * that existed before it rather than failing the command around it.
101
+ */
102
+ async function readSubdirectories(dir) {
103
+ const entries = await fs
104
+ .readdir(dir, { withFileTypes: true })
105
+ .catch(() => []);
106
+ return entries
107
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
108
+ .map((entry) => entry.name);
109
+ }
110
+ /**
111
+ * Collects the nested change directories under `dir`, deepest search bounded by
112
+ * `MAX_NESTING_DEPTH`. A directory that carries a marker is reported and not
113
+ * descended into: its own `specs/` tree is part of that change, not a further
114
+ * nesting level.
115
+ */
116
+ async function collectNested(dir, prefix, depth, found, projectRoot) {
117
+ if (depth > MAX_NESTING_DEPTH)
118
+ return;
119
+ for (const child of await readSubdirectories(dir)) {
120
+ const childPath = path.join(dir, child);
121
+ const id = `${prefix}/${child}`;
122
+ if (await looksLikeChange(childPath, projectRoot)) {
123
+ found.push(id);
124
+ continue;
125
+ }
126
+ await collectNested(childPath, id, depth + 1, found, projectRoot);
127
+ }
128
+ }
129
+ /**
130
+ * Reports whether the directory `changes/<name>/` is a namespace folder holding
131
+ * one or more nested change directories rather than a change of its own.
132
+ *
133
+ * Returns undefined for every ordinary change, including a scaffolded one with
134
+ * no artifacts yet: only a *nested* change directory distinguishes the two.
135
+ */
136
+ export async function findNestedChangesIn(changesDir, name) {
137
+ // `archive` is not a change and never was, so its dated entries must not be
138
+ // read as nested changes and offered up for renaming. `list`, `status` and
139
+ // `validate` already exclude it; `change show` does not.
140
+ if (name === 'archive' || name.startsWith('.'))
141
+ return undefined;
142
+ const dir = path.join(changesDir, name);
143
+ // changes/ is always <root>/openspec/changes, for project and store roots.
144
+ const projectRoot = path.resolve(changesDir, '..', '..');
145
+ if (await looksLikeChange(dir, projectRoot))
146
+ return undefined;
147
+ if (await hasOwnFile(dir))
148
+ return undefined;
149
+ const nested = [];
150
+ await collectNested(dir, name, 1, nested, projectRoot);
151
+ if (nested.length === 0)
152
+ return undefined;
153
+ return { name, nested: nested.sort() };
154
+ }
155
+ /**
156
+ * The same check across every candidate directory under `changes/`, for the
157
+ * commands that enumerate rather than resolve a single name.
158
+ */
159
+ export async function findNestedChanges(changesDir, names) {
160
+ const findings = await Promise.all(names.map((name) => findNestedChangesIn(changesDir, name)));
161
+ return findings.filter((finding) => finding !== undefined);
162
+ }
163
+ /**
164
+ * The stable phrase every nested-change diagnostic contains. Callers that have
165
+ * only the rendered message - `validate`'s next-steps, which branches on the
166
+ * exact issue it is advising about - recognise the case by this.
167
+ */
168
+ export const NESTED_CHANGE_ISSUE_MARKER = 'is not a change: it is a folder wrapping';
169
+ /** Flattens a namespace path into the change name OpenSpec would accept. */
170
+ function flatten(nestedId) {
171
+ return nestedId.split('/').join('-');
172
+ }
173
+ /**
174
+ * The one explanation every surface prints, so the constraint and the way out
175
+ * read identically wherever a user meets them.
176
+ */
177
+ export function describeNestedChange(finding) {
178
+ const list = finding.nested.map((id) => `openspec/changes/${id}/`).join(', ');
179
+ const example = flatten(finding.nested[0]);
180
+ return (`"${finding.name}" ${NESTED_CHANGE_ISSUE_MARKER} ${list}. ` +
181
+ 'A change must be a directory directly under openspec/changes/, so those ' +
182
+ 'nested directories are invisible to OpenSpec while the folder around them ' +
183
+ 'is reported as a change. Nested paths are supported under openspec/specs/ ' +
184
+ `only. Rename each nested change to a flat name (for example "${example}").`);
185
+ }
186
+ //# sourceMappingURL=nested-change.js.map
@@ -21,6 +21,26 @@ export interface DiscoveredSpec {
21
21
  * recreate the exact data-loss class #1353 is closing.
22
22
  */
23
23
  export declare function discoverSpecFiles(specsRoot: string): Promise<DiscoveredSpec[]>;
24
+ export interface UnreadDeltaFile {
25
+ /** File path relative to the specs root, forward-slash separated. */
26
+ path: string;
27
+ /** The spec.md the merge path reads for it, relative to the specs root. */
28
+ expected: string;
29
+ }
30
+ /**
31
+ * Markdown files under a change's specs/ that carry delta sections but are not
32
+ * a capability's `spec.md`, so discoverSpecFiles, and with it validate and
33
+ * archive, never reads them: `specs/user-auth.md`, or `specs/user-auth/delta.md`
34
+ * beside or instead of the capability's spec.md. The artifact graph's
35
+ * recursive specs/ markdown glob does match them, so status and apply report
36
+ * the specs as written while archive has nothing to merge. A `spec.md` at the
37
+ * specs/ root has its own check (#1385) and is not repeated here. Notes with
38
+ * no delta section are not deltas and are not reported. The walk matches
39
+ * discoverSpecFiles: dot entries are skipped, symlinked directories are not
40
+ * followed, and a dangling link is skipped. A missing root yields an empty
41
+ * list; any other read failure is thrown. Results are sorted by path.
42
+ */
43
+ export declare function findUnreadDeltaFiles(specsRoot: string): Promise<UnreadDeltaFile[]>;
24
44
  /**
25
45
  * True when any regular non-dot file exists anywhere under the given
26
46
  * directory. Used by validate/archive to detect content under a change's
@@ -1,6 +1,7 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
3
  import { FileSystemUtils } from './file-system.js';
4
+ import { parseDeltaSpec } from '../core/parsers/requirement-blocks.js';
4
5
  function assertDiscoveredSpecPath(specsRoot, capabilityDir, specFile) {
5
6
  try {
6
7
  FileSystemUtils.assertPathWithin(specsRoot, specFile);
@@ -73,6 +74,64 @@ export async function discoverSpecFiles(specsRoot) {
73
74
  // guarantees the deterministic output the docstring promises.
74
75
  return results.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
75
76
  }
77
+ /**
78
+ * Markdown files under a change's specs/ that carry delta sections but are not
79
+ * a capability's `spec.md`, so discoverSpecFiles, and with it validate and
80
+ * archive, never reads them: `specs/user-auth.md`, or `specs/user-auth/delta.md`
81
+ * beside or instead of the capability's spec.md. The artifact graph's
82
+ * recursive specs/ markdown glob does match them, so status and apply report
83
+ * the specs as written while archive has nothing to merge. A `spec.md` at the
84
+ * specs/ root has its own check (#1385) and is not repeated here. Notes with
85
+ * no delta section are not deltas and are not reported. The walk matches
86
+ * discoverSpecFiles: dot entries are skipped, symlinked directories are not
87
+ * followed, and a dangling link is skipped. A missing root yields an empty
88
+ * list; any other read failure is thrown. Results are sorted by path.
89
+ */
90
+ export async function findUnreadDeltaFiles(specsRoot) {
91
+ const results = [];
92
+ const walk = async (dir, segments) => {
93
+ let entries;
94
+ try {
95
+ entries = await fs.readdir(dir, { withFileTypes: true });
96
+ }
97
+ catch (err) {
98
+ if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR')
99
+ return;
100
+ throw err;
101
+ }
102
+ for (const entry of entries) {
103
+ if (entry.name.startsWith('.'))
104
+ continue;
105
+ if (entry.isDirectory()) {
106
+ await walk(path.join(dir, entry.name), [...segments, entry.name]);
107
+ continue;
108
+ }
109
+ if (!entry.isFile() && !entry.isSymbolicLink())
110
+ continue;
111
+ if (entry.name === 'spec.md' || !entry.name.toLowerCase().endsWith('.md'))
112
+ continue;
113
+ const filePath = path.join(dir, entry.name);
114
+ let content;
115
+ try {
116
+ if (entry.isSymbolicLink() && !(await fs.stat(filePath)).isFile())
117
+ continue;
118
+ content = await fs.readFile(filePath, 'utf-8');
119
+ }
120
+ catch (err) {
121
+ // A dangling link is not content; anything else fails loudly.
122
+ if (err?.code === 'ENOENT')
123
+ continue;
124
+ throw err;
125
+ }
126
+ if (!Object.values(parseDeltaSpec(content).sectionPresence).some(Boolean))
127
+ continue;
128
+ const capability = segments.length > 0 ? segments.join('/') : entry.name.slice(0, -'.md'.length);
129
+ results.push({ path: [...segments, entry.name].join('/'), expected: `${capability}/spec.md` });
130
+ }
131
+ };
132
+ await walk(specsRoot, []);
133
+ return results.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
134
+ }
76
135
  /**
77
136
  * True when any regular non-dot file exists anywhere under the given
78
137
  * directory. Used by validate/archive to detect content under a change's
@@ -1,5 +1,5 @@
1
1
  export interface ParsedTask {
2
- /** Checkbox state: `[x]`/`[X]` is done, anything else is not. */
2
+ /** Checkbox state: `[x]`/`[X]` is done, every other marker (and none) is not. */
3
3
  done: boolean;
4
4
  /** Task text after the checkbox, trimmed (may be empty). */
5
5
  description: string;
@@ -3,22 +3,54 @@ import path from 'path';
3
3
  import { resolveArtifactOutputs, resolveSchema } from '../core/artifact-graph/index.js';
4
4
  import { resolveSchemaForChange } from './change-metadata.js';
5
5
  /**
6
- * A Markdown task line: a `-`/`*` bullet carrying a `[ ]` or `[x]` checkbox.
6
+ * A Markdown task line: a list item carrying a checkbox that holds at most one
7
+ * non-whitespace marker - `[ ]`, `[x]`, `[]`, `[~]`, `[ x ]` all qualify - under
8
+ * any CommonMark list marker - `-`, `*`, `+`, or an ordered `1.` / `1)` of up
9
+ * to nine digits. Reading only `-` and `*` left an unchecked `1. [ ]` or
10
+ * `+ [ ]` task out of every count, so archive reported "✓ Complete" over it.
7
11
  *
8
12
  * Leading whitespace is allowed so nested sub-tasks count like their parents.
9
13
  * Anchoring at column 0 made ` - [ ] 1.1.1 ...` invisible to progress, to the
10
14
  * apply task list, and to archive's incomplete-task check, so a change with
11
15
  * unfinished sub-tasks reported "✓ Complete" and archived without a warning.
12
16
  *
17
+ * The marker is no longer restricted to ` `/`x`/`X`, because a checkbox this
18
+ * pattern rejects is a line that counts toward neither the numerator nor the
19
+ * denominator: a tasks.md whose remaining work was written `- [~] ...`
20
+ * reported "✓ Complete" and archived with no incomplete-task warning, and
21
+ * marking items `[~]` *shrank* the denominator instead of leaving them counted
22
+ * as not-done (#1761). An empty `[]` and a padded `[ x]` were lost the same
23
+ * silent way. Only `x`/`X` means done, so every unrecognised marker reads as
24
+ * not-done - the conservative default, and no new concept: OpenSpec does not
25
+ * adopt `[~]` or any other marker's meaning, it just stops dropping the line.
26
+ *
13
27
  * 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.
28
+ * tightened here drops lines that used to count, and a task this parser drops
29
+ * is a task `openspec archive` stops warning about. The cost of the wide class
30
+ * is over-counting - `- [1] ...` in a tasks file now reads as one unfinished
31
+ * task - which is a loud, correctable false positive, unlike the silent loss.
32
+ *
33
+ * Where the width stops, and why: the marker is one token, so a *multi*
34
+ * character bracket stays unmatched. Widening to `[^\]]*` would swallow the
35
+ * commonest bullet in Markdown - `- [Some doc](./doc.md)`, whose `]` is
36
+ * followed by `(`, not by a space - and turn every link list into phantom
37
+ * unfinished work. A dropped `- [WIP] ...` is the accepted residue of keeping
38
+ * link bullets out; report it as a bug in this trade, not in the marker set.
39
+ *
40
+ * One-character labels need the same guard, which the width alone does not
41
+ * give: `- [A](https://example.com)` and `- [1](./one)` are a link bullet and
42
+ * a reference-link bullet, not tasks, yet their label is a single token and
43
+ * would match. So the closing bracket may not be followed by `(` or `[`, the
44
+ * only two characters that continue Markdown link syntax. A checkbox is
45
+ * followed by its description or by end of line, and `- [x]done` still parses.
46
+ * The one exception is a whitespace-only box: `- [ ](...)` and `- [ ][...]`
47
+ * matched the strict pattern as unfinished tasks, so they still count. The guard
48
+ * may drop only lines that could never hide open work.
17
49
  *
18
50
  * Deliberately unanchored at the end: `.` does not match `\r`, so writing the
19
51
  * description group as `(.*)$` would reject every line of a CRLF tasks.md.
20
52
  */
21
- const TASK_LINE_PATTERN = /^\s*[-*]\s*\[([\sxX])\]\s*(.*)/;
53
+ const TASK_LINE_PATTERN = /^\s*(?:[-*+]|\d{1,9}[.)])\s*\[(?:\s*([^\]\s]?)\s*\](?![([])|\s+\])\s*(.*)/;
22
54
  /**
23
55
  * Parses every task line in a tasks file, in document order.
24
56
  *
@@ -34,7 +66,7 @@ export function parseTaskLines(content) {
34
66
  for (const line of content.split('\n')) {
35
67
  const match = line.match(TASK_LINE_PATTERN);
36
68
  if (match) {
37
- tasks.push({ done: match[1].toLowerCase() === 'x', description: match[2].trim() });
69
+ tasks.push({ done: (match[1] ?? '').toLowerCase() === 'x', description: match[2].trim() });
38
70
  }
39
71
  }
40
72
  return tasks;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fission-ai/openspec",
3
- "version": "1.13.0",
3
+ "version": "1.13.2",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "openspec",
@@ -44,20 +44,20 @@
44
44
  "@changesets/changelog-github": "^1.0.0",
45
45
  "@changesets/cli": "^3.0.1",
46
46
  "@types/node": "^20.19.43",
47
- "@vitest/ui": "^3.2.6",
47
+ "@vitest/ui": "^4.1.11",
48
48
  "eslint": "^10.5.0",
49
49
  "smol-toml": "^1.7.1",
50
50
  "typescript": "^6.0.3",
51
51
  "typescript-eslint": "^8.65.0",
52
- "vitest": "^3.2.6"
52
+ "vitest": "^4.1.11"
53
53
  },
54
54
  "dependencies": {
55
- "@inquirer/core": "^11.2.1",
55
+ "@inquirer/core": "^12.0.0",
56
56
  "@inquirer/prompts": "^8.5.2",
57
57
  "chalk": "^5.6.2",
58
58
  "commander": "^14.0.0",
59
- "diff": "^9.0.0",
60
59
  "cross-spawn": "7.0.6",
60
+ "diff": "^9.0.0",
61
61
  "fast-glob": "^3.3.3",
62
62
  "ora": "^9.4.1",
63
63
  "yaml": "^2.8.3",
@@ -95,7 +95,7 @@ artifacts:
95
95
  - **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently.
96
96
  - Every requirement MUST have at least one scenario.
97
97
 
98
- New capabilities only: start the delta spec with a `## Purpose` section -
98
+ New capabilities only: the delta spec's first section is `## Purpose` -
99
99
  one or two sentences (50+ characters, or `openspec validate --strict`
100
100
  reports it as too brief) describing what the capability is for. Archive
101
101
  copies it into the main spec it creates; without it the new main spec is
@@ -120,8 +120,10 @@ artifacts:
120
120
  Common pitfall: Using MODIFIED with partial content loses detail at archive time.
121
121
  If adding new concerns without changing existing behavior, use ADDED instead.
122
122
 
123
- Example (a new capability, so it opens with `## Purpose`):
123
+ Example (a new capability, so its first section is `## Purpose`):
124
124
  ```
125
+ # Spec Delta
126
+
125
127
  ## Purpose
126
128
 
127
129
  Lets users take their data out of the product in a portable format.
@@ -193,7 +195,10 @@ artifacts:
193
195
  bake an unstated assumption into the task list.
194
196
 
195
197
  **IMPORTANT: Follow the template below exactly.** The apply phase parses
196
- checkbox format to track progress. Tasks not using `- [ ]` won't be tracked.
198
+ checkbox format to track progress. A box holding only `x` counts as done,
199
+ upper or lower case and with any spacing, so `- [ x]` is done too. Every
200
+ other marker, including `- [~]`, `- [-]` and an empty `- []`, reads as
201
+ unfinished. A line with no checkbox is not tracked at all.
197
202
 
198
203
  Guidelines:
199
204
  - Group related tasks under ## numbered headings
@@ -205,9 +210,18 @@ artifacts:
205
210
  that task's checkbox description. Use a separate verification task only
206
211
  when it checks broader integration or system behavior that spans
207
212
  multiple implementation tasks.
213
+ - Each task group MUST land the tests and documentation its own work
214
+ calls for. Do NOT collect testing or documentation into a final group -
215
+ when a late group first exercises work from an early one, the failures
216
+ cascade back through every group in between and force rework. A group
217
+ whose work calls for neither, such as scaffolding or dependency setup,
218
+ carries neither. A final group is for integration checks only, not for
219
+ the tests and docs an earlier group owed.
208
220
 
209
221
  Example:
210
222
  ```
223
+ # Tasks
224
+
211
225
  ## 1. Setup
212
226
 
213
227
  - [ ] 1.1 Create new module structure and verify expected files are present
@@ -217,6 +231,7 @@ artifacts:
217
231
 
218
232
  - [ ] 2.1 Implement data export function and verify the export test passes
219
233
  - [ ] 2.2 Add CSV formatting utilities and verify unit tests cover quoting and delimiters
234
+ - [ ] 2.3 Document the export API in docs/export.md and verify the documented command runs as written
220
235
  ```
221
236
 
222
237
  Reference specs for what needs to be built, design for how to build it.
@@ -1,3 +1,5 @@
1
+ # Design
2
+
1
3
  ## Context
2
4
 
3
5
  <!-- Current state and constraints that shape the approach. See proposal.md for motivation - don't restate it -->
@@ -1,3 +1,5 @@
1
+ # Proposal
2
+
1
3
  ## Why
2
4
 
3
5
  <!-- Explain the motivation for this change. What problem does this solve? Why now? -->
@@ -1,3 +1,5 @@
1
+ # Spec Delta
2
+
1
3
  ## Purpose
2
4
  <!-- New capabilities only: one or two sentences (50+ characters) on what this capability is for. Delete this section for an existing capability. -->
3
5
 
@@ -1,3 +1,5 @@
1
+ # Tasks
2
+
1
3
  ## 1. <!-- Task Group Name -->
2
4
 
3
5
  - [ ] 1.1 <!-- Task description -->