@fission-ai/openspec 1.7.0 → 1.9.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 (113) hide show
  1. package/README.md +3 -1
  2. package/dist/cli/index.d.ts +15 -0
  3. package/dist/cli/index.js +39 -5
  4. package/dist/commands/change.js +16 -1
  5. package/dist/commands/config.d.ts +5 -0
  6. package/dist/commands/config.js +5 -1
  7. package/dist/commands/schema.js +281 -47
  8. package/dist/commands/spec.js +30 -6
  9. package/dist/commands/validate.d.ts +23 -0
  10. package/dist/commands/validate.js +148 -4
  11. package/dist/commands/workflow/instructions.js +32 -27
  12. package/dist/commands/workflow/schemas.d.ts +2 -0
  13. package/dist/commands/workflow/schemas.js +9 -2
  14. package/dist/commands/workflow/status.js +2 -2
  15. package/dist/commands/workflow/templates.js +15 -5
  16. package/dist/core/archive.d.ts +14 -0
  17. package/dist/core/archive.js +1272 -151
  18. package/dist/core/artifact-graph/index.d.ts +1 -1
  19. package/dist/core/artifact-graph/index.js +1 -1
  20. package/dist/core/artifact-graph/instruction-loader.d.ts +3 -1
  21. package/dist/core/artifact-graph/instruction-loader.js +15 -5
  22. package/dist/core/artifact-graph/outputs.d.ts +1 -0
  23. package/dist/core/artifact-graph/outputs.js +67 -5
  24. package/dist/core/artifact-graph/resolver.d.ts +0 -12
  25. package/dist/core/artifact-graph/resolver.js +49 -9
  26. package/dist/core/artifact-graph/types.js +21 -3
  27. package/dist/core/available-tools.d.ts +2 -2
  28. package/dist/core/available-tools.js +14 -3
  29. package/dist/core/change-metadata/schema.d.ts +1 -0
  30. package/dist/core/change-metadata/schema.js +7 -0
  31. package/dist/core/change-status-policy.js +2 -2
  32. package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
  33. package/dist/core/command-generation/adapters/command-code.js +34 -0
  34. package/dist/core/command-generation/adapters/index.d.ts +1 -0
  35. package/dist/core/command-generation/adapters/index.js +1 -0
  36. package/dist/core/command-generation/registry.js +2 -0
  37. package/dist/core/completions/command-registry.js +13 -0
  38. package/dist/core/config-schema.d.ts +3 -0
  39. package/dist/core/config-schema.js +28 -1
  40. package/dist/core/config.d.ts +4 -0
  41. package/dist/core/config.js +40 -17
  42. package/dist/core/file-state.js +56 -24
  43. package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
  44. package/dist/core/github-copilot/cloud-agent.js +497 -0
  45. package/dist/core/global-config.d.ts +11 -0
  46. package/dist/core/init.d.ts +23 -0
  47. package/dist/core/init.js +253 -46
  48. package/dist/core/legacy-cleanup.d.ts +10 -0
  49. package/dist/core/legacy-cleanup.js +52 -28
  50. package/dist/core/migration.d.ts +6 -4
  51. package/dist/core/migration.js +78 -22
  52. package/dist/core/parsers/requirement-blocks.d.ts +10 -0
  53. package/dist/core/parsers/requirement-blocks.js +93 -1
  54. package/dist/core/parsers/requirement-text.d.ts +8 -0
  55. package/dist/core/parsers/requirement-text.js +4 -3
  56. package/dist/core/parsers/spec-structure.d.ts +1 -1
  57. package/dist/core/parsers/spec-structure.js +17 -1
  58. package/dist/core/profile-sync-drift.js +40 -5
  59. package/dist/core/project-config.d.ts +3 -0
  60. package/dist/core/project-config.js +31 -1
  61. package/dist/core/root-selection.d.ts +1 -1
  62. package/dist/core/shared/index.d.ts +1 -0
  63. package/dist/core/shared/index.js +1 -0
  64. package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
  65. package/dist/core/shared/skill-content-equivalence.js +41 -0
  66. package/dist/core/shared/skill-paths.d.ts +13 -0
  67. package/dist/core/shared/skill-paths.js +23 -0
  68. package/dist/core/shared/tool-detection.js +66 -35
  69. package/dist/core/shared-skill-target.d.ts +31 -0
  70. package/dist/core/shared-skill-target.js +186 -0
  71. package/dist/core/specs-apply.d.ts +72 -0
  72. package/dist/core/specs-apply.js +459 -59
  73. package/dist/core/templates/skill-templates.d.ts +1 -1
  74. package/dist/core/templates/skill-templates.js +1 -1
  75. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  76. package/dist/core/templates/workflows/apply-change.js +25 -182
  77. package/dist/core/templates/workflows/archive-change.js +8 -4
  78. package/dist/core/templates/workflows/bulk-archive-change.js +30 -26
  79. package/dist/core/templates/workflows/continue-change.js +6 -6
  80. package/dist/core/templates/workflows/explore.js +42 -18
  81. package/dist/core/templates/workflows/onboard.js +9 -3
  82. package/dist/core/templates/workflows/propose.js +72 -20
  83. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  84. package/dist/core/templates/workflows/store-selection.js +1 -1
  85. package/dist/core/templates/workflows/sync-specs.js +96 -18
  86. package/dist/core/templates/workflows/update-change.js +10 -8
  87. package/dist/core/update.d.ts +1 -0
  88. package/dist/core/update.js +136 -30
  89. package/dist/core/validation/task-numbering.d.ts +16 -0
  90. package/dist/core/validation/task-numbering.js +57 -0
  91. package/dist/core/validation/validator.d.ts +26 -3
  92. package/dist/core/validation/validator.js +189 -19
  93. package/dist/core/version-check.js +6 -11
  94. package/dist/telemetry/config.d.ts +3 -4
  95. package/dist/telemetry/index.d.ts +12 -5
  96. package/dist/telemetry/index.js +27 -9
  97. package/dist/utils/change-metadata.d.ts +17 -4
  98. package/dist/utils/change-metadata.js +26 -4
  99. package/dist/utils/ci.d.ts +12 -0
  100. package/dist/utils/ci.js +16 -0
  101. package/dist/utils/command-references.d.ts +14 -2
  102. package/dist/utils/command-references.js +45 -3
  103. package/dist/utils/file-system.d.ts +10 -0
  104. package/dist/utils/file-system.js +70 -0
  105. package/dist/utils/interactive.d.ts +52 -0
  106. package/dist/utils/interactive.js +135 -0
  107. package/dist/utils/spec-discovery.d.ts +4 -4
  108. package/dist/utils/spec-discovery.js +19 -6
  109. package/dist/utils/task-progress.d.ts +51 -1
  110. package/dist/utils/task-progress.js +93 -41
  111. package/package.json +1 -1
  112. package/schemas/spec-driven/schema.yaml +8 -6
  113. package/schemas/spec-driven/templates/proposal.md +6 -4
