@fission-ai/openspec 1.13.0 → 1.13.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.
Files changed (102) hide show
  1. package/README.md +1 -1
  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 +33 -14
  11. package/dist/commands/workflow/new-change.js +20 -0
  12. package/dist/commands/workflow/shared.js +8 -0
  13. package/dist/commands/workflow/status.d.ts +5 -1
  14. package/dist/commands/workflow/status.js +42 -5
  15. package/dist/core/archive.js +17 -1
  16. package/dist/core/artifact-graph/schema.d.ts +14 -0
  17. package/dist/core/artifact-graph/schema.js +52 -0
  18. package/dist/core/artifact-graph/types.js +12 -1
  19. package/dist/core/change-status-policy.d.ts +14 -0
  20. package/dist/core/change-status-policy.js +14 -6
  21. package/dist/core/command-generation/generator.js +2 -0
  22. package/dist/core/completion-tip.js +7 -7
  23. package/dist/core/completions/installers/bash-installer.js +15 -7
  24. package/dist/core/completions/installers/shell-quote.d.ts +11 -0
  25. package/dist/core/completions/installers/shell-quote.js +13 -0
  26. package/dist/core/completions/installers/zsh-installer.js +3 -2
  27. package/dist/core/config-schema.d.ts +1 -1
  28. package/dist/core/config.d.ts +16 -0
  29. package/dist/core/config.js +27 -1
  30. package/dist/core/global-config.d.ts +30 -2
  31. package/dist/core/global-config.js +57 -3
  32. package/dist/core/init.js +13 -3
  33. package/dist/core/legacy-cleanup.d.ts +4 -0
  34. package/dist/core/legacy-cleanup.js +180 -12
  35. package/dist/core/list.js +55 -11
  36. package/dist/core/migration.js +6 -1
  37. package/dist/core/parsers/change-parser.d.ts +12 -1
  38. package/dist/core/parsers/change-parser.js +85 -87
  39. package/dist/core/parsers/markdown-parser.js +4 -3
  40. package/dist/core/parsers/requirement-blocks.d.ts +44 -3
  41. package/dist/core/parsers/requirement-blocks.js +90 -15
  42. package/dist/core/parsers/requirement-text.d.ts +13 -2
  43. package/dist/core/parsers/requirement-text.js +34 -7
  44. package/dist/core/parsers/spec-structure.js +4 -1
  45. package/dist/core/project-config.d.ts +9 -1
  46. package/dist/core/project-config.js +11 -2
  47. package/dist/core/references.d.ts +20 -0
  48. package/dist/core/references.js +56 -0
  49. package/dist/core/shared/skill-content-equivalence.js +5 -1
  50. package/dist/core/shared/skill-generation.js +36 -8
  51. package/dist/core/shared/tool-detection.js +9 -3
  52. package/dist/core/specs-apply.js +82 -11
  53. package/dist/core/store/git.d.ts +33 -0
  54. package/dist/core/store/git.js +60 -6
  55. package/dist/core/store/operations.d.ts +1 -1
  56. package/dist/core/store/operations.js +32 -10
  57. package/dist/core/store/registry.d.ts +2 -1
  58. package/dist/core/store/registry.js +5 -1
  59. package/dist/core/templates/optional-workflow.d.ts +69 -0
  60. package/dist/core/templates/optional-workflow.js +140 -0
  61. package/dist/core/templates/workflows/apply-change.js +18 -3
  62. package/dist/core/templates/workflows/archive-change.js +50 -15
  63. package/dist/core/templates/workflows/bulk-archive-change.js +63 -13
  64. package/dist/core/templates/workflows/continue-change.js +14 -2
  65. package/dist/core/templates/workflows/explore.js +32 -14
  66. package/dist/core/templates/workflows/ff-change.js +19 -3
  67. package/dist/core/templates/workflows/new-change.js +15 -3
  68. package/dist/core/templates/workflows/onboard.js +67 -37
  69. package/dist/core/templates/workflows/project-root.d.ts +29 -0
  70. package/dist/core/templates/workflows/project-root.js +38 -0
  71. package/dist/core/templates/workflows/propose.js +22 -5
  72. package/dist/core/templates/workflows/sync-specs.js +40 -1
  73. package/dist/core/templates/workflows/update-change.js +52 -25
  74. package/dist/core/templates/workflows/verify-change.js +12 -3
  75. package/dist/core/update.d.ts +12 -0
  76. package/dist/core/update.js +58 -3
  77. package/dist/core/validation/task-checkboxes.d.ts +34 -0
  78. package/dist/core/validation/task-checkboxes.js +181 -0
  79. package/dist/core/validation/validator.d.ts +53 -1
  80. package/dist/core/validation/validator.js +154 -40
  81. package/dist/core/version-check.d.ts +14 -7
  82. package/dist/core/version-check.js +63 -17
  83. package/dist/core/worksets.js +6 -4
  84. package/dist/prompts/searchable-multi-select.d.ts +7 -0
  85. package/dist/prompts/searchable-multi-select.js +35 -6
  86. package/dist/telemetry/config.js +14 -2
  87. package/dist/telemetry/index.d.ts +4 -3
  88. package/dist/telemetry/index.js +22 -10
  89. package/dist/telemetry/opt-out.d.ts +17 -0
  90. package/dist/telemetry/opt-out.js +27 -0
  91. package/dist/utils/nested-change.d.ts +35 -0
  92. package/dist/utils/nested-change.js +186 -0
  93. package/dist/utils/spec-discovery.d.ts +20 -0
  94. package/dist/utils/spec-discovery.js +59 -0
  95. package/dist/utils/task-progress.d.ts +1 -1
  96. package/dist/utils/task-progress.js +38 -6
  97. package/package.json +4 -4
  98. package/schemas/spec-driven/schema.yaml +10 -3
  99. package/schemas/spec-driven/templates/design.md +2 -0
  100. package/schemas/spec-driven/templates/proposal.md +2 -0
  101. package/schemas/spec-driven/templates/spec.md +2 -0
  102. package/schemas/spec-driven/templates/tasks.md +2 -0
