@fission-ai/openspec 1.6.0 → 1.8.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 (208) hide show
  1. package/README.md +31 -2
  2. package/dist/cli/index.js +79 -8
  3. package/dist/commands/change.d.ts +0 -1
  4. package/dist/commands/change.js +105 -51
  5. package/dist/commands/config.js +14 -8
  6. package/dist/commands/doctor.js +9 -4
  7. package/dist/commands/feedback.js +74 -23
  8. package/dist/commands/schema.js +110 -31
  9. package/dist/commands/show.js +5 -2
  10. package/dist/commands/spec.js +49 -29
  11. package/dist/commands/validate.js +20 -5
  12. package/dist/commands/workflow/index.d.ts +1 -1
  13. package/dist/commands/workflow/index.js +1 -1
  14. package/dist/commands/workflow/instructions.d.ts +7 -1
  15. package/dist/commands/workflow/instructions.js +121 -33
  16. package/dist/commands/workflow/shared.d.ts +13 -2
  17. package/dist/commands/workflow/shared.js +34 -4
  18. package/dist/commands/workflow/status.js +9 -4
  19. package/dist/commands/workflow/templates.js +15 -5
  20. package/dist/core/archive.d.ts +14 -1
  21. package/dist/core/archive.js +1332 -155
  22. package/dist/core/artifact-graph/graph.d.ts +16 -0
  23. package/dist/core/artifact-graph/graph.js +30 -6
  24. package/dist/core/artifact-graph/index.d.ts +1 -1
  25. package/dist/core/artifact-graph/index.js +1 -1
  26. package/dist/core/artifact-graph/instruction-loader.d.ts +30 -3
  27. package/dist/core/artifact-graph/instruction-loader.js +70 -12
  28. package/dist/core/artifact-graph/outputs.d.ts +1 -0
  29. package/dist/core/artifact-graph/outputs.js +67 -5
  30. package/dist/core/artifact-graph/resolver.d.ts +14 -0
  31. package/dist/core/artifact-graph/resolver.js +69 -15
  32. package/dist/core/artifact-graph/types.js +21 -3
  33. package/dist/core/available-tools.d.ts +2 -2
  34. package/dist/core/available-tools.js +14 -3
  35. package/dist/core/change-metadata/schema.d.ts +2 -0
  36. package/dist/core/change-metadata/schema.js +13 -0
  37. package/dist/core/change-status-policy.d.ts +1 -1
  38. package/dist/core/change-status-policy.js +2 -2
  39. package/dist/core/command-generation/adapters/amazon-q.d.ts +4 -0
  40. package/dist/core/command-generation/adapters/amazon-q.js +7 -1
  41. package/dist/core/command-generation/adapters/antigravity.js +2 -1
  42. package/dist/core/command-generation/adapters/auggie.js +2 -1
  43. package/dist/core/command-generation/adapters/bob.d.ts +5 -1
  44. package/dist/core/command-generation/adapters/bob.js +6 -5
  45. package/dist/core/command-generation/adapters/claude.js +1 -8
  46. package/dist/core/command-generation/adapters/codebuddy.js +3 -2
  47. package/dist/core/command-generation/adapters/continue.js +3 -2
  48. package/dist/core/command-generation/adapters/costrict.js +2 -1
  49. package/dist/core/command-generation/adapters/crush.js +5 -5
  50. package/dist/core/command-generation/adapters/cursor.js +2 -2
  51. package/dist/core/command-generation/adapters/devin.d.ts +19 -0
  52. package/dist/core/command-generation/adapters/devin.js +36 -0
  53. package/dist/core/command-generation/adapters/factory.js +2 -1
  54. package/dist/core/command-generation/adapters/gemini.js +37 -2
  55. package/dist/core/command-generation/adapters/github-copilot.js +2 -1
  56. package/dist/core/command-generation/adapters/iflow.js +5 -4
  57. package/dist/core/command-generation/adapters/index.d.ts +2 -2
  58. package/dist/core/command-generation/adapters/index.js +2 -2
  59. package/dist/core/command-generation/adapters/junie.js +2 -1
  60. package/dist/core/command-generation/adapters/kiro.js +2 -1
  61. package/dist/core/command-generation/adapters/lingma.js +5 -5
  62. package/dist/core/command-generation/adapters/oh-my-pi.d.ts +2 -2
  63. package/dist/core/command-generation/adapters/oh-my-pi.js +3 -5
  64. package/dist/core/command-generation/adapters/opencode.js +3 -5
  65. package/dist/core/command-generation/adapters/pi.d.ts +2 -2
  66. package/dist/core/command-generation/adapters/pi.js +3 -6
  67. package/dist/core/command-generation/adapters/qoder.js +5 -5
  68. package/dist/core/command-generation/adapters/qwen.d.ts +7 -3
  69. package/dist/core/command-generation/adapters/qwen.js +12 -7
  70. package/dist/core/command-generation/adapters/roocode.d.ts +4 -4
  71. package/dist/core/command-generation/adapters/roocode.js +4 -4
  72. package/dist/core/command-generation/adapters/trae.js +1 -21
  73. package/dist/core/command-generation/adapters/zcode.d.ts +16 -0
  74. package/dist/core/command-generation/adapters/zcode.js +33 -0
  75. package/dist/core/command-generation/generator.d.ts +8 -0
  76. package/dist/core/command-generation/generator.js +15 -1
  77. package/dist/core/command-generation/index.d.ts +1 -1
  78. package/dist/core/command-generation/index.js +1 -1
  79. package/dist/core/command-generation/invocation.d.ts +73 -0
  80. package/dist/core/command-generation/invocation.js +77 -0
  81. package/dist/core/command-generation/registry.js +4 -4
  82. package/dist/core/command-generation/types.d.ts +9 -2
  83. package/dist/core/command-generation/yaml.d.ts +20 -6
  84. package/dist/core/command-generation/yaml.js +31 -21
  85. package/dist/core/command-surface.d.ts +15 -0
  86. package/dist/core/command-surface.js +33 -0
  87. package/dist/core/completions/command-registry.js +16 -2
  88. package/dist/core/completions/generators/powershell-generator.js +15 -8
  89. package/dist/core/completions/installers/zsh-installer.d.ts +9 -0
  90. package/dist/core/completions/installers/zsh-installer.js +20 -4
  91. package/dist/core/config-prompts.d.ts +1 -1
  92. package/dist/core/config-prompts.js +14 -1
  93. package/dist/core/config-schema.d.ts +10 -1
  94. package/dist/core/config-schema.js +67 -1
  95. package/dist/core/config.d.ts +15 -0
  96. package/dist/core/config.js +45 -5
  97. package/dist/core/file-state.js +56 -24
  98. package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
  99. package/dist/core/github-copilot/cloud-agent.js +497 -0
  100. package/dist/core/global-config.d.ts +16 -0
  101. package/dist/core/init.d.ts +58 -0
  102. package/dist/core/init.js +450 -69
  103. package/dist/core/legacy-cleanup.d.ts +59 -0
  104. package/dist/core/legacy-cleanup.js +253 -24
  105. package/dist/core/list.js +5 -7
  106. package/dist/core/migration.d.ts +73 -1
  107. package/dist/core/migration.js +360 -11
  108. package/dist/core/onboarding-commands.d.ts +30 -0
  109. package/dist/core/onboarding-commands.js +38 -0
  110. package/dist/core/parsers/change-parser.d.ts +17 -2
  111. package/dist/core/parsers/change-parser.js +33 -20
  112. package/dist/core/parsers/code-fence.d.ts +15 -0
  113. package/dist/core/parsers/code-fence.js +49 -0
  114. package/dist/core/parsers/markdown-parser.js +2 -1
  115. package/dist/core/parsers/requirement-blocks.d.ts +18 -0
  116. package/dist/core/parsers/requirement-blocks.js +120 -35
  117. package/dist/core/parsers/requirement-text.d.ts +1 -8
  118. package/dist/core/parsers/requirement-text.js +4 -42
  119. package/dist/core/parsers/spec-structure.d.ts +1 -1
  120. package/dist/core/parsers/spec-structure.js +20 -30
  121. package/dist/core/profile-sync-drift.d.ts +0 -8
  122. package/dist/core/profile-sync-drift.js +48 -49
  123. package/dist/core/project-config.d.ts +28 -6
  124. package/dist/core/project-config.js +108 -8
  125. package/dist/core/references.js +42 -4
  126. package/dist/core/relationship-health.d.ts +8 -0
  127. package/dist/core/relationship-health.js +13 -0
  128. package/dist/core/root-selection.d.ts +9 -6
  129. package/dist/core/root-selection.js +42 -5
  130. package/dist/core/shared/index.d.ts +1 -0
  131. package/dist/core/shared/index.js +1 -0
  132. package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
  133. package/dist/core/shared/skill-content-equivalence.js +41 -0
  134. package/dist/core/shared/skill-paths.d.ts +13 -0
  135. package/dist/core/shared/skill-paths.js +23 -0
  136. package/dist/core/shared/tool-detection.d.ts +25 -5
  137. package/dist/core/shared/tool-detection.js +166 -36
  138. package/dist/core/shared-skill-target.d.ts +16 -0
  139. package/dist/core/shared-skill-target.js +156 -0
  140. package/dist/core/specs-apply.d.ts +80 -33
  141. package/dist/core/specs-apply.js +659 -171
  142. package/dist/core/store/git.d.ts +13 -0
  143. package/dist/core/store/git.js +24 -0
  144. package/dist/core/templates/skill-templates.d.ts +1 -1
  145. package/dist/core/templates/skill-templates.js +1 -1
  146. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  147. package/dist/core/templates/workflows/apply-change.js +48 -158
  148. package/dist/core/templates/workflows/archive-change.js +170 -42
  149. package/dist/core/templates/workflows/bulk-archive-change.js +256 -75
  150. package/dist/core/templates/workflows/continue-change.js +38 -46
  151. package/dist/core/templates/workflows/explore.js +54 -18
  152. package/dist/core/templates/workflows/ff-change.js +46 -30
  153. package/dist/core/templates/workflows/new-change.js +2 -2
  154. package/dist/core/templates/workflows/onboard.js +13 -7
  155. package/dist/core/templates/workflows/propose.js +116 -46
  156. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  157. package/dist/core/templates/workflows/store-selection.js +1 -1
  158. package/dist/core/templates/workflows/sync-specs.js +260 -32
  159. package/dist/core/templates/workflows/update-change.js +26 -16
  160. package/dist/core/templates/workflows/verify-change.js +16 -10
  161. package/dist/core/update.d.ts +40 -5
  162. package/dist/core/update.js +389 -93
  163. package/dist/core/validation/constants.d.ts +4 -1
  164. package/dist/core/validation/constants.js +4 -1
  165. package/dist/core/validation/validator.d.ts +22 -8
  166. package/dist/core/validation/validator.js +222 -50
  167. package/dist/core/version-check.d.ts +152 -0
  168. package/dist/core/version-check.js +731 -0
  169. package/dist/core/view.js +12 -17
  170. package/dist/prompts/searchable-multi-select.js +1 -1
  171. package/dist/telemetry/config.d.ts +3 -4
  172. package/dist/telemetry/index.d.ts +10 -5
  173. package/dist/telemetry/index.js +75 -41
  174. package/dist/ui/welcome-screen.d.ts +9 -1
  175. package/dist/ui/welcome-screen.js +91 -38
  176. package/dist/utils/change-metadata.d.ts +43 -0
  177. package/dist/utils/change-metadata.js +123 -7
  178. package/dist/utils/change-utils.d.ts +10 -4
  179. package/dist/utils/change-utils.js +20 -13
  180. package/dist/utils/ci.d.ts +12 -0
  181. package/dist/utils/ci.js +16 -0
  182. package/dist/utils/command-references.d.ts +92 -6
  183. package/dist/utils/command-references.js +180 -7
  184. package/dist/utils/date.d.ts +8 -0
  185. package/dist/utils/date.js +13 -0
  186. package/dist/utils/file-system.d.ts +10 -0
  187. package/dist/utils/file-system.js +70 -0
  188. package/dist/utils/index.d.ts +1 -1
  189. package/dist/utils/index.js +1 -1
  190. package/dist/utils/interactive.d.ts +23 -0
  191. package/dist/utils/interactive.js +32 -0
  192. package/dist/utils/item-discovery.d.ts +19 -0
  193. package/dist/utils/item-discovery.js +30 -48
  194. package/dist/utils/shell-detection.d.ts +1 -1
  195. package/dist/utils/shell-detection.js +64 -11
  196. package/dist/utils/spec-discovery.d.ts +40 -0
  197. package/dist/utils/spec-discovery.js +115 -0
  198. package/dist/utils/task-progress.d.ts +17 -0
  199. package/dist/utils/task-progress.js +41 -13
  200. package/package.json +14 -12
  201. package/schemas/spec-driven/schema.yaml +67 -10
  202. package/schemas/spec-driven/templates/design.md +2 -2
  203. package/schemas/spec-driven/templates/proposal.md +10 -4
  204. package/schemas/spec-driven/templates/spec.md +3 -0
  205. package/dist/core/command-generation/adapters/codex.d.ts +0 -16
  206. package/dist/core/command-generation/adapters/codex.js +0 -39
  207. package/dist/core/command-generation/adapters/windsurf.d.ts +0 -14
  208. package/dist/core/command-generation/adapters/windsurf.js +0 -38