@@ -59,12 +59,47 @@ const SKILL_INVOCATION_PREFIX = {
59
59
  kimi: '/skill:',
60
60
  codex: '$',
61
61
  };
62
+ /**
63
+ * Tools that have no slash-command surface at all: skills are matched
64
+ * automatically or invoked by natural-language prompts, never by typing a
65
+ * `/<name>` command. Rovo Dev CLI is such a tool — `/skills` only manages
66
+ * skills, and any `/openspec-*` form would be a dead command (see
67
+ * docs/supported-tools.md). References for these tools are spelled as prose
68
+ * ("the openspec-propose skill") so generated content never tells the user to
69
+ * type a command their CLI does not register.
70
+ */
71
+ const NATURAL_LANGUAGE_SKILL_TOOLS = new Set(['rovodev']);
72
+ /**
73
+ * Whether a tool references skills by natural language rather than a slash
74
+ * command (see NATURAL_LANGUAGE_SKILL_TOOLS).
75
+ */
76
+ export function usesNaturalLanguageSkillReferences(toolId) {
77
+ return NATURAL_LANGUAGE_SKILL_TOOLS.has(toolId);
78
+ }
79
+ function replaceCommandsWithNaturalLanguageSkillReferences(text) {
80
+ return text.replace(/\/opsx:([a-z-]+)/g, (match, commandId) => {
81
+ const skillName = COMMAND_TO_SKILL_NAME[commandId];
82
+ return skillName === undefined ? match : `the ${skillName} skill`;
83
+ });
84
+ }
62
85
  function replaceCommandsWithSkillReferences(text, prefix) {
63
86
  return text.replace(/\/opsx:([a-z-]+)/g, (match, commandId) => {
64
87
  const skillName = COMMAND_TO_SKILL_NAME[commandId];
65
88
  return skillName === undefined ? match : `${prefix}${skillName}`;
66
89
  });
67
90
  }
