@fission-ai/openspec 1.14.3 → 1.15.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/README.md +13 -9
- package/dist/cli/index.js +47 -0
- package/dist/commands/feedback.js +33 -20
- package/dist/commands/install-standalone.d.ts +27 -0
- package/dist/commands/install-standalone.js +150 -0
- package/dist/commands/schema.js +10 -3
- package/dist/commands/validate.js +10 -3
- package/dist/commands/workflow/instructions.js +15 -4
- package/dist/commands/workflow/new-change.js +12 -4
- package/dist/commands/workflow/status.js +5 -2
- package/dist/commands/workflow/templates.js +5 -2
- package/dist/core/artifact-graph/resolver.d.ts +6 -0
- package/dist/core/artifact-graph/resolver.js +11 -0
- package/dist/core/available-tools.d.ts +2 -1
- package/dist/core/available-tools.js +8 -28
- package/dist/core/completion-tip.d.ts +23 -0
- package/dist/core/completion-tip.js +4 -4
- package/dist/core/completions/command-registry.js +15 -0
- package/dist/core/config-schema.d.ts +1 -0
- package/dist/core/config-schema.js +1 -0
- package/dist/core/file-view.d.ts +13 -0
- package/dist/core/file-view.js +14 -0
- package/dist/core/github-copilot/cloud-agent.d.ts +10 -2
- package/dist/core/github-copilot/cloud-agent.js +13 -9
- package/dist/core/global-config.d.ts +4 -0
- package/dist/core/global-config.js +5 -1
- package/dist/core/legacy-cleanup.d.ts +11 -1
- package/dist/core/legacy-cleanup.js +15 -18
- package/dist/core/migration.d.ts +27 -1
- package/dist/core/migration.js +77 -29
- package/dist/core/profile-sync-drift.d.ts +4 -3
- package/dist/core/profile-sync-drift.js +19 -16
- package/dist/core/shared/tool-detection.d.ts +12 -4
- package/dist/core/shared/tool-detection.js +29 -27
- package/dist/core/shared-skill-target.d.ts +12 -5
- package/dist/core/shared-skill-target.js +34 -23
- package/dist/core/standalone-tip.d.ts +6 -0
- package/dist/core/standalone-tip.js +38 -0
- package/dist/core/templates/workflows/verify-change.js +4 -4
- package/dist/core/update-plan.d.ts +127 -0
- package/dist/core/update-plan.js +390 -0
- package/dist/core/update.d.ts +45 -1
- package/dist/core/update.js +279 -137
- package/dist/core/version-check.d.ts +8 -1
- package/dist/core/version-check.js +48 -5
- package/dist/utils/executable-path.d.ts +5 -0
- package/dist/utils/executable-path.js +44 -0
- package/package.json +4 -3
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type AIToolOption } from './config.js';
|
|
2
|
+
import { type FileView } from './file-view.js';
|
|
2
3
|
/** Reads a valid-looking marker value without letting linked roots escape. */
|
|
3
|
-
export declare function readSharedSkillTarget(projectPath: string, skillsDir: string): string | undefined;
|
|
4
|
+
export declare function readSharedSkillTarget(projectPath: string, skillsDir: string, view?: FileView): string | undefined;
|
|
4
5
|
/** Whether a tool still has an allowlisted managed skill under an old root. */
|
|
5
|
-
export declare function hasLegacySkills(projectPath: string, tool: AIToolOption): boolean;
|
|
6
|
+
export declare function hasLegacySkills(projectPath: string, tool: AIToolOption, view?: FileView): boolean;
|
|
6
7
|
/**
|
|
7
8
|
* Chooses the one selected tool that may render each physical skills root.
|
|
8
9
|
* Command files are not part of this arbitration: tools that share a skills
|
|
@@ -13,18 +14,18 @@ export declare function hasLegacySkills(projectPath: string, tool: AIToolOption)
|
|
|
13
14
|
* usable by every consumer of the shared tree. Codex is the best fresh owner
|
|
14
15
|
* because its renderer includes both Codex and generic skill invocation forms.
|
|
15
16
|
*/
|
|
16
|
-
export declare function resolveSharedSkillWriters(projectPath: string, tools: AIToolOption[]): Set<string>;
|
|
17
|
+
export declare function resolveSharedSkillWriters(projectPath: string, tools: AIToolOption[], view?: FileView): Set<string>;
|
|
17
18
|
/**
|
|
18
19
|
* A shared skill root can only hold one rendered variant of each skill.
|
|
19
20
|
* Keep the writer recorded so later updates do not infer every tool that
|
|
20
21
|
* happens to use the same directory.
|
|
21
22
|
*/
|
|
22
|
-
export declare function reconcileSharedSkillTargets(projectPath: string, tools: AIToolOption[]): AIToolOption[];
|
|
23
|
+
export declare function reconcileSharedSkillTargets(projectPath: string, tools: AIToolOption[], view?: FileView): AIToolOption[];
|
|
23
24
|
/**
|
|
24
25
|
* Returns whether a tool is the active writer for its physical skills root.
|
|
25
26
|
* Non-shared roots are always active.
|
|
26
27
|
*/
|
|
27
|
-
export declare function isSharedSkillTargetActive(projectPath: string, toolId: string): boolean;
|
|
28
|
+
export declare function isSharedSkillTargetActive(projectPath: string, toolId: string, view?: FileView): boolean;
|
|
28
29
|
/**
|
|
29
30
|
* The tool that already owns `toolId`'s shared skills root, when a DIFFERENT
|
|
30
31
|
* one does. Returns the owner's tool id only when the root already carries an
|
|
@@ -40,5 +41,11 @@ export declare function sharedSkillRootOwner(projectPath: string, toolId: string
|
|
|
40
41
|
* writing skills. See {@link sharedSkillRootOwner} for the ownership rules.
|
|
41
42
|
*/
|
|
42
43
|
export declare function sharedSkillRootOwnedByOther(projectPath: string, toolId: string): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Path of the owner marker for a tool's shared skills root, or null when the
|
|
46
|
+
* tool has no shared root to mark. Lets a caller write the marker itself —
|
|
47
|
+
* `update` routes it through its write seam so a dry run can list it.
|
|
48
|
+
*/
|
|
49
|
+
export declare function sharedSkillTargetMarkerPath(projectPath: string, toolId: string): string | null;
|
|
43
50
|
export declare function writeSharedSkillTarget(projectPath: string, toolId: string): void;
|
|
44
51
|
//# sourceMappingURL=shared-skill-target.d.ts.map
|
|
@@ -3,31 +3,32 @@ import * as fs from 'fs';
|
|
|
3
3
|
import { AI_TOOLS, OPENSPEC_SKILL_NAMES } from './config.js';
|
|
4
4
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
5
5
|
import { resolveCommandSurfaceCapability } from './command-surface.js';
|
|
6
|
+
import { DISK_VIEW } from './file-view.js';
|
|
6
7
|
const TARGET_MARKER = '.openspec-target';
|
|
7
8
|
/** Returns the ownership-marker path for one shared skills root. */
|
|
8
9
|
function markerPath(projectPath, skillsDir) {
|
|
9
10
|
return path.join(projectPath, skillsDir, 'skills', TARGET_MARKER);
|
|
10
11
|
}
|
|
11
12
|
/** Reads a valid-looking marker value without letting linked roots escape. */
|
|
12
|
-
export function readSharedSkillTarget(projectPath, skillsDir) {
|
|
13
|
+
export function readSharedSkillTarget(projectPath, skillsDir, view = DISK_VIEW) {
|
|
13
14
|
try {
|
|
14
15
|
const target = markerPath(projectPath, skillsDir);
|
|
15
16
|
FileSystemUtils.assertProjectArtifactPath(projectPath, target);
|
|
16
|
-
return
|
|
17
|
+
return view.read(target).trim() || undefined;
|
|
17
18
|
}
|
|
18
19
|
catch {
|
|
19
20
|
return undefined;
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
23
|
/** Whether a tool still has an allowlisted managed skill under an old root. */
|
|
23
|
-
export function hasLegacySkills(projectPath, tool) {
|
|
24
|
+
export function hasLegacySkills(projectPath, tool, view = DISK_VIEW) {
|
|
24
25
|
return (tool.legacySkillsDirs ?? []).some((root) => {
|
|
25
26
|
const skillsDir = path.join(projectPath, root, 'skills');
|
|
26
27
|
return OPENSPEC_SKILL_NAMES.some((skillName) => {
|
|
27
28
|
try {
|
|
28
29
|
const skillFile = path.join(skillsDir, skillName, 'SKILL.md');
|
|
29
30
|
FileSystemUtils.assertProjectArtifactPath(projectPath, skillFile);
|
|
30
|
-
return
|
|
31
|
+
return view.exists(skillFile);
|
|
31
32
|
}
|
|
32
33
|
catch {
|
|
33
34
|
return false;
|
|
@@ -39,13 +40,13 @@ export function hasLegacySkills(projectPath, tool) {
|
|
|
39
40
|
* Infers pre-marker ownership from generated invocation syntax. This preserves
|
|
40
41
|
* both existing generic `.agents` trees and Codex trees users moved manually.
|
|
41
42
|
*/
|
|
42
|
-
function inferSharedSkillTarget(projectPath, skillsDir) {
|
|
43
|
+
function inferSharedSkillTarget(projectPath, skillsDir, view = DISK_VIEW) {
|
|
43
44
|
let foundGenericReference = false;
|
|
44
45
|
for (const skillName of OPENSPEC_SKILL_NAMES) {
|
|
45
46
|
const skillFile = path.join(projectPath, skillsDir, 'skills', skillName, 'SKILL.md');
|
|
46
47
|
try {
|
|
47
48
|
FileSystemUtils.assertProjectArtifactPath(projectPath, skillFile);
|
|
48
|
-
const content =
|
|
49
|
+
const content = view.read(skillFile);
|
|
49
50
|
if (content.includes('$openspec-'))
|
|
50
51
|
return 'codex';
|
|
51
52
|
if (content.includes('/openspec-'))
|
|
@@ -58,12 +59,12 @@ function inferSharedSkillTarget(projectPath, skillsDir) {
|
|
|
58
59
|
return foundGenericReference ? 'agents' : undefined;
|
|
59
60
|
}
|
|
60
61
|
/** Whether the canonical shared root already contains an OpenSpec skill. */
|
|
61
|
-
function hasCurrentSkills(projectPath, skillsDir) {
|
|
62
|
+
function hasCurrentSkills(projectPath, skillsDir, view = DISK_VIEW) {
|
|
62
63
|
return OPENSPEC_SKILL_NAMES.some((skillName) => {
|
|
63
64
|
const skillFile = path.join(projectPath, skillsDir, 'skills', skillName, 'SKILL.md');
|
|
64
65
|
try {
|
|
65
66
|
FileSystemUtils.assertProjectArtifactPath(projectPath, skillFile);
|
|
66
|
-
return
|
|
67
|
+
return view.exists(skillFile);
|
|
67
68
|
}
|
|
68
69
|
catch {
|
|
69
70
|
return false;
|
|
@@ -80,7 +81,7 @@ function hasCurrentSkills(projectPath, skillsDir) {
|
|
|
80
81
|
* usable by every consumer of the shared tree. Codex is the best fresh owner
|
|
81
82
|
* because its renderer includes both Codex and generic skill invocation forms.
|
|
82
83
|
*/
|
|
83
|
-
export function resolveSharedSkillWriters(projectPath, tools) {
|
|
84
|
+
export function resolveSharedSkillWriters(projectPath, tools, view = DISK_VIEW) {
|
|
84
85
|
const byRoot = new Map();
|
|
85
86
|
const writers = new Set();
|
|
86
87
|
for (const tool of tools) {
|
|
@@ -98,11 +99,11 @@ export function resolveSharedSkillWriters(projectPath, tools) {
|
|
|
98
99
|
}
|
|
99
100
|
const skillsNative = group.filter((tool) => resolveCommandSurfaceCapability(tool.value) !== 'adapter-backed');
|
|
100
101
|
const preferredPool = skillsNative.length > 0 ? skillsNative : group;
|
|
101
|
-
const marked = readSharedSkillTarget(projectPath, root);
|
|
102
|
-
const inferred = inferSharedSkillTarget(projectPath, root);
|
|
102
|
+
const marked = readSharedSkillTarget(projectPath, root, view);
|
|
103
|
+
const inferred = inferSharedSkillTarget(projectPath, root, view);
|
|
103
104
|
const owner = preferredPool.find((tool) => tool.value === marked) ??
|
|
104
105
|
preferredPool.find((tool) => tool.value === inferred) ??
|
|
105
|
-
(hasCurrentSkills(projectPath, root)
|
|
106
|
+
(hasCurrentSkills(projectPath, root, view)
|
|
106
107
|
? preferredPool.find((tool) => tool.value === 'agents')
|
|
107
108
|
: undefined) ??
|
|
108
109
|
preferredPool.find((tool) => tool.value === 'codex') ??
|
|
@@ -116,7 +117,7 @@ export function resolveSharedSkillWriters(projectPath, tools) {
|
|
|
116
117
|
* Keep the writer recorded so later updates do not infer every tool that
|
|
117
118
|
* happens to use the same directory.
|
|
118
119
|
*/
|
|
119
|
-
export function reconcileSharedSkillTargets(projectPath, tools) {
|
|
120
|
+
export function reconcileSharedSkillTargets(projectPath, tools, view = DISK_VIEW) {
|
|
120
121
|
const byRoot = new Map();
|
|
121
122
|
for (const tool of tools) {
|
|
122
123
|
if (!tool.skillsDir)
|
|
@@ -132,14 +133,14 @@ export function reconcileSharedSkillTargets(projectPath, tools) {
|
|
|
132
133
|
continue;
|
|
133
134
|
}
|
|
134
135
|
const root = group[0].skillsDir;
|
|
135
|
-
const marked = readSharedSkillTarget(projectPath, root);
|
|
136
|
+
const marked = readSharedSkillTarget(projectPath, root, view);
|
|
136
137
|
const markedTool = group.find((tool) => tool.value === marked);
|
|
137
138
|
if (markedTool) {
|
|
138
139
|
reconciled.push(markedTool);
|
|
139
140
|
continue;
|
|
140
141
|
}
|
|
141
|
-
const inferred = inferSharedSkillTarget(projectPath, root);
|
|
142
|
-
const legacyCodex = group.find((tool) => tool.value === 'codex' && hasLegacySkills(projectPath, tool));
|
|
142
|
+
const inferred = inferSharedSkillTarget(projectPath, root, view);
|
|
143
|
+
const legacyCodex = group.find((tool) => tool.value === 'codex' && hasLegacySkills(projectPath, tool, view));
|
|
143
144
|
if (inferred === 'agents' && legacyCodex) {
|
|
144
145
|
// Before ownership markers existed, selecting both targets produced a
|
|
145
146
|
// generic canonical tree plus a Codex-only legacy tree. Codex now emits
|
|
@@ -154,11 +155,11 @@ export function reconcileSharedSkillTargets(projectPath, tools) {
|
|
|
154
155
|
}
|
|
155
156
|
// An unmarked canonical tree predates Codex's move into `.agents`; keep
|
|
156
157
|
// that established agents target instead of overwriting it from `.codex`.
|
|
157
|
-
if (hasCurrentSkills(projectPath, root)) {
|
|
158
|
+
if (hasCurrentSkills(projectPath, root, view)) {
|
|
158
159
|
reconciled.push(group.find((tool) => tool.value === 'agents') ?? group[0]);
|
|
159
160
|
continue;
|
|
160
161
|
}
|
|
161
|
-
const legacyTool = group.find((tool) => hasLegacySkills(projectPath, tool));
|
|
162
|
+
const legacyTool = group.find((tool) => hasLegacySkills(projectPath, tool, view));
|
|
162
163
|
if (legacyTool) {
|
|
163
164
|
reconciled.push(legacyTool);
|
|
164
165
|
continue;
|
|
@@ -173,14 +174,14 @@ export function reconcileSharedSkillTargets(projectPath, tools) {
|
|
|
173
174
|
* Returns whether a tool is the active writer for its physical skills root.
|
|
174
175
|
* Non-shared roots are always active.
|
|
175
176
|
*/
|
|
176
|
-
export function isSharedSkillTargetActive(projectPath, toolId) {
|
|
177
|
+
export function isSharedSkillTargetActive(projectPath, toolId, view = DISK_VIEW) {
|
|
177
178
|
const tool = AI_TOOLS.find((candidate) => candidate.value === toolId);
|
|
178
179
|
if (!tool?.skillsDir)
|
|
179
180
|
return false;
|
|
180
181
|
const sharingRoot = AI_TOOLS.filter((candidate) => candidate.skillsDir === tool.skillsDir);
|
|
181
182
|
if (sharingRoot.length < 2)
|
|
182
183
|
return true;
|
|
183
|
-
return reconcileSharedSkillTargets(projectPath, sharingRoot)
|
|
184
|
+
return reconcileSharedSkillTargets(projectPath, sharingRoot, view)
|
|
184
185
|
.some((candidate) => candidate.value === toolId);
|
|
185
186
|
}
|
|
186
187
|
/**
|
|
@@ -213,14 +214,24 @@ export function sharedSkillRootOwner(projectPath, toolId) {
|
|
|
213
214
|
export function sharedSkillRootOwnedByOther(projectPath, toolId) {
|
|
214
215
|
return sharedSkillRootOwner(projectPath, toolId) !== undefined;
|
|
215
216
|
}
|
|
216
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Path of the owner marker for a tool's shared skills root, or null when the
|
|
219
|
+
* tool has no shared root to mark. Lets a caller write the marker itself —
|
|
220
|
+
* `update` routes it through its write seam so a dry run can list it.
|
|
221
|
+
*/
|
|
222
|
+
export function sharedSkillTargetMarkerPath(projectPath, toolId) {
|
|
217
223
|
const tool = AI_TOOLS.find((candidate) => candidate.value === toolId);
|
|
218
224
|
if (!tool?.skillsDir)
|
|
219
|
-
return;
|
|
225
|
+
return null;
|
|
220
226
|
const sharingRoot = AI_TOOLS.filter((candidate) => candidate.skillsDir === tool.skillsDir);
|
|
221
227
|
if (sharingRoot.length < 2)
|
|
228
|
+
return null;
|
|
229
|
+
return markerPath(projectPath, tool.skillsDir);
|
|
230
|
+
}
|
|
231
|
+
export function writeSharedSkillTarget(projectPath, toolId) {
|
|
232
|
+
const target = sharedSkillTargetMarkerPath(projectPath, toolId);
|
|
233
|
+
if (!target)
|
|
222
234
|
return;
|
|
223
|
-
const target = markerPath(projectPath, tool.skillsDir);
|
|
224
235
|
FileSystemUtils.assertProjectArtifactPath(projectPath, target);
|
|
225
236
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
226
237
|
fs.writeFileSync(target, `${toolId}\n`, 'utf-8');
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const STANDALONE_TIP_MESSAGE = "Tip: OpenSpec also comes as a standalone app that runs without Node.js. To switch, run 'openspec install'.";
|
|
2
|
+
/** Never throws: a hint must not break a command. */
|
|
3
|
+
export declare function maybeShowStandaloneTip(options?: {
|
|
4
|
+
silent?: boolean;
|
|
5
|
+
}): Promise<void>;
|
|
6
|
+
//# sourceMappingURL=standalone-tip.d.ts.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time note telling package-manager users that the standalone install
|
|
3
|
+
* exists. It only offers: people who chose npm may have a reason, so it is
|
|
4
|
+
* shown once, never repeated, and never changes anything.
|
|
5
|
+
*
|
|
6
|
+
* Shown on stderr after `openspec update` (the moment people think about
|
|
7
|
+
* versions), and only for a global package-manager install. Deferred, not
|
|
8
|
+
* consumed, when nobody would read it: stderr that is not a terminal (agents,
|
|
9
|
+
* pipes) and CI.
|
|
10
|
+
*/
|
|
11
|
+
import { isCiEnvironment } from '../utils/ci.js';
|
|
12
|
+
import { markConfigFlag, readRawConfig } from './completion-tip.js';
|
|
13
|
+
import { getCliInstallInfo, getStandaloneBinaryPath } from './version-check.js';
|
|
14
|
+
export const STANDALONE_TIP_MESSAGE = "Tip: OpenSpec also comes as a standalone app that runs without Node.js. To switch, run 'openspec install'.";
|
|
15
|
+
/** Never throws: a hint must not break a command. */
|
|
16
|
+
export async function maybeShowStandaloneTip(options = {}) {
|
|
17
|
+
if (isCiEnvironment() || getStandaloneBinaryPath()) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const raw = readRawConfig();
|
|
22
|
+
if (raw === null || raw.standaloneTipSeen === true || options.silent) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
// Project dependencies, npx runs and source checkouts have nothing to switch.
|
|
26
|
+
if (getCliInstallInfo().scope !== 'global') {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
// Record before printing: if the flag cannot be saved, staying quiet beats
|
|
30
|
+
// repeating the note on every run.
|
|
31
|
+
await markConfigFlag('standaloneTipSeen');
|
|
32
|
+
console.error(`\n${STANDALONE_TIP_MESSAGE}`);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Silent failure - a hint should never break the CLI.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=standalone-tip.js.map
|
|
@@ -87,8 +87,8 @@ ${PROJECT_ROOT_GUARD}
|
|
|
87
87
|
- Add CRITICAL issue: "Requirement not found: <requirement name>"
|
|
88
88
|
- Recommendation: "Implement requirement X: <description>"
|
|
89
89
|
- For each REMOVED requirement, the change asks for the behavior to be gone, so invert the check:
|
|
90
|
-
- A REMOVED block carries only the requirement name and any \`**Reason**\`/\`**Migration**\` notes, so the delta does not describe the behavior itself. Before searching, read the baseline requirement in the main spec at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\`, using the same capability path as the delta spec. Match it by normalized name,
|
|
91
|
-
- If that file holds no
|
|
90
|
+
- A REMOVED block carries only the requirement name and any \`**Reason**\`/\`**Migration**\` notes, so the delta does not describe the behavior itself. Before searching, read the baseline requirement in the main spec at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\`, using the same capability path as the delta spec. Match it by exact normalized name, the rule OpenSpec uses when it applies REMOVED blocks: the heading text after \`Requirement:\`, trimmed, with a closing \`#\` run dropped only when a space or tab precedes it (so \`C#\` keeps its \`#\`), compared case-sensitively. A similarly worded heading is not a match; a rename belongs under RENAMED. Its body and scenarios are the evidence for the behavior that must be gone; never search from the requirement name alone.
|
|
91
|
+
- If that file holds no requirement with that exact normalized name - for example because an earlier sync already applied the removal, or a similarly named ADDED requirement replaced it - recover the baseline from the same file's history, in the planning repository rather than the current directory: \`git -C "<planningHome.root>" log -p -- openspec/specs/<capability-path>/spec.md\`, or \`git -C "<planningHome.root>" show <commit>^:./openspec/specs/<capability-path>/spec.md\` once you have identified the commit that applied the removal. Empty output means the pathspec matched nothing, not that there is no baseline: \`git log\` reports an unmatched path as success, so re-check the path before drawing any conclusion from silence.
|
|
92
92
|
- A baseline that adds nothing to its own title - no body beyond a restatement of the name, no scenarios - is unusable evidence, the same as a missing one. If no usable baseline is available in either place, mark **Spec Coverage** as not verified for that entry with the reason and stop there: do not search, and never count an unsearched removal as passing.
|
|
93
93
|
- Search codebase for that behavior. Matches in \`openspec/\` artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, are not evidence by themselves. Report any code path that still delivers the removed behavior, including one shared with an ADDED requirement.
|
|
94
94
|
- Finding no implementation is the expected result, but only once the baseline behavior was the thing you searched for. Never report a REMOVED requirement as "Requirement not found" or recommend implementing it.
|
|
@@ -299,8 +299,8 @@ ${PROJECT_ROOT_GUARD}
|
|
|
299
299
|
- Add CRITICAL issue: "Requirement not found: <requirement name>"
|
|
300
300
|
- Recommendation: "Implement requirement X: <description>"
|
|
301
301
|
- For each REMOVED requirement, the change asks for the behavior to be gone, so invert the check:
|
|
302
|
-
- A REMOVED block carries only the requirement name and any \`**Reason**\`/\`**Migration**\` notes, so the delta does not describe the behavior itself. Before searching, read the baseline requirement in the main spec at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\`, using the same capability path as the delta spec. Match it by normalized name,
|
|
303
|
-
- If that file holds no
|
|
302
|
+
- A REMOVED block carries only the requirement name and any \`**Reason**\`/\`**Migration**\` notes, so the delta does not describe the behavior itself. Before searching, read the baseline requirement in the main spec at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\`, using the same capability path as the delta spec. Match it by exact normalized name, the rule OpenSpec uses when it applies REMOVED blocks: the heading text after \`Requirement:\`, trimmed, with a closing \`#\` run dropped only when a space or tab precedes it (so \`C#\` keeps its \`#\`), compared case-sensitively. A similarly worded heading is not a match; a rename belongs under RENAMED. Its body and scenarios are the evidence for the behavior that must be gone; never search from the requirement name alone.
|
|
303
|
+
- If that file holds no requirement with that exact normalized name - for example because an earlier sync already applied the removal, or a similarly named ADDED requirement replaced it - recover the baseline from the same file's history, in the planning repository rather than the current directory: \`git -C "<planningHome.root>" log -p -- openspec/specs/<capability-path>/spec.md\`, or \`git -C "<planningHome.root>" show <commit>^:./openspec/specs/<capability-path>/spec.md\` once you have identified the commit that applied the removal. Empty output means the pathspec matched nothing, not that there is no baseline: \`git log\` reports an unmatched path as success, so re-check the path before drawing any conclusion from silence.
|
|
304
304
|
- A baseline that adds nothing to its own title - no body beyond a restatement of the name, no scenarios - is unusable evidence, the same as a missing one. If no usable baseline is available in either place, mark **Spec Coverage** as not verified for that entry with the reason and stop there: do not search, and never count an unsearched removal as passing.
|
|
305
305
|
- Search codebase for that behavior. Matches in \`openspec/\` artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, are not evidence by themselves. Report any code path that still delivers the removed behavior, including one shared with an ADDED requirement.
|
|
306
306
|
- Finding no implementation is the expected result, but only once the baseline behavior was the thing you searched for. Never report a REMOVED requirement as "Requirement not found" or recommend implementing it.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update Plan
|
|
3
|
+
*
|
|
4
|
+
* The write seam `update` uses for every file it generates or removes.
|
|
5
|
+
*
|
|
6
|
+
* A real run forwards to the filesystem. A dry run records what the same call
|
|
7
|
+
* would have done and writes nothing, so the preview and the real update come
|
|
8
|
+
* from one code path instead of two descriptions of it.
|
|
9
|
+
*/
|
|
10
|
+
import type { FileView } from './file-view.js';
|
|
11
|
+
export type UpdatePlanAction = 'add' | 'update' | 'delete';
|
|
12
|
+
export interface UpdatePlanChange {
|
|
13
|
+
/** Project-relative POSIX path, or an absolute path (home collapsed to `~`) for files outside the project. */
|
|
14
|
+
path: string;
|
|
15
|
+
action: UpdatePlanAction;
|
|
16
|
+
/**
|
|
17
|
+
* Tool the file belongs to, as configured (e.g. `claude`), or `openspec`
|
|
18
|
+
* for OpenSpec's own files: the global config and legacy-artifact cleanup.
|
|
19
|
+
*/
|
|
20
|
+
tool: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* What the plan cannot settle by itself: a step a terminal user is asked
|
|
24
|
+
* about, which the plan lists as a non-interactive run makes it. Stable
|
|
25
|
+
* identifiers, for a caller that branches on them.
|
|
26
|
+
*/
|
|
27
|
+
export type UpdatePlanNoteCode = 'legacy-tool-directories' | 'legacy-artifacts';
|
|
28
|
+
export interface UpdatePlanNote {
|
|
29
|
+
code: UpdatePlanNoteCode;
|
|
30
|
+
message: string;
|
|
31
|
+
}
|
|
32
|
+
export interface UpdatePlanFailure {
|
|
33
|
+
tool: string;
|
|
34
|
+
error: string;
|
|
35
|
+
}
|
|
36
|
+
export interface UpdatePlan {
|
|
37
|
+
changes: UpdatePlanChange[];
|
|
38
|
+
/**
|
|
39
|
+
* Steps a terminal user would be asked about. `changes` lists them as a
|
|
40
|
+
* non-interactive run makes them. Empty for an ordinary refresh.
|
|
41
|
+
*/
|
|
42
|
+
notes: UpdatePlanNote[];
|
|
43
|
+
/**
|
|
44
|
+
* Tools the real update would fail on. Reported rather than thrown: one
|
|
45
|
+
* broken tool must not cost the caller the rest of the plan.
|
|
46
|
+
*/
|
|
47
|
+
failures: UpdatePlanFailure[];
|
|
48
|
+
}
|
|
49
|
+
/** Every filesystem mutation `update` makes while generating artifacts. */
|
|
50
|
+
export interface UpdateFileOps {
|
|
51
|
+
writeFile(filePath: string, content: string, tool: string): Promise<void>;
|
|
52
|
+
removeFile(filePath: string, tool: string): Promise<void>;
|
|
53
|
+
removeDirectory(dirPath: string, tool: string): Promise<void>;
|
|
54
|
+
/** Removes a directory already known to be empty. */
|
|
55
|
+
removeEmptyDirectory(dirPath: string): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
export declare const realFileOps: UpdateFileOps;
|
|
58
|
+
export declare class UpdatePlanRecorder implements UpdateFileOps {
|
|
59
|
+
private readonly projectPath;
|
|
60
|
+
private readonly notes;
|
|
61
|
+
private readonly failures;
|
|
62
|
+
/**
|
|
63
|
+
* The tree as the real update would have it so far: absolute path to planned
|
|
64
|
+
* content, a moved directory, or null once planned away (a removed directory
|
|
65
|
+
* takes everything under it). Steps that read what an earlier step left
|
|
66
|
+
* behind read this through `view`.
|
|
67
|
+
*/
|
|
68
|
+
private readonly staged;
|
|
69
|
+
/** Every file path the plan has touched, with the tool that touched it last. */
|
|
70
|
+
private readonly touched;
|
|
71
|
+
constructor(projectPath: string);
|
|
72
|
+
writeFile(filePath: string, content: string, tool: string): Promise<void>;
|
|
73
|
+
removeFile(filePath: string, tool: string): Promise<void>;
|
|
74
|
+
removeEmptyDirectory(dirPath: string): Promise<void>;
|
|
75
|
+
removeDirectory(dirPath: string, tool: string): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* A rename, as the legacy-root migration makes it: whatever the entry is
|
|
78
|
+
* moves, a directory with everything in it.
|
|
79
|
+
*/
|
|
80
|
+
moveFile(source: string, destination: string, tool: string): Promise<void>;
|
|
81
|
+
/**
|
|
82
|
+
* The tree with every planned write, move and removal applied. A path the
|
|
83
|
+
* plan has not touched answers exactly as the disk does.
|
|
84
|
+
*/
|
|
85
|
+
readonly view: FileView;
|
|
86
|
+
addNote(code: UpdatePlanNoteCode, message: string): void;
|
|
87
|
+
addFailure(tool: string, error: string): void;
|
|
88
|
+
/**
|
|
89
|
+
* Each touched path, compared as it is on disk now with how the plan leaves
|
|
90
|
+
* it. A file written and then removed, or rewritten with the bytes it
|
|
91
|
+
* already had, is no change at all.
|
|
92
|
+
*/
|
|
93
|
+
getPlan(): UpdatePlan;
|
|
94
|
+
private stage;
|
|
95
|
+
/**
|
|
96
|
+
* What the plan has put at a path: content, a moved directory, null once
|
|
97
|
+
* planned away, or undefined when the plan has not touched it.
|
|
98
|
+
*/
|
|
99
|
+
private stagedEntry;
|
|
100
|
+
/** Whether the plan has put any file or directory below `directory`. */
|
|
101
|
+
private hasStagedDescendant;
|
|
102
|
+
/** Files under a directory in the tree as planned: those on disk, and those the plan added. */
|
|
103
|
+
private filesUnder;
|
|
104
|
+
/**
|
|
105
|
+
* What is at a path in the tree as planned, and whether that is what is on
|
|
106
|
+
* disk (so its permissions are real).
|
|
107
|
+
*/
|
|
108
|
+
private entryKind;
|
|
109
|
+
/**
|
|
110
|
+
* Fails the way the real write would, without writing: a directory where the
|
|
111
|
+
* file belongs, a file where one of its directories belongs, or no
|
|
112
|
+
* permission. Recording the write instead would plan past the point where
|
|
113
|
+
* the real update stops for this tool, listing files it never writes.
|
|
114
|
+
*/
|
|
115
|
+
private probeWrite;
|
|
116
|
+
/** Fails the way unlinking the file would. */
|
|
117
|
+
private probeRemove;
|
|
118
|
+
/**
|
|
119
|
+
* The nearest existing ancestor has to be a directory the update can create
|
|
120
|
+
* entries in; anything it would create below that starts out empty.
|
|
121
|
+
*/
|
|
122
|
+
private probeCreatableIn;
|
|
123
|
+
private toDisplayPath;
|
|
124
|
+
}
|
|
125
|
+
/** Human-readable rendering of a dry-run plan. */
|
|
126
|
+
export declare function formatUpdatePlan(plan: UpdatePlan): string;
|
|
127
|
+
//# sourceMappingURL=update-plan.d.ts.map
|