@fission-ai/openspec 1.13.2 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +39 -1
- package/dist/commands/spec.js +1 -0
- package/dist/commands/workflow/instructions.js +31 -11
- package/dist/commands/workflow/shared.d.ts +2 -0
- package/dist/commands/workflow/status.js +15 -2
- package/dist/core/archive.js +15 -2
- package/dist/core/artifact-graph/instruction-loader.d.ts +7 -0
- package/dist/core/artifact-graph/instruction-loader.js +18 -1
- package/dist/core/change-metadata/schema.d.ts +3 -0
- package/dist/core/change-metadata/schema.js +10 -0
- package/dist/core/change-status-policy.d.ts +8 -0
- package/dist/core/change-status-policy.js +33 -2
- package/dist/core/command-generation/adapters/atomcode.d.ts +24 -0
- package/dist/core/command-generation/adapters/atomcode.js +46 -0
- package/dist/core/command-generation/adapters/codestudio.d.ts +13 -0
- package/dist/core/command-generation/adapters/codestudio.js +27 -0
- package/dist/core/command-generation/adapters/easycode.d.ts +17 -0
- package/dist/core/command-generation/adapters/easycode.js +28 -0
- package/dist/core/command-generation/adapters/gemini.js +1 -35
- package/dist/core/command-generation/adapters/gigacode.d.ts +17 -0
- package/dist/core/command-generation/adapters/gigacode.js +31 -0
- package/dist/core/command-generation/adapters/index.d.ts +4 -0
- package/dist/core/command-generation/adapters/index.js +4 -0
- package/dist/core/command-generation/registry.js +8 -0
- package/dist/core/command-generation/toml.d.ts +19 -0
- package/dist/core/command-generation/toml.js +39 -0
- package/dist/core/command-surface.js +1 -1
- package/dist/core/completions/command-registry.js +19 -0
- package/dist/core/completions/installers/zsh-installer.js +4 -2
- package/dist/core/config-prompts.js +6 -6
- package/dist/core/config.js +11 -1
- package/dist/core/init.js +35 -11
- package/dist/core/legacy-cleanup.js +11 -7
- package/dist/core/list.d.ts +2 -0
- package/dist/core/list.js +49 -23
- package/dist/core/parsers/markdown-parser.js +5 -0
- package/dist/core/parsers/requirement-blocks.d.ts +6 -0
- package/dist/core/parsers/requirement-blocks.js +9 -2
- package/dist/core/project-config.js +45 -1
- package/dist/core/references.js +3 -1
- package/dist/core/root-selection.d.ts +6 -0
- package/dist/core/root-selection.js +16 -0
- package/dist/core/schemas/base.schema.d.ts +3 -0
- package/dist/core/schemas/base.schema.js +6 -0
- package/dist/core/schemas/change.schema.d.ts +8 -0
- package/dist/core/schemas/spec.schema.d.ts +2 -0
- package/dist/core/templates/workflows/apply-change.js +5 -2
- package/dist/core/templates/workflows/archive-change.js +26 -4
- package/dist/core/templates/workflows/bulk-archive-change.js +6 -2
- package/dist/core/validation/constants.d.ts +1 -1
- package/dist/core/validation/constants.js +1 -1
- package/dist/core/validation/validator.js +9 -1
- package/dist/core/version-check.d.ts +23 -1
- package/dist/core/version-check.js +112 -26
- package/dist/core/view.d.ts +2 -0
- package/dist/core/view.js +62 -4
- package/dist/utils/change-metadata.d.ts +20 -1
- package/dist/utils/change-metadata.js +57 -1
- package/dist/utils/command-references.d.ts +1 -1
- package/dist/utils/command-references.js +2 -2
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/package.json +1 -1
- package/schemas/spec-driven/schema.yaml +2 -1
- package/schemas/spec-driven/templates/proposal.md +6 -3
|
@@ -136,4 +136,10 @@ export declare function findMissingCurrentScenarios(current: RequirementBlock, i
|
|
|
136
136
|
* the counts a reader sees cannot differ between the two commands.
|
|
137
137
|
*/
|
|
138
138
|
export declare function describeScenarioBalance(diff: ScenarioNameDiff): string;
|
|
139
|
+
/**
|
|
140
|
+
* scenarioNameAt for header text whose leading `####` is already gone, as the
|
|
141
|
+
* section parser (MarkdownParser) holds it, so `show --json` names a scenario
|
|
142
|
+
* exactly as the MODIFIED loss check does.
|
|
143
|
+
*/
|
|
144
|
+
export declare function scenarioNameFromHeaderText(headerText: string): string;
|
|
139
145
|
//# sourceMappingURL=requirement-blocks.d.ts.map
|
|
@@ -471,8 +471,15 @@ function scenarioHeaderAt(lines, mask, index) {
|
|
|
471
471
|
* ATX-closed, one not) are not mistaken for a dropped scenario.
|
|
472
472
|
*/
|
|
473
473
|
function scenarioNameAt(line) {
|
|
474
|
-
return line
|
|
475
|
-
|
|
474
|
+
return scenarioNameFromHeaderText(line.replace(SCENARIO_HEADER, ''));
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* scenarioNameAt for header text whose leading `####` is already gone, as the
|
|
478
|
+
* section parser (MarkdownParser) holds it, so `show --json` names a scenario
|
|
479
|
+
* exactly as the MODIFIED loss check does.
|
|
480
|
+
*/
|
|
481
|
+
export function scenarioNameFromHeaderText(headerText) {
|
|
482
|
+
return headerText
|
|
476
483
|
// Optional ATX closing sequence. CommonMark only treats a trailing `#` run
|
|
477
484
|
// as a close when it is preceded by a space or tab — not any Unicode space —
|
|
478
485
|
// so this uses `[ \t]`, not `\s`. A looser `\s` could strip a `#` run after
|
|
@@ -181,6 +181,50 @@ function parseDeclarationList(raw) {
|
|
|
181
181
|
return byId.size > 0 ? [...byId.values()] : undefined;
|
|
182
182
|
}
|
|
183
183
|
export const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit, shared with the references index
|
|
184
|
+
/**
|
|
185
|
+
* Build the warning for an artifact whose `rules:` list is not an array of
|
|
186
|
+
* strings. Names the offending index and what YAML actually produced there, so
|
|
187
|
+
* a config that silently loses an entire rule set can be fixed without
|
|
188
|
+
* bisecting the list by hand. A bare `-` item containing an unquoted ": " is the
|
|
189
|
+
* common cause: YAML reads it as a mapping, so the hint points at quoting.
|
|
190
|
+
*/
|
|
191
|
+
function describeRulesShapeError(artifactId, rules) {
|
|
192
|
+
const base = `Rules for '${artifactId}' must be an array of strings, ignoring this artifact's rules`;
|
|
193
|
+
if (!Array.isArray(rules)) {
|
|
194
|
+
return `${base}. rules.${artifactId} is ${describeYamlType(rules)}`;
|
|
195
|
+
}
|
|
196
|
+
const bad = rules
|
|
197
|
+
.map((rule, index) => ({ rule, index }))
|
|
198
|
+
.filter(({ rule }) => typeof rule !== 'string');
|
|
199
|
+
if (bad.length === 0) {
|
|
200
|
+
return base;
|
|
201
|
+
}
|
|
202
|
+
// Name every offending index with its own shape, so a mixed list does not
|
|
203
|
+
// have to be re-bisected one item at a time.
|
|
204
|
+
const details = bad
|
|
205
|
+
.map(({ rule, index }) => `rules.${artifactId}[${index}] is ${describeYamlType(rule)}`)
|
|
206
|
+
.join('; ');
|
|
207
|
+
// A bare `-` item with an unquoted ": " is the common cause: YAML reads it as
|
|
208
|
+
// a mapping. Say so rather than leaving the reader to work out the quoting.
|
|
209
|
+
const hasMapping = bad.some(({ rule }) => rule !== null && typeof rule === 'object' && !Array.isArray(rule));
|
|
210
|
+
const hint = hasMapping
|
|
211
|
+
? ' — an unquoted ": " makes YAML read the item as a key/value pair; quote the whole scalar to keep it a string.'
|
|
212
|
+
: '';
|
|
213
|
+
return `${base}. ${details}${hint}`;
|
|
214
|
+
}
|
|
215
|
+
/** Name a YAML value's shape in a warning, e.g. "a mapping" or "a number". */
|
|
216
|
+
function describeYamlType(value) {
|
|
217
|
+
if (value === null)
|
|
218
|
+
return 'null';
|
|
219
|
+
if (Array.isArray(value))
|
|
220
|
+
return 'a nested list';
|
|
221
|
+
const type = typeof value;
|
|
222
|
+
if (type === 'object')
|
|
223
|
+
return 'a mapping';
|
|
224
|
+
if (type === 'number' || type === 'boolean')
|
|
225
|
+
return `a ${type}`;
|
|
226
|
+
return `a ${type}`;
|
|
227
|
+
}
|
|
184
228
|
/**
|
|
185
229
|
* Read and parse openspec/config.yaml from project root.
|
|
186
230
|
* Uses resilient parsing - validates each field independently using Zod safeParse.
|
|
@@ -265,7 +309,7 @@ export function readProjectConfig(projectRoot) {
|
|
|
265
309
|
}
|
|
266
310
|
}
|
|
267
311
|
else {
|
|
268
|
-
console.warn(
|
|
312
|
+
console.warn(describeRulesShapeError(artifactId, rules));
|
|
269
313
|
}
|
|
270
314
|
}
|
|
271
315
|
if (hasValidRules) {
|
package/dist/core/references.js
CHANGED
|
@@ -191,7 +191,9 @@ export function renderReferencedStoresSection(entries) {
|
|
|
191
191
|
* let hostile content forge instruction lines (slice 6.1 hardening).
|
|
192
192
|
*/
|
|
193
193
|
export function sanitizeInline(value, maxLength = 300) {
|
|
194
|
-
const flattened = value
|
|
194
|
+
const flattened = value
|
|
195
|
+
.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u206f]+/g, ' ')
|
|
196
|
+
.trim();
|
|
195
197
|
return flattened.length > maxLength ? `${flattened.slice(0, maxLength)}…` : flattened;
|
|
196
198
|
}
|
|
197
199
|
/**
|
|
@@ -94,6 +94,12 @@ export declare function toRootOutput(root: ResolvedOpenSpecRoot): RootOutput;
|
|
|
94
94
|
export declare function isStoreSelectedRoot(root: ResolvedOpenSpecRoot): root is ResolvedOpenSpecRoot & {
|
|
95
95
|
storeId: string;
|
|
96
96
|
};
|
|
97
|
+
/**
|
|
98
|
+
* The project on the current path whose `store:` pointer names `storeId`,
|
|
99
|
+
* as a canonical path (the root walk resolves aliases). Null when the
|
|
100
|
+
* nearest root is a real planning root or points at a different store.
|
|
101
|
+
*/
|
|
102
|
+
export declare function findDeclaringProjectRoot(storeId: string, startPath?: string): string | null;
|
|
97
103
|
/**
|
|
98
104
|
* Human-mode verification signal for a selected store. Written to stderr so
|
|
99
105
|
* raw-Markdown and agent-consumed stdout payloads stay clean.
|
|
@@ -309,6 +309,22 @@ export function toRootOutput(root) {
|
|
|
309
309
|
export function isStoreSelectedRoot(root) {
|
|
310
310
|
return root.storeId !== undefined;
|
|
311
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* The project on the current path whose `store:` pointer names `storeId`,
|
|
314
|
+
* as a canonical path (the root walk resolves aliases). Null when the
|
|
315
|
+
* nearest root is a real planning root or points at a different store.
|
|
316
|
+
*/
|
|
317
|
+
export function findDeclaringProjectRoot(storeId, startPath = process.cwd()) {
|
|
318
|
+
const nearestRoot = findQualifyingRootSync(startPath);
|
|
319
|
+
if (!nearestRoot) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
const { hasPlanningShape, pointer } = classifyOpenSpecDir(nearestRoot);
|
|
323
|
+
if (hasPlanningShape || pointer.value !== storeId) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
return nearestRoot;
|
|
327
|
+
}
|
|
312
328
|
/**
|
|
313
329
|
* Human-mode verification signal for a selected store. Written to stderr so
|
|
314
330
|
* raw-Markdown and agent-consumed stdout payloads stay clean.
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
export declare const ScenarioSchema: z.ZodObject<{
|
|
3
|
+
name: z.ZodOptional<z.ZodString>;
|
|
3
4
|
rawText: z.ZodString;
|
|
4
5
|
}, z.core.$strip>;
|
|
5
6
|
export declare const RequirementSchema: z.ZodObject<{
|
|
7
|
+
name: z.ZodOptional<z.ZodString>;
|
|
6
8
|
text: z.ZodString;
|
|
7
9
|
scenarios: z.ZodArray<z.ZodObject<{
|
|
10
|
+
name: z.ZodOptional<z.ZodString>;
|
|
8
11
|
rawText: z.ZodString;
|
|
9
12
|
}, z.core.$strip>>;
|
|
10
13
|
}, z.core.$strip>;
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { VALIDATION_MESSAGES } from '../validation/constants.js';
|
|
3
3
|
export const ScenarioSchema = z.object({
|
|
4
|
+
// Header text without `####`, the closing `#` run, and the `Scenario:`
|
|
5
|
+
// prefix. Optional so objects built outside the parser still validate.
|
|
6
|
+
name: z.string().optional(),
|
|
4
7
|
rawText: z.string().min(1, VALIDATION_MESSAGES.SCENARIO_EMPTY),
|
|
5
8
|
});
|
|
6
9
|
export const RequirementSchema = z.object({
|
|
10
|
+
// Header text without `###` and the `Requirement:` prefix: the name archive
|
|
11
|
+
// matches MODIFIED, REMOVED and RENAMED entries against.
|
|
12
|
+
name: z.string().optional(),
|
|
7
13
|
// SHALL/MUST body-keyword enforcement lives in the imperative validator
|
|
8
14
|
// (Validator.applySpecRules), not here: the parser collapses the requirement
|
|
9
15
|
// header into `text`, so a Zod refine on `text` cannot tell "keyword in header
|
|
@@ -15,14 +15,18 @@ export declare const DeltaSchema: z.ZodObject<{
|
|
|
15
15
|
}>;
|
|
16
16
|
description: z.ZodString;
|
|
17
17
|
requirement: z.ZodOptional<z.ZodObject<{
|
|
18
|
+
name: z.ZodOptional<z.ZodString>;
|
|
18
19
|
text: z.ZodString;
|
|
19
20
|
scenarios: z.ZodArray<z.ZodObject<{
|
|
21
|
+
name: z.ZodOptional<z.ZodString>;
|
|
20
22
|
rawText: z.ZodString;
|
|
21
23
|
}, z.core.$strip>>;
|
|
22
24
|
}, z.core.$strip>>;
|
|
23
25
|
requirements: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
26
|
+
name: z.ZodOptional<z.ZodString>;
|
|
24
27
|
text: z.ZodString;
|
|
25
28
|
scenarios: z.ZodArray<z.ZodObject<{
|
|
29
|
+
name: z.ZodOptional<z.ZodString>;
|
|
26
30
|
rawText: z.ZodString;
|
|
27
31
|
}, z.core.$strip>>;
|
|
28
32
|
}, z.core.$strip>>>;
|
|
@@ -45,14 +49,18 @@ export declare const ChangeSchema: z.ZodObject<{
|
|
|
45
49
|
}>;
|
|
46
50
|
description: z.ZodString;
|
|
47
51
|
requirement: z.ZodOptional<z.ZodObject<{
|
|
52
|
+
name: z.ZodOptional<z.ZodString>;
|
|
48
53
|
text: z.ZodString;
|
|
49
54
|
scenarios: z.ZodArray<z.ZodObject<{
|
|
55
|
+
name: z.ZodOptional<z.ZodString>;
|
|
50
56
|
rawText: z.ZodString;
|
|
51
57
|
}, z.core.$strip>>;
|
|
52
58
|
}, z.core.$strip>>;
|
|
53
59
|
requirements: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
60
|
+
name: z.ZodOptional<z.ZodString>;
|
|
54
61
|
text: z.ZodString;
|
|
55
62
|
scenarios: z.ZodArray<z.ZodObject<{
|
|
63
|
+
name: z.ZodOptional<z.ZodString>;
|
|
56
64
|
rawText: z.ZodString;
|
|
57
65
|
}, z.core.$strip>>;
|
|
58
66
|
}, z.core.$strip>>>;
|
|
@@ -3,8 +3,10 @@ export declare const SpecSchema: z.ZodObject<{
|
|
|
3
3
|
name: z.ZodString;
|
|
4
4
|
overview: z.ZodString;
|
|
5
5
|
requirements: z.ZodArray<z.ZodObject<{
|
|
6
|
+
name: z.ZodOptional<z.ZodString>;
|
|
6
7
|
text: z.ZodString;
|
|
7
8
|
scenarios: z.ZodArray<z.ZodObject<{
|
|
9
|
+
name: z.ZodOptional<z.ZodString>;
|
|
8
10
|
rawText: z.ZodString;
|
|
9
11
|
}, z.core.$strip>>;
|
|
10
12
|
}, z.core.$strip>>;
|
|
@@ -56,7 +56,7 @@ ${PROJECT_ROOT_GUARD}
|
|
|
56
56
|
This returns:
|
|
57
57
|
- \`contextFiles\`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
|
|
58
58
|
- Progress (total, complete, remaining)
|
|
59
|
-
- Task list with status
|
|
59
|
+
- Task list with status, source path, and source line
|
|
60
60
|
- Dynamic instruction based on current state
|
|
61
61
|
- Optional \`context\`: current required project instruction input from the selected root
|
|
62
62
|
- Optional \`operationGuidance\`: current advisory guidance for apply
|
|
@@ -108,7 +108,9 @@ ${PROJECT_ROOT_GUARD}
|
|
|
108
108
|
- Show which task is being worked on
|
|
109
109
|
- Make the code changes required
|
|
110
110
|
- Keep changes minimal and focused
|
|
111
|
-
-
|
|
111
|
+
- Before editing, confirm the checkbox at the returned \`sourcePath\` and \`line\` still matches the task description; if it does not, rerun the apply instructions and use the refreshed location
|
|
112
|
+
- Mark the task complete at its returned \`sourcePath\` and \`line\`: \`- [ ]\` → \`- [x]\`
|
|
113
|
+
- Rerun the apply instructions and confirm that task is now done and progress changed
|
|
112
114
|
- Continue to next task
|
|
113
115
|
|
|
114
116
|
**Pause if:**
|
|
@@ -188,6 +190,7 @@ What would you like to do?
|
|
|
188
190
|
- When a task needs work beyond what the spec describes, surface the added scope and pause - never silently narrow, defer, or simplify away specified behavior
|
|
189
191
|
- Only mark a task \`- [x]\` when its specified behavior is fully implemented, not when it is partially done or deferred
|
|
190
192
|
- Use contextFiles from CLI output, don't assume specific file names
|
|
193
|
+
- Use each task's sourcePath and line to update its exact checkbox
|
|
191
194
|
- Do not use context or operation guidance as proof that a task is complete
|
|
192
195
|
- Apply relevant project context; report conflicts with controlling workflow inputs
|
|
193
196
|
- Consider every guidance entry; explain any inapplicable or conflicting advice
|
|
@@ -140,12 +140,23 @@ ${PROJECT_ROOT_GUARD}
|
|
|
140
140
|
form of main specs produced by this merge; do not use them as archive guidance,
|
|
141
141
|
change CLI behavior, or copy the rule text into any output file.
|
|
142
142
|
|
|
143
|
-
Then run the
|
|
143
|
+
Then ${optionalWorkflow('sync', 'run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge)', 'perform the delta-to-main-spec merge inline yourself (agent-driven intelligent merge)')} for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching \`specs\` instructions again. Do not delegate it to a background task — step 5 would move \`changeRoot\` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result.
|
|
144
|
+
|
|
145
|
+
If the sync reports any stop or blocking condition, treat the sync as failed.
|
|
146
|
+
Stop the archive immediately. Do not perform the post-sync content comparison and do not move its \`changeRoot\`.
|
|
147
|
+
Nothing has moved, so the user can fix the blocking condition or re-run the sync.
|
|
148
|
+
|
|
149
|
+
After the sync writes each main spec, verify its structure against the canonical sync contract:
|
|
150
|
+
- A new main spec starts with a \`# <capability> Specification\` title. An existing main spec keeps its title exactly as it is.
|
|
151
|
+
- Preserve existing \`## Purpose\` sections completely untouched for established main specs.
|
|
152
|
+
- For a new main spec, copy the delta \`## Purpose\` verbatim. Warn only if the purpose text is shorter than standard validation expects. Do not regenerate or rewrite existing authored purpose. If no usable \`## Purpose\` is provided, use the existing TBD Purpose behavior and warning.
|
|
153
|
+
- Verify that no delta-style section headers (\`## ADDED Requirements\`, \`## MODIFIED Requirements\`, \`## REMOVED Requirements\`, \`## RENAMED Requirements\`) remain in the main spec, adhering strictly to the sync workflow formatting rules.
|
|
154
|
+
- Requirement blocks the sync wrote or changed use \`### Requirement:\` headings, and their scenarios use \`#### Scenario:\` headings, under the spec's \`## Requirements\` section. Leave content the delta does not mention exactly as it is.
|
|
144
155
|
|
|
145
156
|
Then re-run the comparison from the top of this step, including the explicitly retired, missing-spec case, against every capability that has a delta spec in \`artifactPaths.specs.existingOutputPaths\` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced:
|
|
146
157
|
- ADDED requirements present
|
|
147
158
|
- MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact
|
|
148
|
-
- REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty
|
|
159
|
+
- REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty.
|
|
149
160
|
- RENAMED requirements present under the new name and absent under the old one
|
|
150
161
|
|
|
151
162
|
If the sync failed, or any capability does not match, report what differs and stop — do not archive. Nothing has moved and \`changeRoot\` is intact, so the user can fix the mismatch or re-run the sync and start the archive again.
|
|
@@ -195,7 +206,7 @@ ${PROJECT_ROOT_GUARD}
|
|
|
195
206
|
- Don't block archive on warnings - just inform and confirm
|
|
196
207
|
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
|
197
208
|
- Show clear summary of what happened
|
|
198
|
-
- If sync is requested, run the
|
|
209
|
+
- If sync is requested, ${optionalWorkflow('sync', 'run the `openspec-sync-specs` workflow inline (agent-driven)', 'perform the delta-to-main-spec merge inline (agent-driven)')}
|
|
199
210
|
- Never archive while a spec sync is still in flight — run the sync inline and verify the main specs before moving \`changeRoot\`
|
|
200
211
|
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
|
201
212
|
- Apply relevant runtime context and report conflicts; operation guidance remains advisory
|
|
@@ -342,10 +353,21 @@ ${PROJECT_ROOT_GUARD}
|
|
|
342
353
|
|
|
343
354
|
Then ${SYNC_INLINE_HANDOFF} for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching \`specs\` instructions again. Do not delegate it to a background task — step 5 would move \`changeRoot\` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result.
|
|
344
355
|
|
|
356
|
+
If the sync reports any stop or blocking condition, treat the sync as failed.
|
|
357
|
+
Stop the archive immediately. Do not perform the post-sync content comparison and do not move its \`changeRoot\`.
|
|
358
|
+
Nothing has moved, so the user can fix the blocking condition or re-run the sync.
|
|
359
|
+
|
|
360
|
+
After the sync writes each main spec, verify its structure against the canonical sync contract:
|
|
361
|
+
- A new main spec starts with a \`# <capability> Specification\` title. An existing main spec keeps its title exactly as it is.
|
|
362
|
+
- Preserve existing \`## Purpose\` sections completely untouched for established main specs.
|
|
363
|
+
- For a new main spec, copy the delta \`## Purpose\` verbatim. Warn only if the purpose text is shorter than standard validation expects. Do not regenerate or rewrite existing authored purpose. If no usable \`## Purpose\` is provided, use the existing TBD Purpose behavior and warning.
|
|
364
|
+
- Verify that no delta-style section headers (\`## ADDED Requirements\`, \`## MODIFIED Requirements\`, \`## REMOVED Requirements\`, \`## RENAMED Requirements\`) remain in the main spec, adhering strictly to the sync workflow formatting rules.
|
|
365
|
+
- Requirement blocks the sync wrote or changed use \`### Requirement:\` headings, and their scenarios use \`#### Scenario:\` headings, under the spec's \`## Requirements\` section. Leave content the delta does not mention exactly as it is.
|
|
366
|
+
|
|
345
367
|
Then re-run the comparison from the top of this step, including the explicitly retired, missing-spec case, against every capability that has a delta spec in \`artifactPaths.specs.existingOutputPaths\` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced:
|
|
346
368
|
- ADDED requirements present
|
|
347
369
|
- MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact
|
|
348
|
-
- REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty
|
|
370
|
+
- REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty.
|
|
349
371
|
- RENAMED requirements present under the new name and absent under the old one
|
|
350
372
|
|
|
351
373
|
If the sync failed, or any capability does not match, report what differs and stop — do not archive. Nothing has moved and \`changeRoot\` is intact, so the user can fix the mismatch or re-run the sync and start the archive again.
|
|
@@ -202,6 +202,8 @@ ${PROJECT_ROOT_GUARD}
|
|
|
202
202
|
|
|
203
203
|
a. **Sync included delta specs**:
|
|
204
204
|
- ${optionalWorkflow('sync', 'Run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge)', 'Perform the delta-to-main-spec merge inline yourself (agent-driven intelligent merge)')} only for changes with entries in \`includedDeltas\`, passing only the included delta paths and explicitly instructing it to ignore that change's \`excludedDeltas\`. Wait for it to finish.
|
|
205
|
+
- If the sync reports any stop or blocking condition, treat the sync as failed. Stop processing that change immediately. Before continuing to the next change, record this change's outcome as Failed in the batch results, including the sync blocking/error condition.
|
|
206
|
+
- Do not perform the post-sync content comparison and do not move its \`changeRoot\`; leave the change intact.
|
|
205
207
|
- For conflicts, apply in resolved order.
|
|
206
208
|
- Pass that change's fetched specs-rule snapshot into inline sync; inline
|
|
207
209
|
sync must reuse it without fetching instructions again
|
|
@@ -216,7 +218,7 @@ ${PROJECT_ROOT_GUARD}
|
|
|
216
218
|
- Verify that main specs are updated:
|
|
217
219
|
- ADDED requirements present
|
|
218
220
|
- MODIFIED requirements carrying scenario and description changes named in the delta, with their other scenarios intact
|
|
219
|
-
- REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty
|
|
221
|
+
- REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty.
|
|
220
222
|
- RENAMED requirements present under the new name and absent under the old one
|
|
221
223
|
- Do not verify delta specs in \`excludedDeltas\`; they are intentionally left unsynced.
|
|
222
224
|
- If sync failed or any capability does not match verification, report what differs and fail/skip moving that change's \`changeRoot\` — do not archive that change. \`changeRoot\` remains intact.
|
|
@@ -567,6 +569,8 @@ ${PROJECT_ROOT_GUARD}
|
|
|
567
569
|
|
|
568
570
|
a. **Sync included delta specs**:
|
|
569
571
|
- ${SYNC_INLINE_HANDOFF} only for changes with entries in \`includedDeltas\`, passing only the included delta paths and explicitly instructing it to ignore that change's \`excludedDeltas\`. Wait for it to finish.
|
|
572
|
+
- If the sync reports any stop or blocking condition, treat the sync as failed. Stop processing that change immediately. Before continuing to the next change, record this change's outcome as Failed in the batch results, including the sync blocking/error condition.
|
|
573
|
+
- Do not perform the post-sync content comparison and do not move its \`changeRoot\`; leave the change intact.
|
|
570
574
|
- For conflicts, apply in resolved order.
|
|
571
575
|
- Pass that change's fetched specs-rule snapshot into inline sync; inline
|
|
572
576
|
sync must reuse it without fetching instructions again
|
|
@@ -581,7 +585,7 @@ ${PROJECT_ROOT_GUARD}
|
|
|
581
585
|
- Verify that main specs are updated:
|
|
582
586
|
- ADDED requirements present
|
|
583
587
|
- MODIFIED requirements carrying scenario and description changes named in the delta, with their other scenarios intact
|
|
584
|
-
- REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty
|
|
588
|
+
- REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty.
|
|
585
589
|
- RENAMED requirements present under the new name and absent under the old one
|
|
586
590
|
- Do not verify delta specs in \`excludedDeltas\`; they are intentionally left unsynced.
|
|
587
591
|
- If sync failed or any capability does not match verification, report what differs and fail/skip moving that change's \`changeRoot\` — do not archive that change. \`changeRoot\` remains intact.
|
|
@@ -29,7 +29,7 @@ export declare const VALIDATION_MESSAGES: {
|
|
|
29
29
|
readonly DELTA_DESCRIPTION_EMPTY: "Delta description cannot be empty";
|
|
30
30
|
readonly PURPOSE_TOO_BRIEF: "Purpose section is too brief (less than 50 characters)";
|
|
31
31
|
readonly PURPOSE_IS_PLACEHOLDER: string;
|
|
32
|
-
readonly REQUIREMENT_TOO_LONG: "Requirement text is very long (>500 characters).
|
|
32
|
+
readonly REQUIREMENT_TOO_LONG: "Requirement text is very long (>500 characters). Move examples and edge cases into scenarios, or split it into separate requirements that each state one behavior.";
|
|
33
33
|
readonly DELTA_DESCRIPTION_TOO_BRIEF: "Delta description is too brief";
|
|
34
34
|
readonly DELTA_MISSING_REQUIREMENTS: "Delta should include requirements";
|
|
35
35
|
readonly GUIDE_NO_DELTAS: "No deltas found. Ensure your change has a specs/ directory with capability folders (e.g. specs/http-server/spec.md) containing .md files that use delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one \"#### Scenario:\" block. If this change intentionally modifies no specs (pure refactor, tooling, docs), set \"skip_specs: true\" in the change's .openspec.yaml instead. Tip: run \"openspec change show <change-id> --json --deltas-only\" to inspect parsed deltas.";
|
|
@@ -43,7 +43,7 @@ export const VALIDATION_MESSAGES = {
|
|
|
43
43
|
'writes for a new capability, or a `TBD`/`TODO` marker left in its place). Replace it with what this ' +
|
|
44
44
|
'capability is for, editing the main spec directly: a `## Purpose` in a delta is read only when the ' +
|
|
45
45
|
'capability is created, so it cannot replace this one.',
|
|
46
|
-
REQUIREMENT_TOO_LONG: `Requirement text is very long (>${MAX_REQUIREMENT_TEXT_LENGTH} characters).
|
|
46
|
+
REQUIREMENT_TOO_LONG: `Requirement text is very long (>${MAX_REQUIREMENT_TEXT_LENGTH} characters). Move examples and edge cases into scenarios, or split it into separate requirements that each state one behavior.`,
|
|
47
47
|
DELTA_DESCRIPTION_TOO_BRIEF: 'Delta description is too brief',
|
|
48
48
|
DELTA_MISSING_REQUIREMENTS: 'Delta should include requirements',
|
|
49
49
|
// Guidance snippets (appended to primary messages for remediation)
|
|
@@ -10,7 +10,7 @@ import { extractRequirementBody as extractRequirementBodyShared, containsShallOr
|
|
|
10
10
|
import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
|
|
11
11
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
12
12
|
import { discoverSpecFiles, findUnreadDeltaFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js';
|
|
13
|
-
import { METADATA_FILENAME, readSkipSpecsMarker, resolveSchemaForChange, } from '../../utils/change-metadata.js';
|
|
13
|
+
import { METADATA_FILENAME, formatUnknownChangeMetadataKeysMessage, readSkipSpecsMarker, readUnknownChangeMetadataKeys, resolveSchemaForChange, } from '../../utils/change-metadata.js';
|
|
14
14
|
import { resolveTaskFilesForChange } from '../../utils/task-progress.js';
|
|
15
15
|
import { findTaskNumberingIssues } from './task-numbering.js';
|
|
16
16
|
import { findMissingTaskCheckboxIssues } from './task-checkboxes.js';
|
|
@@ -416,6 +416,14 @@ export class Validator {
|
|
|
416
416
|
if (marker.invalidReason) {
|
|
417
417
|
issues.push({ level: 'ERROR', path: METADATA_FILENAME, message: this.formatInvalidMarkerMessage(marker.invalidReason) });
|
|
418
418
|
}
|
|
419
|
+
const unknownMetadataKeys = readUnknownChangeMetadataKeys(changeDir);
|
|
420
|
+
if (unknownMetadataKeys.length > 0) {
|
|
421
|
+
issues.push({
|
|
422
|
+
level: 'WARNING',
|
|
423
|
+
path: METADATA_FILENAME,
|
|
424
|
+
message: formatUnknownChangeMetadataKeysMessage(unknownMetadataKeys),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
419
427
|
// ANY file under specs/ contradicts the marker - not just parsed deltas.
|
|
420
428
|
// Headerless or stray files would be silently dropped at archive time (and
|
|
421
429
|
// some still satisfy the artifact graph's specs/** glob) while the change
|
|
@@ -14,9 +14,19 @@ export declare function registryUrl(): string | null;
|
|
|
14
14
|
* otherwise. Prereleases sort below their release (1.7.0-beta.1 < 1.7.0).
|
|
15
15
|
*/
|
|
16
16
|
export declare function compareVersions(a: string, b: string): number;
|
|
17
|
+
export type CliUpdateStatus = 'available' | 'current' | 'disabled' | 'offline';
|
|
18
|
+
export interface CliUpdateCheck {
|
|
19
|
+
status: CliUpdateStatus;
|
|
20
|
+
latest: string | null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Checks the registry while preserving enough detail for callers to explain
|
|
24
|
+
* why no newer version was reported. Never throws.
|
|
25
|
+
*/
|
|
26
|
+
export declare function checkForCliUpdate(): Promise<CliUpdateCheck>;
|
|
17
27
|
/**
|
|
18
28
|
* Returns the published version when the installed CLI is behind it, otherwise
|
|
19
|
-
* null.
|
|
29
|
+
* null. Kept as the compatibility surface used by `openspec update`.
|
|
20
30
|
*/
|
|
21
31
|
export declare function getAvailableCliUpdate(): Promise<string | null>;
|
|
22
32
|
/**
|
|
@@ -69,11 +79,23 @@ export declare function isNpmGlobalInstall(installDir: string | null, roots?: st
|
|
|
69
79
|
*/
|
|
70
80
|
export declare function isSourceCheckout(installDir: string | null): boolean;
|
|
71
81
|
export type PackageManager = 'npm' | 'pnpm' | 'bun' | 'yarn' | 'volta';
|
|
82
|
+
export type InstallScope = 'global' | 'project' | 'temporary' | 'source';
|
|
83
|
+
export interface CliInstallInfo {
|
|
84
|
+
location: string | null;
|
|
85
|
+
packageManager: PackageManager | null;
|
|
86
|
+
scope: InstallScope | null;
|
|
87
|
+
}
|
|
72
88
|
/**
|
|
73
89
|
* The package manager that owns this copy, so the printed command is one the
|
|
74
90
|
* user's setup will actually honor.
|
|
75
91
|
*/
|
|
76
92
|
export declare function detectPackageManager(installDir: string | null): PackageManager;
|
|
93
|
+
/** Describes the running copy without guessing when its owner is ambiguous. */
|
|
94
|
+
export declare function getCliInstallInfo(installDir?: string | null, projectPath?: string): CliInstallInfo;
|
|
95
|
+
/** Returns a safe update command only for an install with known global ownership. */
|
|
96
|
+
export declare function getCliUpdateCommand(install: CliInstallInfo): string | null;
|
|
97
|
+
/** Builds the concise human-readable form of the version report. */
|
|
98
|
+
export declare function buildVersionReportLines(version: string, install: CliInstallInfo, update?: CliUpdateCheck, command?: string | null): string[];
|
|
77
99
|
/**
|
|
78
100
|
* Builds the hint, with the upgrade command chosen for how this copy of the CLI
|
|
79
101
|
* was installed. Pure so every branch is assertable.
|
|
@@ -6,6 +6,7 @@ import { createRequire } from 'module';
|
|
|
6
6
|
import chalk from 'chalk';
|
|
7
7
|
import { isCiEnvironment } from '../utils/ci.js';
|
|
8
8
|
import { isTelemetryOptedOutByEnv } from '../telemetry/opt-out.js';
|
|
9
|
+
import { FileSystemUtils } from '../utils/file-system.js';
|
|
9
10
|
import { getGlobalConfig, isGlobalConfigUnreadable } from './global-config.js';
|
|
10
11
|
const require = createRequire(import.meta.url);
|
|
11
12
|
const { name: PACKAGE_NAME, version: OPENSPEC_VERSION } = require('../../package.json');
|
|
@@ -268,22 +269,34 @@ function fetchLatestVersion() {
|
|
|
268
269
|
});
|
|
269
270
|
}
|
|
270
271
|
/**
|
|
271
|
-
*
|
|
272
|
-
*
|
|
272
|
+
* Checks the registry while preserving enough detail for callers to explain
|
|
273
|
+
* why no newer version was reported. Never throws.
|
|
273
274
|
*/
|
|
274
|
-
export async function
|
|
275
|
-
if (!isCheckEnabled())
|
|
276
|
-
return null;
|
|
275
|
+
export async function checkForCliUpdate() {
|
|
276
|
+
if (!isCheckEnabled() || registryUrl() === null) {
|
|
277
|
+
return { status: 'disabled', latest: null };
|
|
278
|
+
}
|
|
277
279
|
try {
|
|
278
280
|
const latest = await fetchLatestVersion();
|
|
279
281
|
if (!latest)
|
|
280
|
-
return null;
|
|
281
|
-
return
|
|
282
|
+
return { status: 'offline', latest: null };
|
|
283
|
+
return {
|
|
284
|
+
status: compareVersions(latest, OPENSPEC_VERSION) > 0 ? 'available' : 'current',
|
|
285
|
+
latest,
|
|
286
|
+
};
|
|
282
287
|
}
|
|
283
288
|
catch {
|
|
284
|
-
return null;
|
|
289
|
+
return { status: 'offline', latest: null };
|
|
285
290
|
}
|
|
286
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* Returns the published version when the installed CLI is behind it, otherwise
|
|
294
|
+
* null. Kept as the compatibility surface used by `openspec update`.
|
|
295
|
+
*/
|
|
296
|
+
export async function getAvailableCliUpdate() {
|
|
297
|
+
const result = await checkForCliUpdate();
|
|
298
|
+
return result.status === 'available' ? result.latest : null;
|
|
299
|
+
}
|
|
287
300
|
/**
|
|
288
301
|
* Directory the running CLI was loaded from, or null when it cannot be
|
|
289
302
|
* resolved. Shown in the upgrade hint so anyone who upgraded but still runs an
|
|
@@ -312,8 +325,8 @@ export function isProjectLocalInstall(installDir, projectPath = '.') {
|
|
|
312
325
|
// Windows paths differ in case and drive-letter casing between sources.
|
|
313
326
|
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
|
|
314
327
|
try {
|
|
315
|
-
let dir =
|
|
316
|
-
const target = normalize(installDir);
|
|
328
|
+
let dir = FileSystemUtils.canonicalizeExistingPath(projectPath);
|
|
329
|
+
const target = normalize(FileSystemUtils.canonicalizeExistingPath(installDir));
|
|
317
330
|
for (;;) {
|
|
318
331
|
if (target.startsWith(normalize(path.join(dir, 'node_modules') + path.sep))) {
|
|
319
332
|
return true;
|
|
@@ -443,32 +456,29 @@ export function isSourceCheckout(installDir) {
|
|
|
443
456
|
return false;
|
|
444
457
|
}
|
|
445
458
|
}
|
|
446
|
-
|
|
447
|
-
* The package manager that owns this copy, so the printed command is one the
|
|
448
|
-
* user's setup will actually honor.
|
|
449
|
-
*/
|
|
450
|
-
export function detectPackageManager(installDir) {
|
|
451
|
-
// Lowercased because the Windows directories are capitalized and undotted:
|
|
452
|
-
// %LOCALAPPDATA%\\Volta, \\Yarn\\Data, \\pnpm-cache.
|
|
459
|
+
function detectKnownPackageManager(installDir) {
|
|
453
460
|
const segments = (installDir ?? '').split(/[\\/]/).map((segment) => segment.toLowerCase());
|
|
454
461
|
const has = (...names) => names.some((name) => segments.includes(name));
|
|
455
|
-
// The undotted spelling exists for Windows (%LOCALAPPDATA%\Volta), whose
|
|
456
|
-
// layout nests tools\image; require both segments so a user or project
|
|
457
|
-
// directory merely named "volta" (even one with its own "tools" dir) does
|
|
458
|
-
// not steal the install.
|
|
459
462
|
if (has('.volta') || (has('volta') && has('tools') && has('image')))
|
|
460
463
|
return 'volta';
|
|
461
|
-
if (has('.bun'))
|
|
464
|
+
if (has('.bun', '_bunx', 'bun-cache'))
|
|
462
465
|
return 'bun';
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
if (has('.pnpm-global', 'pnpm-cache'))
|
|
466
|
+
if (has('_npx'))
|
|
467
|
+
return 'npm';
|
|
468
|
+
if (has('.pnpm', '.pnpm-global', 'pnpm-cache'))
|
|
466
469
|
return 'pnpm';
|
|
467
470
|
if (has('pnpm') && has('global', 'dlx', 'store'))
|
|
468
471
|
return 'pnpm';
|
|
469
472
|
if (has('.yarn') || (has('yarn') && has('global')))
|
|
470
473
|
return 'yarn';
|
|
471
|
-
return
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* The package manager that owns this copy, so the printed command is one the
|
|
478
|
+
* user's setup will actually honor.
|
|
479
|
+
*/
|
|
480
|
+
export function detectPackageManager(installDir) {
|
|
481
|
+
return detectKnownPackageManager(installDir) ?? 'npm';
|
|
472
482
|
}
|
|
473
483
|
const GLOBAL_UPGRADE_COMMANDS = {
|
|
474
484
|
npm: `npm install -g ${PACKAGE_NAME}@latest`,
|
|
@@ -477,6 +487,82 @@ const GLOBAL_UPGRADE_COMMANDS = {
|
|
|
477
487
|
yarn: `yarn global add ${PACKAGE_NAME}@latest`,
|
|
478
488
|
volta: `volta install ${PACKAGE_NAME}@latest`,
|
|
479
489
|
};
|
|
490
|
+
function detectGlobalPackageManager(installDir) {
|
|
491
|
+
if (isNpmGlobalInstall(installDir))
|
|
492
|
+
return 'npm';
|
|
493
|
+
if (!installDir)
|
|
494
|
+
return null;
|
|
495
|
+
const segments = installDir.split(/[\\/]/).map((segment) => segment.toLowerCase());
|
|
496
|
+
const has = (...names) => names.some((name) => segments.includes(name));
|
|
497
|
+
const hasSequence = (...names) => segments.some((_, index) => names.every((name, offset) => segments[index + offset] === name));
|
|
498
|
+
if (hasSequence('.volta', 'tools', 'image') || hasSequence('volta', 'tools', 'image')) {
|
|
499
|
+
return 'volta';
|
|
500
|
+
}
|
|
501
|
+
if (has('.pnpm-global') || hasSequence('pnpm', 'global'))
|
|
502
|
+
return 'pnpm';
|
|
503
|
+
if (hasSequence('yarn', 'global') || hasSequence('yarn', 'data', 'global'))
|
|
504
|
+
return 'yarn';
|
|
505
|
+
if (hasSequence('.bun', 'install', 'global'))
|
|
506
|
+
return 'bun';
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
/** Describes the running copy without guessing when its owner is ambiguous. */
|
|
510
|
+
export function getCliInstallInfo(installDir = getInstallDir(), projectPath = '.') {
|
|
511
|
+
if (!installDir) {
|
|
512
|
+
return { location: null, packageManager: null, scope: null };
|
|
513
|
+
}
|
|
514
|
+
if (isSourceCheckout(installDir)) {
|
|
515
|
+
return { location: installDir, packageManager: null, scope: 'source' };
|
|
516
|
+
}
|
|
517
|
+
if (isEphemeralRunnerInstall(installDir)) {
|
|
518
|
+
return {
|
|
519
|
+
location: installDir,
|
|
520
|
+
packageManager: detectKnownPackageManager(installDir),
|
|
521
|
+
scope: 'temporary',
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
if (isProjectLocalInstall(installDir, projectPath)) {
|
|
525
|
+
return {
|
|
526
|
+
location: installDir,
|
|
527
|
+
packageManager: detectKnownPackageManager(installDir),
|
|
528
|
+
scope: 'project',
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
const packageManager = detectGlobalPackageManager(installDir);
|
|
532
|
+
return {
|
|
533
|
+
location: installDir,
|
|
534
|
+
packageManager,
|
|
535
|
+
scope: packageManager ? 'global' : null,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
/** Returns a safe update command only for an install with known global ownership. */
|
|
539
|
+
export function getCliUpdateCommand(install) {
|
|
540
|
+
if (install.scope !== 'global' || install.packageManager === null)
|
|
541
|
+
return null;
|
|
542
|
+
return GLOBAL_UPGRADE_COMMANDS[install.packageManager];
|
|
543
|
+
}
|
|
544
|
+
/** Builds the concise human-readable form of the version report. */
|
|
545
|
+
export function buildVersionReportLines(version, install, update, command = null) {
|
|
546
|
+
const details = [install.packageManager, install.scope].filter(Boolean).join(', ');
|
|
547
|
+
const lines = [`OpenSpec ${version}${details ? ` (${details})` : ''}`];
|
|
548
|
+
if (!update)
|
|
549
|
+
return lines;
|
|
550
|
+
if (update.status === 'available') {
|
|
551
|
+
lines.push(`Update available: ${update.latest}`);
|
|
552
|
+
if (command)
|
|
553
|
+
lines.push(` ${command}`);
|
|
554
|
+
}
|
|
555
|
+
else if (update.status === 'current') {
|
|
556
|
+
lines.push('OpenSpec is up to date.');
|
|
557
|
+
}
|
|
558
|
+
else if (update.status === 'disabled') {
|
|
559
|
+
lines.push('Update check disabled.');
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
lines.push('Could not check for updates.');
|
|
563
|
+
}
|
|
564
|
+
return lines;
|
|
565
|
+
}
|
|
480
566
|
/**
|
|
481
567
|
* Builds the hint, with the upgrade command chosen for how this copy of the CLI
|
|
482
568
|
* was installed. Pure so every branch is assertable.
|