@fission-ai/openspec 1.12.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.
- package/README.md +3 -15
- package/dist/commands/change.js +12 -1
- package/dist/commands/config.d.ts +9 -0
- package/dist/commands/config.js +161 -22
- package/dist/commands/feedback.js +6 -4
- package/dist/commands/schema.js +30 -9
- package/dist/commands/store.js +5 -2
- package/dist/commands/validate.d.ts +7 -0
- package/dist/commands/validate.js +63 -1
- package/dist/commands/workflow/instructions.js +170 -16
- package/dist/commands/workflow/new-change.js +20 -0
- package/dist/commands/workflow/shared.d.ts +8 -0
- package/dist/commands/workflow/shared.js +8 -0
- package/dist/commands/workflow/status.d.ts +5 -1
- package/dist/commands/workflow/status.js +42 -5
- package/dist/core/archive.js +17 -1
- package/dist/core/artifact-graph/schema.d.ts +14 -0
- package/dist/core/artifact-graph/schema.js +52 -0
- package/dist/core/artifact-graph/types.js +12 -1
- package/dist/core/change-status-policy.d.ts +14 -0
- package/dist/core/change-status-policy.js +14 -6
- package/dist/core/command-generation/generator.js +2 -0
- package/dist/core/completion-tip.js +7 -7
- package/dist/core/completions/installers/bash-installer.js +15 -7
- package/dist/core/completions/installers/shell-quote.d.ts +11 -0
- package/dist/core/completions/installers/shell-quote.js +13 -0
- package/dist/core/completions/installers/zsh-installer.js +3 -2
- package/dist/core/config-schema.d.ts +1 -1
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +27 -1
- package/dist/core/global-config.d.ts +30 -2
- package/dist/core/global-config.js +57 -3
- package/dist/core/init.js +33 -3
- package/dist/core/legacy-cleanup.d.ts +4 -0
- package/dist/core/legacy-cleanup.js +180 -12
- package/dist/core/list.js +55 -11
- package/dist/core/migration.js +6 -1
- package/dist/core/onboarding-commands.d.ts +11 -1
- package/dist/core/onboarding-commands.js +26 -0
- package/dist/core/parsers/change-parser.d.ts +12 -1
- package/dist/core/parsers/change-parser.js +85 -87
- package/dist/core/parsers/markdown-parser.js +4 -3
- package/dist/core/parsers/requirement-blocks.d.ts +44 -3
- package/dist/core/parsers/requirement-blocks.js +164 -43
- package/dist/core/parsers/requirement-text.d.ts +13 -2
- package/dist/core/parsers/requirement-text.js +34 -7
- package/dist/core/parsers/spec-structure.js +4 -1
- package/dist/core/project-config.d.ts +9 -1
- package/dist/core/project-config.js +11 -2
- package/dist/core/references.d.ts +20 -0
- package/dist/core/references.js +56 -0
- package/dist/core/shared/skill-content-equivalence.js +5 -1
- package/dist/core/shared/skill-generation.js +36 -8
- package/dist/core/shared/tool-detection.js +37 -4
- package/dist/core/specs-apply.js +260 -18
- package/dist/core/store/git.d.ts +33 -0
- package/dist/core/store/git.js +60 -6
- package/dist/core/store/operations.d.ts +1 -1
- package/dist/core/store/operations.js +32 -10
- package/dist/core/store/registry.d.ts +2 -1
- package/dist/core/store/registry.js +5 -1
- package/dist/core/templates/optional-workflow.d.ts +69 -0
- package/dist/core/templates/optional-workflow.js +140 -0
- package/dist/core/templates/workflows/apply-change.js +18 -3
- package/dist/core/templates/workflows/archive-change.js +50 -15
- package/dist/core/templates/workflows/bulk-archive-change.js +63 -13
- package/dist/core/templates/workflows/continue-change.js +14 -2
- package/dist/core/templates/workflows/explore.js +48 -14
- package/dist/core/templates/workflows/ff-change.js +19 -3
- package/dist/core/templates/workflows/new-change.js +15 -3
- package/dist/core/templates/workflows/onboard.js +67 -37
- package/dist/core/templates/workflows/project-root.d.ts +29 -0
- package/dist/core/templates/workflows/project-root.js +38 -0
- package/dist/core/templates/workflows/propose.js +52 -15
- package/dist/core/templates/workflows/sync-specs.js +40 -1
- package/dist/core/templates/workflows/update-change.js +52 -25
- package/dist/core/templates/workflows/verify-change.js +12 -3
- package/dist/core/update.d.ts +34 -0
- package/dist/core/update.js +109 -11
- package/dist/core/validation/task-checkboxes.d.ts +34 -0
- package/dist/core/validation/task-checkboxes.js +181 -0
- package/dist/core/validation/validator.d.ts +53 -1
- package/dist/core/validation/validator.js +154 -40
- package/dist/core/version-check.d.ts +14 -7
- package/dist/core/version-check.js +63 -17
- package/dist/core/worksets.js +6 -4
- package/dist/prompts/searchable-multi-select.d.ts +7 -0
- package/dist/prompts/searchable-multi-select.js +35 -6
- package/dist/telemetry/config.js +14 -2
- package/dist/telemetry/index.d.ts +4 -3
- package/dist/telemetry/index.js +22 -10
- package/dist/telemetry/opt-out.d.ts +17 -0
- package/dist/telemetry/opt-out.js +27 -0
- package/dist/utils/nested-change.d.ts +35 -0
- package/dist/utils/nested-change.js +186 -0
- package/dist/utils/spec-discovery.d.ts +20 -0
- package/dist/utils/spec-discovery.js +59 -0
- package/dist/utils/task-progress.d.ts +1 -1
- package/dist/utils/task-progress.js +38 -6
- package/package.json +4 -4
- package/schemas/spec-driven/schema.yaml +24 -5
- package/schemas/spec-driven/templates/design.md +2 -0
- package/schemas/spec-driven/templates/proposal.md +2 -0
- package/schemas/spec-driven/templates/spec.md +2 -0
- package/schemas/spec-driven/templates/tasks.md +2 -0
package/dist/telemetry/index.js
CHANGED
|
@@ -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
|
|
63
|
-
* 2. DO_NOT_TRACK
|
|
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.
|
|
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
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
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,
|
|
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
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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*[
|
|
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.
|
|
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": "^
|
|
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": "^
|
|
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",
|
|
@@ -18,7 +18,19 @@ artifacts:
|
|
|
18
18
|
- **Impact**: Affected code, APIs, dependencies, or systems.
|
|
19
19
|
|
|
20
20
|
IMPORTANT: The Capabilities section is critical. It creates the contract between
|
|
21
|
-
proposal and specs phases. Research existing specs before filling this in
|
|
21
|
+
proposal and specs phases. Research existing specs before filling this in:
|
|
22
|
+
run `openspec list --specs` for the project's capability inventory, then
|
|
23
|
+
`openspec show "<spec-id>" --type spec --json --no-scenarios` for any that
|
|
24
|
+
look related - that returns a capability's purpose and requirement texts
|
|
25
|
+
without pulling whole spec files into context. Append `--store "<id>"` to
|
|
26
|
+
both commands only for a registered standalone store, and keep `--type
|
|
27
|
+
spec`: a change and a spec sharing a name is otherwise an ambiguous-item
|
|
28
|
+
error. `openspec list` without `--specs` lists in-flight changes, not
|
|
29
|
+
specs - it never shows what the project already covers. Reuse an existing
|
|
30
|
+
capability's exact path instead of introducing a near-duplicate name.
|
|
31
|
+
The filtered read is only an overview. Before deciding what is already
|
|
32
|
+
covered or what should change, read each relevant spec in full, including
|
|
33
|
+
scenarios, with `openspec show "<spec-id>" --type spec` (same `--store` rule).
|
|
22
34
|
Each capability listed here will need a corresponding spec file.
|
|
23
35
|
|
|
24
36
|
Every change must either declare at least one capability (new or
|
|
@@ -63,7 +75,7 @@ artifacts:
|
|
|
63
75
|
`<capability-path>` is the spec directory relative to `specs/` (for example,
|
|
64
76
|
`user-auth` or `identity/user-auth`). Preserve the full path:
|
|
65
77
|
- 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.
|
|
78
|
+
- Modified capabilities: use the exact existing path from `openspec/specs/<capability-path>/` when creating the delta at `specs/<capability-path>/spec.md`. Run `openspec list --specs` to confirm that path before writing the delta, appending `--store "<id>"` only for a registered standalone store - a mistyped or invented path targets a capability that does not exist rather than the one you meant. Do not move or rename the capability.
|
|
67
79
|
|
|
68
80
|
There must be at least one spec file unless the change's `.openspec.yaml`
|
|
69
81
|
sets `skip_specs: true` (no spec-level behavior change) - `openspec validate`
|
|
@@ -83,7 +95,7 @@ artifacts:
|
|
|
83
95
|
- **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently.
|
|
84
96
|
- Every requirement MUST have at least one scenario.
|
|
85
97
|
|
|
86
|
-
New capabilities only:
|
|
98
|
+
New capabilities only: the delta spec's first section is `## Purpose` -
|
|
87
99
|
one or two sentences (50+ characters, or `openspec validate --strict`
|
|
88
100
|
reports it as too brief) describing what the capability is for. Archive
|
|
89
101
|
copies it into the main spec it creates; without it the new main spec is
|
|
@@ -108,8 +120,10 @@ artifacts:
|
|
|
108
120
|
Common pitfall: Using MODIFIED with partial content loses detail at archive time.
|
|
109
121
|
If adding new concerns without changing existing behavior, use ADDED instead.
|
|
110
122
|
|
|
111
|
-
Example (a new capability, so
|
|
123
|
+
Example (a new capability, so its first section is `## Purpose`):
|
|
112
124
|
```
|
|
125
|
+
# Spec Delta
|
|
126
|
+
|
|
113
127
|
## Purpose
|
|
114
128
|
|
|
115
129
|
Lets users take their data out of the product in a portable format.
|
|
@@ -181,7 +195,10 @@ artifacts:
|
|
|
181
195
|
bake an unstated assumption into the task list.
|
|
182
196
|
|
|
183
197
|
**IMPORTANT: Follow the template below exactly.** The apply phase parses
|
|
184
|
-
checkbox format to track progress.
|
|
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.
|
|
185
202
|
|
|
186
203
|
Guidelines:
|
|
187
204
|
- Group related tasks under ## numbered headings
|
|
@@ -196,6 +213,8 @@ artifacts:
|
|
|
196
213
|
|
|
197
214
|
Example:
|
|
198
215
|
```
|
|
216
|
+
# Tasks
|
|
217
|
+
|
|
199
218
|
## 1. Setup
|
|
200
219
|
|
|
201
220
|
- [ ] 1.1 Create new module structure and verify expected files are present
|