@@ -87,6 +87,76 @@ export class FileSystemUtils {
87
87
  }
88
88
  }
89
89
  }
90
+ /**
91
+ * Refuses a target that leaves an allowed directory, including through an
92
+ * existing symlink in either the target or one of its parent directories.
93
+ * Missing suffixes are resolved from their nearest existing ancestor.
94
+ */
95
+ static assertPathWithin(allowedDirectory, targetPath) {
96
+ const resolvedDirectory = path.resolve(allowedDirectory);
97
+ const resolvedTarget = path.resolve(targetPath);
98
+ if (!this.isPathWithin(resolvedDirectory, resolvedTarget)) {
99
+ throw new Error(`Path is outside the allowed directory: ${targetPath}`);
100
+ }
101
+ const canonicalDirectory = this.canonicalizePotentialPath(resolvedDirectory);
102
+ const canonicalTarget = this.canonicalizePotentialPath(resolvedTarget);
103
+ if (!this.isPathWithin(canonicalDirectory, canonicalTarget)) {
104
+ throw new Error(`Path is outside the allowed directory: ${targetPath}`);
105
+ }
106
+ }
107
+ static resolveProjectArtifactPath(projectPath, artifactPath) {
108
+ if (path.isAbsolute(artifactPath)) {
109
+ throw new Error(`Refusing to manage an artifact outside the project: ${artifactPath}`);
110
+ }
111
+ const targetPath = path.join(projectPath, artifactPath);
112
+ this.assertPathWithin(projectPath, targetPath);
113
+ return targetPath;
114
+ }
115
+ static assertProjectArtifactPath(projectPath, targetPath) {
116
+ this.assertPathWithin(projectPath, targetPath);
117
+ }
118
+ static isPathWithin(allowedDirectory, targetPath) {
119
+ const relative = path.relative(allowedDirectory, targetPath);
120
+ return (relative === '' ||
121
+ (relative !== '..' &&
122
+ !relative.startsWith(`..${path.sep}`) &&
123
+ !path.isAbsolute(relative)));
124
+ }
125
+ static canonicalizePotentialPath(targetPath) {
126
+ let existingPath = targetPath;
127
+ const missingSegments = [];
128
+ while (true) {
129
+ try {
130
+ // lstat distinguishes a missing path from a dangling symlink. A
131
+ // dangling link cannot be proven confined, so realpath must fail it.
132
+ nodeFs.lstatSync(existingPath);
133
+ const canonicalExisting = nodeFs.realpathSync.native(existingPath);
134
+ return path.resolve(canonicalExisting, ...missingSegments);
135
+ }
136
+ catch (error) {
137
+ const code = error.code;
138
+ if (code !== 'ENOENT') {
139
+ throw error;
140
+ }
141
+ try {
142
+ if (nodeFs.lstatSync(existingPath).isSymbolicLink()) {
143
+ throw new Error(`Cannot verify dangling symbolic link: ${existingPath}`);
144
+ }
145
+ }
146
+ catch (lstatError) {
147
+ if (lstatError.code !== 'ENOENT') {
148
+ throw lstatError;
149
+ }
150
+ }
151
+ const parent = path.dirname(existingPath);
152
+ if (parent === existingPath) {
153
+ throw new Error(`Cannot resolve an existing parent for ${targetPath}`);
154
+ }
155
+ missingSegments.unshift(path.basename(existingPath));
156
+ existingPath = parent;
157
+ }
158
+ }
159
+ }
90
160
  static isWindowsBasePath(basePath) {
91
161
  return /^[A-Za-z]:[\\/]/.test(basePath) || basePath.startsWith('\\');
92
162
  }