@@ -20,9 +20,10 @@
20
20
  * versions and broke installs (#1390).
21
21
  */
22
22
  import { randomUUID } from 'crypto';
23
- import { getGlobalConfig } from '../core/global-config.js';
23
+ import { getGlobalConfig, isGlobalConfigUnreadable } from '../core/global-config.js';
24
24
  import { isCiEnvironment } from '../utils/ci.js';
25
25
  import { getTelemetryConfig, updateTelemetryConfig } from './config.js';
26
+ import { isTelemetryOptedOutByEnv } from './opt-out.js';
26
27
  // PostHog API key - public key for client-side analytics
27
28
  // This is safe to embed as it only allows sending events, not reading data
28
29
  const POSTHOG_API_KEY = 'phc_Hthu8YvaIJ9QaFKyTG4TbVwkbd5ktcAFzVTKeMmoW2g';
@@ -59,22 +60,21 @@ async function safeTelemetryFetch(url, options) {
59
60
  * Check if telemetry is enabled.
60
61
  *
61
62
  * Precedence (first match wins):
62
- * 1. OPENSPEC_TELEMETRY=0 → disabled
63
- * 2. DO_NOT_TRACK=1 → disabled
63
+ * 1. OPENSPEC_TELEMETRY set to anything but an on-value (1/true/yes/on) → disabled
64
+ * 2. DO_NOT_TRACK set to anything but an off-value (0/false/no/off) → disabled
64
65
  * 3. CI set to a truthy/on value → disabled (same rule as version-check)
65
66
  * 4. global config telemetry.enabled === false → disabled
66
- * 5. otherwise enabled (unset config means on; opt-out model)
67
+ * 5. global config file exists but cannot be parsed → disabled
68
+ * 6. otherwise enabled (unset config means on; opt-out model)
67
69
  *
68
70
  * Kept synchronous so call sites need not become async. Reads config via
69
71
  * sync getGlobalConfig() rather than async getTelemetryConfig().
70
72
  */
71
73
  export function isTelemetryEnabled() {
72
- // Check explicit opt-out
73
- if (process.env.OPENSPEC_TELEMETRY === '0') {
74
- return false;
75
- }
76
- // Respect DO_NOT_TRACK standard
77
- if (process.env.DO_NOT_TRACK === '1') {
74
+ // Explicit opt-out, and the DO_NOT_TRACK standard. Both are read
75
+ // tolerantly (see opt-out.ts): an opt-out that only worked for one exact
76
+ // spelling would leave users tracked who believe they are not.
77
+ if (isTelemetryOptedOutByEnv()) {
78
78
  return false;
79
79
  }
80
80
  // Auto-disable in CI environments (providers use true/1/yes/…)
@@ -85,6 +85,11 @@ export function isTelemetryEnabled() {
85
85
  if (getGlobalConfig().telemetry?.enabled === false) {
86
86
  return false;
87
87
  }
88
+ // A config file that cannot be parsed reads as defaults, which carry no
89
+ // opt-out, but the file itself may hold one. Unknown is not consent.
90
+ if (isGlobalConfigUnreadable()) {
91
+ return false;
92
+ }
88
93
  return true;
89
94
  }
90
95
  /**
@@ -144,6 +149,13 @@ export async function trackCommand(commandName, version) {
144
149
  return;
145
150
  }
146
151
  try {
152
+ // Disclosure before collection. A --json run defers the notice (printing
153
+ // it would corrupt machine-readable output), and for a user whose runs
154
+ // are all --json it may never have appeared — so nothing is sent, and no
155
+ // anonymous id is created, until the notice has actually been shown.
156
+ if (!(await getTelemetryConfig()).noticeSeen) {
157
+ return;
158
+ }
147
159
  const userId = await getOrCreateAnonymousId();
148
160
  sendEvent(userId, 'command_executed', {
149
161
  command: commandName,
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Telemetry opt-out signals, shared by telemetry and the version check so both
3
+ * outbound surfaces honor exactly the same values.
4
+ *
5
+ * Parsing is tolerant in the style of isCiEnvironment(): a user who wrote
6
+ * DO_NOT_TRACK=true meant it, and an exact-match test that silently kept
7
+ * sending would be a privacy control that does not work. Ambiguity fails
8
+ * safe — a value we cannot read as "on" suppresses the request rather than
9
+ * enabling it.
10
+ */
11
+ /** True when `DO_NOT_TRACK` is set to anything but an explicit off-value. */
12
+ export declare function isDoNotTrackSet(env?: NodeJS.ProcessEnv): boolean;
13
+ /** True when `OPENSPEC_TELEMETRY` is set to anything but an explicit on-value. */
14
+ export declare function isTelemetryDisabledByEnv(env?: NodeJS.ProcessEnv): boolean;
15
+ /** True when either opt-out signal asks us to stay off the network. */
16
+ export declare function isTelemetryOptedOutByEnv(env?: NodeJS.ProcessEnv): boolean;
17
+ //# sourceMappingURL=opt-out.d.ts.map
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Telemetry opt-out signals, shared by telemetry and the version check so both
3
+ * outbound surfaces honor exactly the same values.
4
+ *
5
+ * Parsing is tolerant in the style of isCiEnvironment(): a user who wrote
6
+ * DO_NOT_TRACK=true meant it, and an exact-match test that silently kept
7
+ * sending would be a privacy control that does not work. Ambiguity fails
8
+ * safe — a value we cannot read as "on" suppresses the request rather than
9
+ * enabling it.
10
+ */
11
+ const ON_VALUES = new Set(['1', 'true', 'yes', 'on']);
12
+ const OFF_VALUES = new Set(['', '0', 'false', 'no', 'off']);
13
+ /** True when `DO_NOT_TRACK` is set to anything but an explicit off-value. */
14
+ export function isDoNotTrackSet(env = process.env) {
15
+ const value = env.DO_NOT_TRACK;
16
+ return value !== undefined && !OFF_VALUES.has(value.trim().toLowerCase());
17
+ }
18
+ /** True when `OPENSPEC_TELEMETRY` is set to anything but an explicit on-value. */
19
+ export function isTelemetryDisabledByEnv(env = process.env) {
20
+ const value = env.OPENSPEC_TELEMETRY;
21
+ return value !== undefined && !ON_VALUES.has(value.trim().toLowerCase());
22
+ }
23
+ /** True when either opt-out signal asks us to stay off the network. */
24
+ export function isTelemetryOptedOutByEnv(env = process.env) {
25
+ return isTelemetryDisabledByEnv(env) || isDoNotTrackSet(env);
26
+ }
27
+ //# sourceMappingURL=opt-out.js.map
@@ -0,0 +1,35 @@
1
+ /** A namespace folder under `changes/` and the change directories buried in it. */
2
+ export interface NestedChangeFinding {
3
+ /** The directory name directly under `changes/` that is not a change. */
4
+ name: string;
5
+ /**
6
+ * Slash-separated paths of the nested change directories, relative to
7
+ * `changes/`, sorted. Always at least one entry.
8
+ */
9
+ nested: string[];
10
+ }
11
+ /**
12
+ * Reports whether the directory `changes/<name>/` is a namespace folder holding
13
+ * one or more nested change directories rather than a change of its own.
14
+ *
15
+ * Returns undefined for every ordinary change, including a scaffolded one with
16
+ * no artifacts yet: only a *nested* change directory distinguishes the two.
17
+ */
18
+ export declare function findNestedChangesIn(changesDir: string, name: string): Promise<NestedChangeFinding | undefined>;
19
+ /**
20
+ * The same check across every candidate directory under `changes/`, for the
21
+ * commands that enumerate rather than resolve a single name.
22
+ */
23
+ export declare function findNestedChanges(changesDir: string, names: string[]): Promise<NestedChangeFinding[]>;
24
+ /**
25
+ * The stable phrase every nested-change diagnostic contains. Callers that have
26
+ * only the rendered message - `validate`'s next-steps, which branches on the
27
+ * exact issue it is advising about - recognise the case by this.
28
+ */
29
+ export declare const NESTED_CHANGE_ISSUE_MARKER = "is not a change: it is a folder wrapping";
30
+ /**
31
+ * The one explanation every surface prints, so the constraint and the way out
32
+ * read identically wherever a user meets them.
33
+ */
34
+ export declare function describeNestedChange(finding: NestedChangeFinding): string;
35
+ //# sourceMappingURL=nested-change.d.ts.map
@@ -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.1",
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
55
  "@inquirer/core": "^11.2.1",
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
@@ -208,6 +213,8 @@ artifacts:
208
213
 
209
214
  Example:
210
215
  ```
216
+ # Tasks
217
+
211
218
  ## 1. Setup
212
219
 
213
220
  - [ ] 1.1 Create new module structure and verify expected files are present
@@ -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 -->