91
+ /**
92
+ * Keeps Codex's `$<name>` spelling first while making its canonical shared
93
+ * `.agents` tree usable by agents that invoke the same skills with `/<name>`.
94
+ */
95
+ export function transformToCodexCompatibleSkillReferences(text) {
96
+ return text.replace(/\/opsx:([a-z-]+)/g, (match, commandId) => {
97
+ const skillName = COMMAND_TO_SKILL_NAME[commandId];
98
+ return skillName === undefined
99
+ ? match
100
+ : `$${skillName} (Codex) or /${skillName} (other agents)`;
101
+ });
102
+ }
68
103
  /**
69
104
  * Transforms command references to skill references using the default `/`
70
105
  * invocation prefix. Converts `/opsx:<command>` patterns to
@@ -88,12 +123,17 @@ export function transformToSkillReferences(text) {
88
123
  /**
89
124
  * Returns the skill-reference transformer for a specific tool, honoring the
90
125
  * tool's documented skill invocation syntax (e.g. Kimi Code's
91
- * `/skill:openspec-propose`). Falls back to the default `/openspec-*` form.
126
+ * `/skill:openspec-propose`). Tools with no slash surface (e.g. Rovo Dev) get
127
+ * natural-language references ("the openspec-propose skill"); everything else
128
+ * falls back to the default `/openspec-*` form.
92
129
  *
93
- * @param toolId - The AI tool identifier (e.g. 'kimi', 'vibe')
130
+ * @param toolId - The AI tool identifier (e.g. 'kimi', 'vibe', 'rovodev')
94
131
  * @returns A transformer converting `/opsx:*` references to skill invocations
95
132
  */