@@ -2,5 +2,5 @@ export { validateChangeName, createChange } from './change-utils.js';
2
2
  export type { ValidationResult, CreateChangeOptions } from './change-utils.js';
3
3
  export { readChangeMetadata, writeChangeMetadata, resolveSchemaForChange, validateSchemaName, ChangeMetadataError, } from './change-metadata.js';
4
4
  export { FileSystemUtils, removeMarkerBlock } from './file-system.js';
5
- export { transformToHyphenCommands } from './command-references.js';
5
+ export { transformCommandInvocations, transformToSkillReferences, getSkillReferenceTransformer, getTransformerForTool, } from './command-references.js';
6
6
  //# sourceMappingURL=index.d.ts.map
@@ -5,5 +5,5 @@ export { readChangeMetadata, writeChangeMetadata, resolveSchemaForChange, valida
5
5
  // File system utilities
6
6
  export { FileSystemUtils, removeMarkerBlock } from './file-system.js';
7
7
  // Command reference utilities
8
- export { transformToHyphenCommands } from './command-references.js';
8
+ export { transformCommandInvocations, transformToSkillReferences, getSkillReferenceTransformer, getTransformerForTool, } from './command-references.js';
9
9
  //# sourceMappingURL=index.js.map
@@ -15,4 +15,27 @@ export type InteractiveOptions = {
15
15
  */
16
16
  export declare function resolveNoInteractive(value?: boolean | InteractiveOptions): boolean;
17
17
  export declare function isInteractive(value?: boolean | InteractiveOptions): boolean;
18
+ /**
19
+ * True when a prompt failed because no answer could be read — an agent or a
20
+ * script that ran the command with stdin closed, a CI job, or a shell whose
21
+ * stdin is not a terminal. @inquirer rejects those with `User force closed
22
+ * the prompt with 0 null`, which is accurate and useless: it names no flag
23
+ * and no next step (#1479).
24
+ *
25
+ * Two things it deliberately is not:
26
+ *
27
+ * - It is not a substitute for `isInteractive()`. This classifies a prompt
28
+ * that has *already failed*, so piped answers are unaffected: an answer
29
+ * that arrives resolves the prompt and never reaches this check. Refusing
30
+ * to prompt up front would break `printf 'y\n' | openspec archive ...`,
31
+ * which works today.
32
+ * - It is not a cancellation check. Ctrl-C raises the same error class, and
33
+ * it reaches a process whose stdin is a pipe just as easily as one at a
34
+ * terminal, so the SIGINT signal - not the terminal - is what proves
35
+ * somebody was there and chose to quit.
36
+ *
37
+ * Beyond that it defers to `isInteractive()`, so `CI`, `OPEN_SPEC_INTERACTIVE=0`
38
+ * and `--no-interactive` count even when a runner allocated a pty.
39
+ */
40
+ export declare function isNonInteractivePromptError(error: unknown, value?: boolean | InteractiveOptions): boolean;
18
41
  //# sourceMappingURL=interactive.d.ts.map
@@ -18,4 +18,36 @@ export function isInteractive(value) {
18
18
  return false;
19
19
  return !!process.stdin.isTTY;
20
20
  }
21
+ /**
22
+ * True when a prompt failed because no answer could be read — an agent or a
23
+ * script that ran the command with stdin closed, a CI job, or a shell whose
24
+ * stdin is not a terminal. @inquirer rejects those with `User force closed
25
+ * the prompt with 0 null`, which is accurate and useless: it names no flag
26
+ * and no next step (#1479).
27
+ *
28
+ * Two things it deliberately is not:
29
+ *
30
+ * - It is not a substitute for `isInteractive()`. This classifies a prompt
31
+ * that has *already failed*, so piped answers are unaffected: an answer
32
+ * that arrives resolves the prompt and never reaches this check. Refusing
33
+ * to prompt up front would break `printf 'y\n' | openspec archive ...`,
34
+ * which works today.
35
+ * - It is not a cancellation check. Ctrl-C raises the same error class, and
36
+ * it reaches a process whose stdin is a pipe just as easily as one at a
37
+ * terminal, so the SIGINT signal - not the terminal - is what proves
38
+ * somebody was there and chose to quit.
39
+ *
40
+ * Beyond that it defers to `isInteractive()`, so `CI`, `OPEN_SPEC_INTERACTIVE=0`
41
+ * and `--no-interactive` count even when a runner allocated a pty.
42
+ */
43
+ export function isNonInteractivePromptError(error, value) {
44
+ if (!(error instanceof Error))
45
+ return false;
46
+ const failedPrompt = error.name === 'ExitPromptError' || error.message.includes('force closed the prompt');
47
+ if (!failedPrompt)
48
+ return false;
49
+ if (error.message.includes('SIGINT'))
50
+ return false;
51
+ return !isInteractive(value);
52
+ }
21
53
  //# sourceMappingURL=interactive.js.map
@@ -1,4 +1,23 @@
1
+ /**
2
+ * Returns the ids of active changes: every directory under openspec/changes/
3
+ * except the archive and hidden directories.
4
+ *
5
+ * A change is resolved by its directory alone - the same rule `list`,
6
+ * `status`, `instructions` and `validate` use (`getAvailableChanges`).
7
+ * Requiring proposal.md here made `openspec show` and shell completion miss
8
+ * changes those commands resolve: `openspec new change <name>` scaffolds only
9
+ * `.openspec.yaml`, and a custom schema need not define a proposal artifact at
10
+ * all (#1161).
11
+ */
1
12
  export declare function getActiveChangeIds(root?: string): Promise<string[]>;
2
13
  export declare function getSpecIds(root?: string): Promise<string[]>;
14
+ /**
15
+ * Returns the ids of archived changes: every directory under
16
+ * openspec/changes/archive/ except hidden directories.
17
+ *
18
+ * Resolved by directory for the same reason as `getActiveChangeIds`: a change
19
+ * archived from a schema without a proposal artifact has no proposal.md, and
20
+ * gating on it hid those entries from shell completion.
21
+ */
3
22
  export declare function getArchivedChangeIds(root?: string): Promise<string[]>;
4
23
  //# sourceMappingURL=item-discovery.d.ts.map
@@ -1,23 +1,25 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
+ import { discoverSpecFiles } from './spec-discovery.js';
4
+ /**
5
+ * Returns the ids of active changes: every directory under openspec/changes/
6
+ * except the archive and hidden directories.
7
+ *
8
+ * A change is resolved by its directory alone - the same rule `list`,
9
+ * `status`, `instructions` and `validate` use (`getAvailableChanges`).
10
+ * Requiring proposal.md here made `openspec show` and shell completion miss
11
+ * changes those commands resolve: `openspec new change <name>` scaffolds only
12
+ * `.openspec.yaml`, and a custom schema need not define a proposal artifact at
13
+ * all (#1161).
14
+ */
3
15
  export async function getActiveChangeIds(root = process.cwd()) {
4
16
  const changesPath = path.join(root, 'openspec', 'changes');
5
17
  try {
6
18
  const entries = await fs.readdir(changesPath, { withFileTypes: true });
7
- const result = [];
8
- for (const entry of entries) {
9
- if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'archive')
10
- continue;
11
- const proposalPath = path.join(changesPath, entry.name, 'proposal.md');
12
- try {
13
- await fs.access(proposalPath);
14
- result.push(entry.name);
15
- }
16
- catch {
17
- // skip directories without proposal.md
18
- }
19
- }
20
- return result.sort();
19
+ return entries
20
+ .filter((entry) => entry.isDirectory() && entry.name !== 'archive' && !entry.name.startsWith('.'))
21
+ .map((entry) => entry.name)
22
+ .sort();
21
23
  }
22
24
  catch {
23
25
  return [];
@@ -25,45 +27,25 @@ export async function getActiveChangeIds(root = process.cwd()) {
25
27
  }
26
28
  export async function getSpecIds(root = process.cwd()) {
27
29
  const specsPath = path.join(root, 'openspec', 'specs');
28
- const result = [];
29
- try {
30
- const entries = await fs.readdir(specsPath, { withFileTypes: true });
31
- for (const entry of entries) {
32
- if (!entry.isDirectory() || entry.name.startsWith('.'))
33
- continue;
34
- const specFile = path.join(specsPath, entry.name, 'spec.md');
35
- try {
36
- await fs.access(specFile);
37
- result.push(entry.name);
38
- }
39
- catch {
40
- // ignore
41
- }
42
- }
43
- }
44
- catch {
45
- // ignore
46
- }
47
- return result.sort();
30
+ const discovered = await discoverSpecFiles(specsPath);
31
+ return discovered.map((spec) => spec.id);
48
32
  }
33
+ /**
34
+ * Returns the ids of archived changes: every directory under
35
+ * openspec/changes/archive/ except hidden directories.
36
+ *
37
+ * Resolved by directory for the same reason as `getActiveChangeIds`: a change
38
+ * archived from a schema without a proposal artifact has no proposal.md, and
39
+ * gating on it hid those entries from shell completion.
40
+ */
49
41
  export async function getArchivedChangeIds(root = process.cwd()) {
50
42
  const archivePath = path.join(root, 'openspec', 'changes', 'archive');
51
43
  try {
52
44
  const entries = await fs.readdir(archivePath, { withFileTypes: true });
53
- const result = [];
54
- for (const entry of entries) {
55
- if (!entry.isDirectory() || entry.name.startsWith('.'))
56
- continue;
57
- const proposalPath = path.join(archivePath, entry.name, 'proposal.md');
58
- try {
59
- await fs.access(proposalPath);
60
- result.push(entry.name);
61
- }
62
- catch {
63
- // skip directories without proposal.md
64
- }
65
- }
66
- return result.sort();
45
+ return entries
46
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
47
+ .map((entry) => entry.name)
48
+ .sort();
67
49
  }
68
50
  catch {
69
51
  return [];
@@ -12,7 +12,7 @@ export interface ShellDetectionResult {
12
12
  detected: string | undefined;
13
13
  }
14
14
  /**
15
- * Detects the current user's shell based on environment variables
15
+ * Detects the current user's shell based on the parent process and environment
16
16
  *
17
17
  * @returns Detection result with supported shell and raw detected name
18
18
  */
@@ -1,21 +1,74 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  /**
2
- * Detects the current user's shell based on environment variables
3
+ * Map a raw shell name/path to a supported shell, if any.
4
+ */
5
+ function matchSupportedShell(name) {
6
+ // Match the executable basename exactly so lookalikes such as `fish-lsp` or
7
+ // `bash-language-server` don't get mistaken for the shell itself. Login
8
+ // shells report a leading dash (e.g. `-zsh`), so strip it first.
9
+ const executable = name.trim().toLowerCase().split('/').pop()?.replace(/^-/, '');
10
+ if (executable === 'zsh')
11
+ return 'zsh';
12
+ if (executable === 'bash')
13
+ return 'bash';
14
+ if (executable === 'fish')
15
+ return 'fish';
16
+ return undefined;
17
+ }
18
+ /**
19
+ * Detect the interactive shell from the parent process.
20
+ *
21
+ * `process.env.SHELL` is only the login shell, so users whose interactive shell
22
+ * differs from it (e.g. running fish while their login shell is bash) are
23
+ * misdetected. Inspecting the parent process reflects the shell that actually
24
+ * launched openspec. POSIX-only and best-effort — any failure returns undefined
25
+ * so the caller falls back to `$SHELL`.
26
+ *
27
+ * @returns The supported shell running as the parent process, or undefined
28
+ */
29
+ function detectShellFromParentProcess() {
30
+ // `ps` is POSIX-only; Windows shells are handled via PSModulePath/COMSPEC.
31
+ if (process.platform === 'win32') {
32
+ return undefined;
33
+ }
34
+ const ppid = process.ppid;
35
+ if (!ppid || ppid <= 1) {
36
+ return undefined;
37
+ }
38
+ try {
39
+ const comm = execFileSync('ps', ['-p', String(ppid), '-o', 'comm='], {
40
+ encoding: 'utf8',
41
+ timeout: 1000,
42
+ }).trim();
43
+ if (!comm) {
44
+ return undefined;
45
+ }
46
+ // Only trust the parent process when it maps to a supported shell; an
47
+ // unrelated parent (node, npm, sudo, a pager) falls through to `$SHELL`.
48
+ return matchSupportedShell(comm);
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
54
+ /**
55
+ * Detects the current user's shell based on the parent process and environment
3
56
  *
4
57
  * @returns Detection result with supported shell and raw detected name
5
58
  */
6
59
  export function detectShell() {
7
- // Try SHELL environment variable first (Unix-like systems)
60
+ // Prefer the actual running shell (parent process) over `$SHELL`, which only
61
+ // reflects the login shell and misses users whose interactive shell differs.
62
+ const parentShell = detectShellFromParentProcess();
63
+ if (parentShell) {
64
+ return { shell: parentShell, detected: parentShell };
65
+ }
66
+ // Try SHELL environment variable next (Unix-like systems)
8
67
  const shellPath = process.env.SHELL;
9
68
  if (shellPath) {
10
- const shellName = shellPath.toLowerCase();
11
- if (shellName.includes('zsh')) {
12
- return { shell: 'zsh', detected: 'zsh' };
13
- }
14
- if (shellName.includes('bash')) {
15
- return { shell: 'bash', detected: 'bash' };
16
- }
17
- if (shellName.includes('fish')) {
18
- return { shell: 'fish', detected: 'fish' };
69
+ const supported = matchSupportedShell(shellPath);
70
+ if (supported) {
71
+ return { shell: supported, detected: supported };
19
72
  }
20
73
  // Shell detected but not supported
21
74
  // Extract shell name from path (e.g., /bin/tcsh -> tcsh)
@@ -0,0 +1,40 @@
1
+ export interface DiscoveredSpec {
2
+ /** Spec id relative to the specs root, forward-slash separated on every platform (e.g. "web" or "platform/session-layout"). */
3
+ id: string;
4
+ /** Path to the spec.md file (absolute if the specs root is absolute). */
5
+ specFile: string;
6
+ }
7
+ /**
8
+ * Recursively discover every `spec.md` under a specs root, so both the flat
9
+ * `specs/<id>/spec.md` layout and nested `specs/<area>/<id>/spec.md` layouts
10
+ * are found (#1353). A `spec.md` sitting directly in the root is ignored,
11
+ * matching the historical requirement that specs live in a capability folder.
12
+ * Dot-directories are skipped and symlinked directories are not followed.
13
+ * An in-capability symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the
14
+ * artifact graph's globs both count it as content, so dropping it here would
15
+ * silently lose the delta on archive. A link outside its capability is
16
+ * rejected and a dangling link is skipped. Results are sorted by id.
17
+ *
18
+ * A missing root (ENOENT) yields an empty list, but any other read failure
19
+ * (EACCES, EIO, ...) is thrown rather than swallowed: since this feeds the
20
+ * archive/apply merge path, silently dropping an unreadable capability would
21
+ * recreate the exact data-loss class #1353 is closing.
22
+ */
23
+ export declare function discoverSpecFiles(specsRoot: string): Promise<DiscoveredSpec[]>;
24
+ /**
25
+ * True when any regular non-dot file exists anywhere under the given
26
+ * directory. Used by validate/archive to detect content under a change's
27
+ * specs/ that contradicts a declared skip_specs marker - including files that
28
+ * discoverSpecFiles ignores (a root spec.md, stray non-spec.md notes), since
29
+ * anything there would be silently dropped or misread while the change claims
30
+ * to have nothing. Dot entries (.DS_Store, .gitkeep, dot-directories) are
31
+ * skipped to match discoverSpecFiles - they are invisible to every other
32
+ * code path, so they must not count as spec content. Symlinks DO count
33
+ * (without being followed): the artifact graph's globs follow them, so a
34
+ * symlinked spec would read as existing content while the change claims to
35
+ * have none - it contradicts the marker like any regular file. A missing
36
+ * directory returns false; other read failures are thrown for the caller to
37
+ * decide.
38
+ */
39
+ export declare function hasAnyFileUnder(dirPath: string): Promise<boolean>;
40
+ //# sourceMappingURL=spec-discovery.d.ts.map
@@ -0,0 +1,115 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import { FileSystemUtils } from './file-system.js';
4
+ function assertDiscoveredSpecPath(specsRoot, capabilityDir, specFile) {
5
+ try {
6
+ FileSystemUtils.assertPathWithin(specsRoot, specFile);
7
+ }
8
+ catch {
9
+ // Direct capability directories may intentionally be external monorepo
10
+ // links. In that case, confine the file to the capability itself.
11
+ FileSystemUtils.assertPathWithin(capabilityDir, specFile);
12
+ }
13
+ }
14
+ /**
15
+ * Recursively discover every `spec.md` under a specs root, so both the flat
16
+ * `specs/<id>/spec.md` layout and nested `specs/<area>/<id>/spec.md` layouts
17
+ * are found (#1353). A `spec.md` sitting directly in the root is ignored,
18
+ * matching the historical requirement that specs live in a capability folder.
19
+ * Dot-directories are skipped and symlinked directories are not followed.
20
+ * An in-capability symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the
21
+ * artifact graph's globs both count it as content, so dropping it here would
22
+ * silently lose the delta on archive. A link outside its capability is
23
+ * rejected and a dangling link is skipped. Results are sorted by id.
24
+ *
25
+ * A missing root (ENOENT) yields an empty list, but any other read failure
26
+ * (EACCES, EIO, ...) is thrown rather than swallowed: since this feeds the
27
+ * archive/apply merge path, silently dropping an unreadable capability would
28
+ * recreate the exact data-loss class #1353 is closing.
29
+ */
30
+ export async function discoverSpecFiles(specsRoot) {
31
+ const results = [];
32
+ const walk = async (dir, segments) => {
33
+ let entries;
34
+ try {
35
+ entries = await fs.readdir(dir, { withFileTypes: true });
36
+ }
37
+ catch (err) {
38
+ if (err?.code === 'ENOENT')
39
+ return;
40
+ throw err;
41
+ }
42
+ for (const entry of entries) {
43
+ if (entry.name.startsWith('.'))
44
+ continue;
45
+ if (entry.isDirectory()) {
46
+ await walk(path.join(dir, entry.name), [...segments, entry.name]);
47
+ }
48
+ else if (entry.name === 'spec.md' && segments.length > 0) {
49
+ const specFile = path.join(dir, entry.name);
50
+ if (entry.isFile()) {
51
+ assertDiscoveredSpecPath(specsRoot, dir, specFile);
52
+ results.push({ id: segments.join('/'), specFile });
53
+ }
54
+ else if (entry.isSymbolicLink()) {
55
+ try {
56
+ if ((await fs.stat(specFile)).isFile()) {
57
+ assertDiscoveredSpecPath(specsRoot, dir, specFile);
58
+ results.push({ id: segments.join('/'), specFile });
59
+ }
60
+ }
61
+ catch (err) {
62
+ // A dangling link is not content; anything else fails loudly.
63
+ if (err?.code !== 'ENOENT')
64
+ throw err;
65
+ }
66
+ }
67
+ }
68
+ }
69
+ };
70
+ await walk(specsRoot, []);
71
+ // Plain code-point comparison, not localeCompare: the latter follows the
72
+ // process's ICU locale, so ordering could vary by OS/CI. Code-point ordering
73
+ // guarantees the deterministic output the docstring promises.
74
+ return results.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
75
+ }
76
+ /**
77
+ * True when any regular non-dot file exists anywhere under the given
78
+ * directory. Used by validate/archive to detect content under a change's
79
+ * specs/ that contradicts a declared skip_specs marker - including files that
80
+ * discoverSpecFiles ignores (a root spec.md, stray non-spec.md notes), since
81
+ * anything there would be silently dropped or misread while the change claims
82
+ * to have nothing. Dot entries (.DS_Store, .gitkeep, dot-directories) are
83
+ * skipped to match discoverSpecFiles - they are invisible to every other
84
+ * code path, so they must not count as spec content. Symlinks DO count
85
+ * (without being followed): the artifact graph's globs follow them, so a
86
+ * symlinked spec would read as existing content while the change claims to
87
+ * have none - it contradicts the marker like any regular file. A missing
88
+ * directory returns false; other read failures are thrown for the caller to
89
+ * decide.
90
+ */
91
+ export async function hasAnyFileUnder(dirPath) {
92
+ let entries;
93
+ try {
94
+ entries = await fs.readdir(dirPath, { withFileTypes: true });
95
+ }
96
+ catch (err) {
97
+ if (err?.code === 'ENOENT') {
98
+ return false;
99
+ }
100
+ throw err;
101
+ }
102
+ for (const entry of entries) {
103
+ if (entry.name.startsWith('.')) {
104
+ continue;
105
+ }
106
+ if (entry.isFile() || entry.isSymbolicLink()) {
107
+ return true;
108
+ }
109
+ if (entry.isDirectory() && (await hasAnyFileUnder(path.join(dirPath, entry.name)))) {
110
+ return true;
111
+ }
112
+ }
113
+ return false;
114
+ }
115
+ //# sourceMappingURL=spec-discovery.js.map
@@ -1,3 +1,20 @@
1
+ export interface ParsedTask {
2
+ /** Checkbox state: `[x]`/`[X]` is done, anything else is not. */
3
+ done: boolean;
4
+ /** Task text after the checkbox, trimmed (may be empty). */
5
+ description: string;
6
+ }
7
+ /**
8
+ * Parses every task line in a tasks file, in document order.
9
+ *
10
+ * Every line matching the pattern counts, wherever it sits - inside a code
11
+ * fence, an HTML comment or an indented block, as before. Skipping fenced
12
+ * checkboxes was tried and dropped: every rule for deciding which fence is
13
+ * "real" has an input where a stray or unbalanced ``` swallows genuine tasks.
14
+ * Counting a documented example as work is a loud, bypassable false positive;
15
+ * losing a real task is a silent one.
16
+ */
17
+ export declare function parseTaskLines(content: string): ParsedTask[];
1
18
  export interface TaskProgress {
2
19
  total: number;
3
20
  completed: number;
@@ -2,21 +2,49 @@ import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
3
  import { resolveArtifactOutputs, resolveSchema } from '../core/artifact-graph/index.js';
4
4
  import { resolveSchemaForChange } from './change-metadata.js';
5
- const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i;
6
- const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i;
7
- export function countTasksFromContent(content) {
8
- const lines = content.split('\n');
9
- let total = 0;
10
- let completed = 0;
11
- for (const line of lines) {
12
- if (line.match(TASK_PATTERN)) {
13
- total++;
14
- if (line.match(COMPLETED_TASK_PATTERN)) {
15
- completed++;
16
- }
5
+ /**
6
+ * A Markdown task line: a `-`/`*` bullet carrying a `[ ]` or `[x]` checkbox.
7
+ *
8
+ * Leading whitespace is allowed so nested sub-tasks count like their parents.
9
+ * Anchoring at column 0 made ` - [ ] 1.1.1 ...` invisible to progress, to the
10
+ * apply task list, and to archive's incomplete-task check, so a change with
11
+ * unfinished sub-tasks reported "✓ Complete" and archived without a warning.
12
+ *
13
+ * Permissive on purpose, and safe to keep that way: any character class
14
+ * tightened here - the `\s` inside the brackets, which lets a tab or
15
+ * non-breaking space stand for an empty box - drops lines that used to count,
16
+ * and a task this parser drops is a task `openspec archive` stops warning about.
17
+ *
18
+ * Deliberately unanchored at the end: `.` does not match `\r`, so writing the
19
+ * description group as `(.*)$` would reject every line of a CRLF tasks.md.
20
+ */
21
+ const TASK_LINE_PATTERN = /^\s*[-*]\s*\[([\sxX])\]\s*(.*)/;
22
+ /**
23
+ * Parses every task line in a tasks file, in document order.
24
+ *
25
+ * Every line matching the pattern counts, wherever it sits - inside a code
26
+ * fence, an HTML comment or an indented block, as before. Skipping fenced
27
+ * checkboxes was tried and dropped: every rule for deciding which fence is
28
+ * "real" has an input where a stray or unbalanced ``` swallows genuine tasks.
29
+ * Counting a documented example as work is a loud, bypassable false positive;
30
+ * losing a real task is a silent one.
31
+ */
32
+ export function parseTaskLines(content) {
33
+ const tasks = [];
34
+ for (const line of content.split('\n')) {
35
+ const match = line.match(TASK_LINE_PATTERN);
36
+ if (match) {
37
+ tasks.push({ done: match[1].toLowerCase() === 'x', description: match[2].trim() });
17
38
  }
18
39
  }
19
- return { total, completed };
40
+ return tasks;
41
+ }
42
+ export function countTasksFromContent(content) {
43
+ const tasks = parseTaskLines(content);
44
+ return {
45
+ total: tasks.length,
46
+ completed: tasks.filter((task) => task.done).length,
47
+ };
20
48
  }
21
49
  /**
22
50
  * Identifies the change's tracked-tasks artifact: the artifact whose `generates`