@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.
Files changed (48) hide show
  1. package/README.md +13 -9
  2. package/dist/cli/index.js +47 -0
  3. package/dist/commands/feedback.js +33 -20
  4. package/dist/commands/install-standalone.d.ts +27 -0
  5. package/dist/commands/install-standalone.js +150 -0
  6. package/dist/commands/schema.js +10 -3
  7. package/dist/commands/validate.js +10 -3
  8. package/dist/commands/workflow/instructions.js +15 -4
  9. package/dist/commands/workflow/new-change.js +12 -4
  10. package/dist/commands/workflow/status.js +5 -2
  11. package/dist/commands/workflow/templates.js +5 -2
  12. package/dist/core/artifact-graph/resolver.d.ts +6 -0
  13. package/dist/core/artifact-graph/resolver.js +11 -0
  14. package/dist/core/available-tools.d.ts +2 -1
  15. package/dist/core/available-tools.js +8 -28
  16. package/dist/core/completion-tip.d.ts +23 -0
  17. package/dist/core/completion-tip.js +4 -4
  18. package/dist/core/completions/command-registry.js +15 -0
  19. package/dist/core/config-schema.d.ts +1 -0
  20. package/dist/core/config-schema.js +1 -0
  21. package/dist/core/file-view.d.ts +13 -0
  22. package/dist/core/file-view.js +14 -0
  23. package/dist/core/github-copilot/cloud-agent.d.ts +10 -2
  24. package/dist/core/github-copilot/cloud-agent.js +13 -9
  25. package/dist/core/global-config.d.ts +4 -0
  26. package/dist/core/global-config.js +5 -1
  27. package/dist/core/legacy-cleanup.d.ts +11 -1
  28. package/dist/core/legacy-cleanup.js +15 -18
  29. package/dist/core/migration.d.ts +27 -1
  30. package/dist/core/migration.js +77 -29
  31. package/dist/core/profile-sync-drift.d.ts +4 -3
  32. package/dist/core/profile-sync-drift.js +19 -16
  33. package/dist/core/shared/tool-detection.d.ts +12 -4
  34. package/dist/core/shared/tool-detection.js +29 -27
  35. package/dist/core/shared-skill-target.d.ts +12 -5
  36. package/dist/core/shared-skill-target.js +34 -23
  37. package/dist/core/standalone-tip.d.ts +6 -0
  38. package/dist/core/standalone-tip.js +38 -0
  39. package/dist/core/templates/workflows/verify-change.js +4 -4
  40. package/dist/core/update-plan.d.ts +127 -0
  41. package/dist/core/update-plan.js +390 -0
  42. package/dist/core/update.d.ts +45 -1
  43. package/dist/core/update.js +279 -137
  44. package/dist/core/version-check.d.ts +8 -1
  45. package/dist/core/version-check.js +48 -5
  46. package/dist/utils/executable-path.d.ts +5 -0
  47. package/dist/utils/executable-path.js +44 -0
  48. 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 fs.readFileSync(target, 'utf-8').trim() || undefined;
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 fs.existsSync(skillFile);
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 = fs.readFileSync(skillFile, 'utf-8');
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 fs.existsSync(skillFile);
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
- export function writeSharedSkillTarget(projectPath, toolId) {
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, scanning that file's requirement headings rather than string-matching the delta's exact title. 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 such requirement under this or a near-equivalent name - for example because an earlier sync already applied the removal - 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.
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, scanning that file's requirement headings rather than string-matching the delta's exact title. 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 such requirement under this or a near-equivalent name - for example because an earlier sync already applied the removal - 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.
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