96
133
  export function getSkillReferenceTransformer(toolId) {
134
+ if (usesNaturalLanguageSkillReferences(toolId)) {
135
+ return replaceCommandsWithNaturalLanguageSkillReferences;
136
+ }
97
137
  const prefix = SKILL_INVOCATION_PREFIX[toolId];
98
138
  if (prefix === undefined) {
99
139
  return transformToSkillReferences;
@@ -138,7 +178,9 @@ export function getSkillReferenceTransformer(toolId) {
138
178
  */
139
179
  export function getTransformerForTool(toolId, delivery, capability, invocation) {
140
180
  if (delivery === 'skills' || capability !== 'adapter-backed') {
141
- return getSkillReferenceTransformer(toolId);
181
+ return toolId === 'codex'
182
+ ? transformToCodexCompatibleSkillReferences
183
+ : getSkillReferenceTransformer(toolId);
142
184
  }
143
185
  if (toolId === 'devin' && delivery === 'both') {
144
186
  return getSkillReferenceTransformer(toolId);
@@ -9,6 +9,16 @@ export declare class FileSystemUtils {
9
9
  * Falls back to path.resolve() so callers can still produce a stable absolute path.
10
10
  */
11
11
  static canonicalizeExistingPath(targetPath: string): string;
12
+ /**
13
+ * Refuses a target that leaves an allowed directory, including through an
14
+ * existing symlink in either the target or one of its parent directories.
15
+ * Missing suffixes are resolved from their nearest existing ancestor.
16
+ */
17
+ static assertPathWithin(allowedDirectory: string, targetPath: string): void;
18
+ static resolveProjectArtifactPath(projectPath: string, artifactPath: string): string;
19
+ static assertProjectArtifactPath(projectPath: string, targetPath: string): void;
20
+ private static isPathWithin;
21
+ private static canonicalizePotentialPath;
12
22
  private static isWindowsBasePath;
13
23
  private static normalizeSegments;
14
24
  static joinPath(basePath: string, ...segments: string[]): string;
@@ -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
  }
@@ -1,3 +1,4 @@
1
+ import type { Readable, Writable } from 'node:stream';
1
2
  export type InteractiveOptions = {
2
3
  /**
3
4
  * Explicit "disable prompts" flag passed by internal callers.
@@ -15,4 +16,55 @@ export type InteractiveOptions = {
15
16
  */
16
17
  export declare function resolveNoInteractive(value?: boolean | InteractiveOptions): boolean;
17
18
  export declare function isInteractive(value?: boolean | InteractiveOptions): boolean;
19
+ /**
20
+ * True when a prompt failed because no answer could be read — an agent or a
21
+ * script that ran the command with stdin closed, a CI job, or a shell whose
22
+ * stdin is not a terminal. @inquirer rejects those with `User force closed
23
+ * the prompt with 0 null`, which is accurate and useless: it names no flag
24
+ * and no next step (#1479).
25
+ *
26
+ * Two things it deliberately is not:
27
+ *
28
+ * - It is not a substitute for `isInteractive()`. This classifies a prompt
29
+ * that has *already failed*, so piped answers are unaffected: an answer
30
+ * that arrives resolves the prompt and never reaches this check. Refusing
31
+ * to prompt up front would break `printf 'y\n' | openspec archive ...`,
32
+ * which works today.
33
+ * - It is not a cancellation check. Ctrl-C raises the same error class, and
34
+ * it reaches a process whose stdin is a pipe just as easily as one at a
35
+ * terminal, so the SIGINT signal - not the terminal - is what proves
36
+ * somebody was there and chose to quit.
37
+ *
38
+ * Beyond that it defers to `isInteractive()`, so `CI`, `OPEN_SPEC_INTERACTIVE=0`
39
+ * and `--no-interactive` count even when a runner allocated a pty. It also
40
+ * counts a redirected stdout: `confirmPrompt` drops to the plain reader whenever
41
+ * *either* stream is not a TTY, so a stdin-TTY-but-stdout-redirected run
42
+ * (`openspec archive x > log.txt` from a terminal) that hits EOF must classify
43
+ * the same way the prompt was selected — otherwise it would leak the raw
44
+ * `ExitPromptError` instead of the `--yes` guidance.
45
+ */
46
+ export declare function isNonInteractivePromptError(error: unknown, value?: boolean | InteractiveOptions): boolean;
47
+ export type ConfirmPrompt = {
48
+ message: string;
49
+ default: boolean;
50
+ };
51
+ /**
52
+ * Ask a yes/no question. A real terminal gets @inquirer's rich prompt;
53
+ * everything else — a pipe, a file redirect, an agent that captures stdout —
54
+ * reads one plain line instead.
55
+ *
56
+ * @inquirer renders `confirm` by writing ANSI cursor-movement escape sequences,
57
+ * and it emits them even when stdout is not a TTY. Redirected to a file those
58
+ * sequences are noise, and in some non-TTY hosts the render loop never settles
59
+ * and repeats `ESC[NNG` cursor moves until the disk fills (#1526). Reading
60
+ * the answer ourselves keeps the single piped answer @inquirer ever supported
61
+ * working (`printf 'y\n' | openspec archive ...`) without emitting any escapes.
62
+ *
63
+ * `io` overrides the streams; it exists for tests and mirrors @inquirer's own
64
+ * `{ input, output }` context. Production callers pass only the prompt.
65
+ */
66
+ export declare function confirmPrompt(prompt: ConfirmPrompt, io?: {
67
+ input?: Readable;
68
+ output?: Writable;
69
+ }): Promise<boolean>;
18
70
  //# sourceMappingURL=interactive.d.ts.map
@@ -1,3 +1,4 @@
1
+ import { createInterface } from 'node:readline';
1
2
  /**
2
3
  * Resolves whether non-interactive mode is requested.
3
4
  * Handles both explicit `noInteractive: true` and Commander.js style `interactive: false`.
@@ -18,4 +19,138 @@ export function isInteractive(value) {
18
19
  return false;
19
20
  return !!process.stdin.isTTY;
20
21
  }
22
+ /**
23
+ * True when a prompt failed because no answer could be read — an agent or a
24
+ * script that ran the command with stdin closed, a CI job, or a shell whose
25
+ * stdin is not a terminal. @inquirer rejects those with `User force closed
26
+ * the prompt with 0 null`, which is accurate and useless: it names no flag
27
+ * and no next step (#1479).
28
+ *
29
+ * Two things it deliberately is not:
30
+ *
31
+ * - It is not a substitute for `isInteractive()`. This classifies a prompt
32
+ * that has *already failed*, so piped answers are unaffected: an answer
33
+ * that arrives resolves the prompt and never reaches this check. Refusing
34
+ * to prompt up front would break `printf 'y\n' | openspec archive ...`,
35
+ * which works today.
36
+ * - It is not a cancellation check. Ctrl-C raises the same error class, and
37
+ * it reaches a process whose stdin is a pipe just as easily as one at a
38
+ * terminal, so the SIGINT signal - not the terminal - is what proves
39
+ * somebody was there and chose to quit.
40
+ *
41
+ * Beyond that it defers to `isInteractive()`, so `CI`, `OPEN_SPEC_INTERACTIVE=0`
42
+ * and `--no-interactive` count even when a runner allocated a pty. It also
43
+ * counts a redirected stdout: `confirmPrompt` drops to the plain reader whenever
44
+ * *either* stream is not a TTY, so a stdin-TTY-but-stdout-redirected run
45
+ * (`openspec archive x > log.txt` from a terminal) that hits EOF must classify
46
+ * the same way the prompt was selected — otherwise it would leak the raw
47
+ * `ExitPromptError` instead of the `--yes` guidance.
48
+ */
49
+ export function isNonInteractivePromptError(error, value) {
50
+ if (!(error instanceof Error))
51
+ return false;
52
+ const failedPrompt = error.name === 'ExitPromptError' || error.message.includes('force closed the prompt');
53
+ if (!failedPrompt)
54
+ return false;
55
+ if (error.message.includes('SIGINT'))
56
+ return false;
57
+ return !isInteractive(value) || !process.stdout.isTTY;
58
+ }
59
+ /**
60
+ * Ask a yes/no question. A real terminal gets @inquirer's rich prompt;
61
+ * everything else — a pipe, a file redirect, an agent that captures stdout —
62
+ * reads one plain line instead.
63
+ *
64
+ * @inquirer renders `confirm` by writing ANSI cursor-movement escape sequences,
65
+ * and it emits them even when stdout is not a TTY. Redirected to a file those
66
+ * sequences are noise, and in some non-TTY hosts the render loop never settles
67
+ * and repeats `ESC[NNG` cursor moves until the disk fills (#1526). Reading
68
+ * the answer ourselves keeps the single piped answer @inquirer ever supported
69
+ * working (`printf 'y\n' | openspec archive ...`) without emitting any escapes.
70
+ *
71
+ * `io` overrides the streams; it exists for tests and mirrors @inquirer's own
72
+ * `{ input, output }` context. Production callers pass only the prompt.
73
+ */
74
+ export async function confirmPrompt(prompt, io = {}) {
75
+ const input = io.input ?? process.stdin;
76
+ const output = io.output ?? process.stdout;
77
+ const isTerminal = Boolean(input.isTTY) &&
78
+ Boolean(output.isTTY);
79
+ if (isTerminal) {
80
+ const { confirm } = await import('@inquirer/prompts');
81
+ return confirm(prompt);
82
+ }
83
+ return readYesNo(prompt, input, output);
84
+ }
85
+ function readYesNo(prompt, input, output) {
86
+ return new Promise((resolve, reject) => {
87
+ let settled = false;
88
+ // Detach the input-stream error listener on settle: `input` is the
89
+ // long-lived process.stdin, so a leftover listener would accumulate across
90
+ // archive's successive prompts and swallow a later, unrelated stdin error.
91
+ const cleanup = () => {
92
+ input.removeListener('error', onError);
93
+ };
94
+ const blockOnNoAnswer = () => {
95
+ if (settled)
96
+ return;
97
+ settled = true;
98
+ cleanup();
99
+ // No line could be read (stdin closed / EOF). Mirror @inquirer's failure
100
+ // so callers that classify it — isNonInteractivePromptError, the #1479
101
+ // "rerun with --yes" guidance — keep working unchanged.
102
+ const error = new Error('User force closed the prompt');
103
+ error.name = 'ExitPromptError';
104
+ reject(error);
105
+ };
106
+ // A stdin error surfaces on the interface (readline forwards input-stream
107
+ // errors since Node 16). Without a handler the promise would hang and the
108
+ // 'error' would go unhandled; settle it with the real fault instead.
109
+ const onError = (err) => {
110
+ if (settled)
111
+ return;
112
+ settled = true;
113
+ cleanup();
114
+ rl.close();
115
+ reject(err instanceof Error ? err : new Error(String(err)));
116
+ };
117
+ // An earlier prompt may have already drained stdin (only one piped answer
118
+ // was ever supported). A fresh readline over an ended stream never emits
119
+ // 'close', so guard here rather than hang and exit as a no-op.
120
+ if (input.readableEnded) {
121
+ blockOnNoAnswer();
122
+ return;
123
+ }
124
+ output.write(`${prompt.message} ${prompt.default ? '(Y/n)' : '(y/N)'} `);
125
+ // terminal:false guarantees readline never emits its own line-editing
126
+ // escapes — an escape-free read is the whole point here.
127
+ const rl = createInterface({ input, terminal: false });
128
+ input.once('error', onError);
129
+ rl.once('error', onError);
130
+ rl.once('line', (line) => {
131
+ if (settled)
132
+ return;
133
+ settled = true;
134
+ cleanup();
135
+ rl.close();
136
+ output.write('\n');
137
+ // Mirror @inquirer/confirm's parser (prefix match on y/yes and n/no,
138
+ // otherwise the default) so a piped answer resolves identically to the
139
+ // interactive prompt it replaces.
140
+ const answer = line.trim();
141
+ if (/^(y|yes)/i.test(answer)) {
142
+ resolve(true);
143
+ }
144
+ else if (/^(n|no)/i.test(answer)) {
145
+ resolve(false);
146
+ }
147
+ else {
148
+ resolve(prompt.default);
149
+ }
150
+ });
151
+ rl.once('close', () => {
152
+ blockOnNoAnswer();
153
+ });
154
+ });
155
+ }
21
156
  //# sourceMappingURL=interactive.js.map
@@ -10,10 +10,10 @@ export interface DiscoveredSpec {
10
10
  * are found (#1353). A `spec.md` sitting directly in the root is ignored,
11
11
  * matching the historical requirement that specs live in a capability folder.
12
12
  * Dot-directories are skipped and symlinked directories are not followed.
13
- * A symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the artifact
14
- * graph's globs both count it as content, so dropping it here would silently
15
- * lose the delta on archive; a dangling link is skipped. Results are sorted
16
- * by id for deterministic output.
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
17
  *
18
18
  * A missing root (ENOENT) yields an empty list, but any other read failure
19
19
  * (EACCES, EIO, ...) is thrown rather than swallowed: since this feeds the
@@ -1,15 +1,26 @@
1
1
  import { promises as fs } from 'fs';
2
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
+ }
3
14
  /**
4
15
  * Recursively discover every `spec.md` under a specs root, so both the flat
5
16
  * `specs/<id>/spec.md` layout and nested `specs/<area>/<id>/spec.md` layouts
6
17
  * are found (#1353). A `spec.md` sitting directly in the root is ignored,
7
18
  * matching the historical requirement that specs live in a capability folder.
8
19
  * Dot-directories are skipped and symlinked directories are not followed.
9
- * A symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the artifact
10
- * graph's globs both count it as content, so dropping it here would silently
11
- * lose the delta on archive; a dangling link is skipped. Results are sorted
12
- * by id for deterministic output.
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.
13
24
  *
14
25
  * A missing root (ENOENT) yields an empty list, but any other read failure
15
26
  * (EACCES, EIO, ...) is thrown rather than swallowed: since this feeds the
@@ -35,13 +46,15 @@ export async function discoverSpecFiles(specsRoot) {
35
46
  await walk(path.join(dir, entry.name), [...segments, entry.name]);
36
47
  }
37
48
  else if (entry.name === 'spec.md' && segments.length > 0) {
49
+ const specFile = path.join(dir, entry.name);
38
50
  if (entry.isFile()) {
39
- results.push({ id: segments.join('/'), specFile: path.join(dir, entry.name) });
51
+ assertDiscoveredSpecPath(specsRoot, dir, specFile);
52
+ results.push({ id: segments.join('/'), specFile });
40
53
  }
41
54
  else if (entry.isSymbolicLink()) {
42
- const specFile = path.join(dir, entry.name);
43
55
  try {
44
56
  if ((await fs.stat(specFile)).isFile()) {
57
+ assertDiscoveredSpecPath(specsRoot, dir, specFile);
45
58
  results.push({ id: segments.join('/'), specFile });
46
59
  }
47
60
  }
@@ -1,8 +1,46 @@
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;
4
21
  }
5
22
  export declare function countTasksFromContent(content: string): TaskProgress;
23
+ /**
24
+ * Run-scoped memo mapping a schema name to its tracked-tasks `generates` glob.
25
+ * When one command resolves progress for many changes under a constant
26
+ * `projectRoot` — e.g. `validate --archived` over an append-only archive — this
27
+ * avoids re-reading and re-parsing (YAML + Zod) the same `schema.yaml` once per
28
+ * change. Keyed by schema name alone, which is safe *only* because a single run
29
+ * holds `projectRoot` constant; never reuse one cache across differing roots.
30
+ */
31
+ export type SchemaGlobCache = Map<string, string | undefined>;
32
+ /** Resolves the task files selected by the schema's apply tracking rule. */
33
+ export declare function resolveTaskFilesForChange(changeDir: string, projectRoot: string, schemaGlobCache?: SchemaGlobCache): string[];
34
+ export interface TaskProgressDetail extends TaskProgress {
35
+ /**
36
+ * Task files that exist but could not be read (any error other than ENOENT).
37
+ * `getTaskProgressForChange` discards this list to preserve its behavior;
38
+ * callers that must fail loudly on an unreadable tasks file — e.g.
39
+ * `openspec validate --archived` — read it so an unreadable file is never
40
+ * silently counted as "no tasks" (#205).
41
+ */
42
+ unreadable: string[];
43
+ }
6
44
  /**
7
45
  * Computes a change's task progress by resolving its tracked-tasks artifact and
8
46
  * counting checkboxes across every file matched by that artifact's `generates`
@@ -10,7 +48,19 @@ export declare function countTasksFromContent(content: string): TaskProgress;
10
48
  * artifact (`resolveArtifactOutputs`) — so progress is no longer blind to nested
11
49
  * `tasks.md` files (#1202). Falls back to a single top-level `tasks.md` (exactly
12
50
  * as before) when the schema is unresolvable, no tracked-tasks artifact is found,
13
- * or the glob matches no file. Never throws.
51
+ * or the glob matches no file. Also reports task files that exist but could not
52
+ * be read. Per-file read errors are captured (never thrown); the only throw path
53
+ * is a malformed/unsafe schema whose glob resolution rejects (path traversal or
54
+ * a linked-directory cycle in `resolveArtifactOutputs`). Pass `schemaGlobCache`
55
+ * to memoize schema→glob resolution across many changes in one run.
56
+ */
57
+ export declare function getTaskProgressDetailForChange(changesDir: string, changeName: string, projectRoot: string, schemaGlobCache?: SchemaGlobCache): Promise<TaskProgressDetail>;
58
+ /**
59
+ * The task-completion counter `status`, `list`, and `archive` share. Delegates
60
+ * to `getTaskProgressDetailForChange` and drops the `unreadable` detail, so its
61
+ * returned totals are unchanged. Throws only on the same malformed/unsafe-schema
62
+ * glob-resolution path as that function (existing behavior; callers guard it as
63
+ * they did before).
14
64
  */
15
65
  export declare function getTaskProgressForChange(changesDir: string, changeName: string, projectRoot: string): Promise<TaskProgress>;
16
66
  export declare function formatTaskStatus(progress: TaskProgress): string;