From 685212701417ae279dc024f978f609784b82543c Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 16:29:36 -0500 Subject: [PATCH 01/23] fix(archive): make the scenario-drift check fence-aware parseScenarioBlocks matched #### Scenario: headers on raw lines while the validator's countScenarios masks fenced code blocks (#1151). The drift check (#1391) inherited the raw scan, so a fenced scenario example in the current spec aborted an archive that validate had passed, and a fenced name in the MODIFIED block counted as keeping a scenario the block had actually dropped. Build the shared code-fence mask and skip masked lines in both the header scan and the block-end scan. Co-Authored-By: Claude Opus 5 (1M context) --- src/core/specs-apply.ts | 9 ++- test/core/archive.test.ts | 125 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 2 deletions(-) diff --git a/src/core/specs-apply.ts b/src/core/specs-apply.ts index db80ae8bf..b41a80da0 100644 --- a/src/core/specs-apply.ts +++ b/src/core/specs-apply.ts @@ -579,11 +579,16 @@ function findMissingCurrentScenarios(current: RequirementBlock, incoming: Requir function parseScenarioBlocks(requirementRaw: string): ScenarioBlock[] { const lines = requirementRaw.replace(/\r\n?/g, '\n').split('\n'); + // A `#### Scenario:` inside a fenced example is not a real scenario. The + // validator's countScenarios already ignores fenced lines; the drift check + // must agree with it, or a fenced sample can false-abort an archive (or + // mask a genuinely dropped scenario). + const mask = buildCodeFenceMask(lines); const scenarios: ScenarioBlock[] = []; let index = 0; while (index < lines.length) { - const headerMatch = lines[index].match(/^####\s*Scenario:\s*(.+)\s*$/); + const headerMatch = mask[index] ? null : lines[index].match(/^####\s*Scenario:\s*(.+)\s*$/); if (!headerMatch) { index++; continue; @@ -592,7 +597,7 @@ function parseScenarioBlocks(requirementRaw: string): ScenarioBlock[] { const start = index; const name = headerMatch[1].trim(); index++; - while (index < lines.length && !/^####\s*Scenario:\s*(.+)\s*$/.test(lines[index])) { + while (index < lines.length && (mask[index] || !/^####\s*Scenario:\s*(.+)\s*$/.test(lines[index]))) { index++; } diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index fd5a5d313..8169c3aa8 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -2020,6 +2020,131 @@ The system SHALL authenticate. expect(archives.some(a => a.includes(changeName))).toBe(false); }); + it('should not treat a fenced scenario example in the current spec as real drift', async () => { + // The validator ignores fenced `#### Scenario:` lines (countScenarios is + // fence-aware); the drift check must agree, or a fenced sample in the + // current spec aborts an archive that validate said was fine. + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'fenced-current'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainSpecPath = path.join(mainSpecDir, 'spec.md'); + await fs.writeFile( + mainSpecPath, + `# fenced-current Specification + +## Purpose +Fenced scenario samples in the current spec. + +## Requirements + +### Requirement: Reporting +The system SHALL report results using the scenario format: + +\`\`\`markdown +#### Scenario: Fenced sample +- **WHEN** shown as an example +- **THEN** it is not a real scenario +\`\`\` + +#### Scenario: Emit report +- **WHEN** a run finishes +- **THEN** a report is emitted` + ); + + const changeName = 'edit-fenced-current'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'fenced-current'); + await fs.mkdir(changeSpecDir, { recursive: true }); + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `# Edit Fenced Current - Change + +## MODIFIED Requirements + +### Requirement: Reporting +The system SHALL report results in JSON. + +#### Scenario: Emit report +- **WHEN** a run finishes +- **THEN** a JSON report is emitted` + ); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updated = await fs.readFile(mainSpecPath, 'utf-8'); + expect(updated).toContain('The system SHALL report results in JSON.'); + expect(updated).toContain('a JSON report is emitted'); + expect(console.log).not.toHaveBeenCalledWith( + expect.stringContaining('current spec contains scenario(s) not present in the modified block') + ); + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeName))).toBe(true); + }); + + it('should abort when a MODIFIED block only keeps a dropped scenario inside a fence', async () => { + // The inverse hole: a fenced `#### Scenario: Audit` in the incoming block + // must not count as keeping the real Audit scenario the block dropped. + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'fenced-incoming'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainSpecPath = path.join(mainSpecDir, 'spec.md'); + await fs.writeFile( + mainSpecPath, + `# fenced-incoming Specification + +## Purpose +Fenced scenario names in the incoming block. + +## Requirements + +### Requirement: Access log +The system SHALL log access. + +#### Scenario: Audit +- **WHEN** a user signs in +- **THEN** an audit row is written` + ); + + const changeName = 'drop-audit-behind-fence'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'fenced-incoming'); + await fs.mkdir(changeSpecDir, { recursive: true }); + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `# Drop Audit Behind Fence - Change + +## MODIFIED Requirements + +### Requirement: Access log +The system SHALL log access, for example: + +\`\`\`markdown +#### Scenario: Audit +- **WHEN** shown as an example +- **THEN** it is not a real scenario +\`\`\` + +#### Scenario: Trace +- **WHEN** a request is served +- **THEN** a trace row is written` + ); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updated = await fs.readFile(mainSpecPath, 'utf-8'); + // Spec must be untouched — the real Audit scenario preserved. + expect(updated).toContain('an audit row is written'); + expect(updated).not.toContain('Trace'); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining( + 'fenced-incoming MODIFIED failed for header "### Requirement: Access log" - current spec contains scenario(s) not present in the modified block: "Audit"' + ) + ); + expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.'); + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeName))).toBe(false); + }); + it('should abort with a structural error when target spec hides requirements outside ## Requirements', async () => { const changeName = 'hidden-requirement-target'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); From 287ec35fe1a540313d8bb51ede819ae05e7dcfec Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 16:31:14 -0500 Subject: [PATCH 02/23] fix(update): tear down the redirected request when the budget expires The overall request budget was armed inside the first send() and its callback closed over that hop's request. After a redirect the timer destroyed the already-dead first request, so a redirect target that trickled bytes kept resetting its idle timeout and held the socket open until the body-size cap. Track the in-flight request and have the budget timer destroy whichever one is open. Co-Authored-By: Claude Opus 5 (1M context) --- src/core/version-check.ts | 10 +++++++++- test/core/version-check.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/core/version-check.ts b/src/core/version-check.ts index 1747e9d31..92b23edc2 100644 --- a/src/core/version-check.ts +++ b/src/core/version-check.ts @@ -158,6 +158,12 @@ function fetchLatestVersion(): Promise { // check would be permanently and silently dead for them. let redirectsLeft = MAX_REDIRECTS; + // The budget timer must tear down whichever request is open when it + // fires. Closing over the first hop's request would leave a redirected + // socket alive: a target that trickles bytes keeps resetting its idle + // timeout, and only the body-size cap would end it. + let activeRequest: http.ClientRequest | undefined; + const send = (target: URL): void => { const request = (target.protocol === 'http:' ? http : https).get( target, @@ -217,6 +223,8 @@ function fetchLatestVersion(): Promise { } ); + activeRequest = request; + request.on('timeout', () => { request.destroy(); finish(null); @@ -226,7 +234,7 @@ function fetchLatestVersion(): Promise { // One budget for the whole exchange, redirects included. if (!timer) { timer = setTimeout(() => { - request.destroy(); + activeRequest?.destroy(); finish(null); }, REQUEST_TIMEOUT_MS); } diff --git a/test/core/version-check.test.ts b/test/core/version-check.test.ts index 00471d70b..c8607cd5c 100644 --- a/test/core/version-check.test.ts +++ b/test/core/version-check.test.ts @@ -215,6 +215,35 @@ describe('getAvailableCliUpdate', () => { await expect(getAvailableCliUpdate()).resolves.toBeNull(); }); + it('tears down a redirected connection when the overall budget expires', async () => { + // The redirect target trickles bytes forever: steady data keeps resetting + // the per-request idle timeout, so only the overall budget timer can end + // the exchange — and it must destroy the redirected request, not the + // already-dead first hop, or the socket outlives the check. + let hop = 0; + let trickleClosed = false; + respond = (res) => { + hop += 1; + if (hop === 1) { + res.writeHead(302, { location: '/mirror/@fission-ai/openspec/latest' }); + res.end(); + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.write('{"ver'); + const trickle = setInterval(() => res.write('x'), 200); + res.on('close', () => { + trickleClosed = true; + clearInterval(trickle); + }); + }; + + const startedAt = Date.now(); + await expect(getAvailableCliUpdate()).resolves.toBeNull(); + expect(Date.now() - startedAt).toBeLessThan(5000); + await vi.waitFor(() => expect(trickleClosed).toBe(true), { timeout: 2000 }); + }, 10000); + it('gives up rather than hanging when the registry stalls mid-response', async () => { respond = (res) => { res.writeHead(200, { 'content-type': 'application/json' }); From 09844005d804b55f76d3373bd8cecf246d888195 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 16:31:51 -0500 Subject: [PATCH 03/23] chore(release): add changesets for user-facing changes missing from the 1.7.0 notes 18 feat/fix commits merged since v1.6.0 without a changeset, so the pending Version Packages PR would have released them silently: five tool integrations (ZCode, Hermes, CodeArts, Kimi Code rename, Codex skills-only), skills.sh distribution, symlinked schema dirs, nested spec discovery, drift multiplicity, checkbox markers, Windows welcome input, npx avoidance, doctor store drift, local dates, missing-core-workflows warning, store-aware main specs, open-questions guidance, and spec content guidance. Plus changesets for this branch's two fixes. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/add-codearts-tool.md | 5 +++++ .changeset/add-hermes-tool.md | 5 +++++ .changeset/add-zcode-tool.md | 5 +++++ .changeset/avoid-npx-profile-changes.md | 5 +++++ .changeset/codex-skills-only.md | 5 +++++ .changeset/doctor-store-drift.md | 5 +++++ .changeset/drift-check-multiplicity.md | 5 +++++ .changeset/fence-aware-drift-check.md | 5 +++++ .changeset/kimi-cli-to-kimi-code.md | 5 +++++ .changeset/local-dates-cli.md | 5 +++++ .changeset/missing-core-workflows-warning.md | 5 +++++ .changeset/multiselect-checkbox-markers.md | 5 +++++ .changeset/nested-spec-discovery.md | 5 +++++ .changeset/resolve-open-questions.md | 5 +++++ .changeset/skills-sh-distribution.md | 5 +++++ .changeset/spec-content-guidance.md | 5 +++++ .changeset/store-aware-main-specs.md | 5 +++++ .changeset/symlinked-schema-dirs.md | 5 +++++ .changeset/update-check-redirect-teardown.md | 5 +++++ .changeset/windows-welcome-input.md | 5 +++++ 20 files changed, 100 insertions(+) create mode 100644 .changeset/add-codearts-tool.md create mode 100644 .changeset/add-hermes-tool.md create mode 100644 .changeset/add-zcode-tool.md create mode 100644 .changeset/avoid-npx-profile-changes.md create mode 100644 .changeset/codex-skills-only.md create mode 100644 .changeset/doctor-store-drift.md create mode 100644 .changeset/drift-check-multiplicity.md create mode 100644 .changeset/fence-aware-drift-check.md create mode 100644 .changeset/kimi-cli-to-kimi-code.md create mode 100644 .changeset/local-dates-cli.md create mode 100644 .changeset/missing-core-workflows-warning.md create mode 100644 .changeset/multiselect-checkbox-markers.md create mode 100644 .changeset/nested-spec-discovery.md create mode 100644 .changeset/resolve-open-questions.md create mode 100644 .changeset/skills-sh-distribution.md create mode 100644 .changeset/spec-content-guidance.md create mode 100644 .changeset/store-aware-main-specs.md create mode 100644 .changeset/symlinked-schema-dirs.md create mode 100644 .changeset/update-check-redirect-teardown.md create mode 100644 .changeset/windows-welcome-input.md diff --git a/.changeset/add-codearts-tool.md b/.changeset/add-codearts-tool.md new file mode 100644 index 000000000..d18a465c2 --- /dev/null +++ b/.changeset/add-codearts-tool.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": minor +--- + +Add CodeArts Agent skills support: `openspec init --tools codeartsagent` installs the workflow skills. diff --git a/.changeset/add-hermes-tool.md b/.changeset/add-hermes-tool.md new file mode 100644 index 000000000..38b51340a --- /dev/null +++ b/.changeset/add-hermes-tool.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": minor +--- + +Add Hermes Agent as a supported AI tool with skills and command generation. diff --git a/.changeset/add-zcode-tool.md b/.changeset/add-zcode-tool.md new file mode 100644 index 000000000..8caf5759c --- /dev/null +++ b/.changeset/add-zcode-tool.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": minor +--- + +Add ZCode as a supported AI tool: `openspec init --tools zcode` generates its skills and `/opsx-*` commands. diff --git a/.changeset/avoid-npx-profile-changes.md b/.changeset/avoid-npx-profile-changes.md new file mode 100644 index 000000000..4e250e4bd --- /dev/null +++ b/.changeset/avoid-npx-profile-changes.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Apply profile changes with the installed CLI instead of shelling out to `npx`, which could run a different version. diff --git a/.changeset/codex-skills-only.md b/.changeset/codex-skills-only.md new file mode 100644 index 000000000..5866f48d4 --- /dev/null +++ b/.changeset/codex-skills-only.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": minor +--- + +Codex is now skills-only: workflows install as `$openspec-*` skills and previously managed custom prompts are retired (existing ones are cleaned up on update). diff --git a/.changeset/doctor-store-drift.md b/.changeset/doctor-store-drift.md new file mode 100644 index 000000000..d106b5d86 --- /dev/null +++ b/.changeset/doctor-store-drift.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +`openspec doctor` now notes when a store checkout is behind its upstream ref. diff --git a/.changeset/drift-check-multiplicity.md b/.changeset/drift-check-multiplicity.md new file mode 100644 index 000000000..5510ec28e --- /dev/null +++ b/.changeset/drift-check-multiplicity.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Make the archive scenario-drift check multiplicity-aware: a MODIFIED block that keeps only one of two same-named scenarios no longer silently drops the other. diff --git a/.changeset/fence-aware-drift-check.md b/.changeset/fence-aware-drift-check.md new file mode 100644 index 000000000..5e85c7a6d --- /dev/null +++ b/.changeset/fence-aware-drift-check.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +The archive scenario-drift check now ignores `#### Scenario:` lines inside fenced code blocks, matching validate: a fenced example no longer false-aborts an archive, and a fenced name no longer masks a genuinely dropped scenario. diff --git a/.changeset/kimi-cli-to-kimi-code.md b/.changeset/kimi-cli-to-kimi-code.md new file mode 100644 index 000000000..8daaf5b26 --- /dev/null +++ b/.changeset/kimi-cli-to-kimi-code.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Follow the Kimi CLI rename to Kimi Code: new install paths with automatic migration of existing `.kimi` setups. diff --git a/.changeset/local-dates-cli.md b/.changeset/local-dates-cli.md new file mode 100644 index 000000000..b26946abe --- /dev/null +++ b/.changeset/local-dates-cli.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Use local dates for CLI date-only values (archive names, timestamps) instead of UTC, so late-evening archives no longer get tomorrow's date. diff --git a/.changeset/missing-core-workflows-warning.md b/.changeset/missing-core-workflows-warning.md new file mode 100644 index 000000000..959c93269 --- /dev/null +++ b/.changeset/missing-core-workflows-warning.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +`openspec update` warns when a custom profile is missing core workflows instead of silently generating a partial install. diff --git a/.changeset/multiselect-checkbox-markers.md b/.changeset/multiselect-checkbox-markers.md new file mode 100644 index 000000000..caada04e0 --- /dev/null +++ b/.changeset/multiselect-checkbox-markers.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Render multi-select prompts with `[x]`/`[ ]` checkbox markers instead of radio-button icons. diff --git a/.changeset/nested-spec-discovery.md b/.changeset/nested-spec-discovery.md new file mode 100644 index 000000000..7a6881a16 --- /dev/null +++ b/.changeset/nested-spec-discovery.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Discover nested spec paths like `specs///spec.md` recursively and consistently across parse, apply, and archive. diff --git a/.changeset/resolve-open-questions.md b/.changeset/resolve-open-questions.md new file mode 100644 index 000000000..731e1bc28 --- /dev/null +++ b/.changeset/resolve-open-questions.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Proposal guidance now resolves blocking open questions with the user instead of deferring them to design.md. diff --git a/.changeset/skills-sh-distribution.md b/.changeset/skills-sh-distribution.md new file mode 100644 index 000000000..84ab95a6f --- /dev/null +++ b/.changeset/skills-sh-distribution.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": minor +--- + +Publish the workflow skills as static `skills//SKILL.md` files so `npx skills add Fission-AI/OpenSpec` works. diff --git a/.changeset/spec-content-guidance.md b/.changeset/spec-content-guidance.md new file mode 100644 index 000000000..d5fca0ad9 --- /dev/null +++ b/.changeset/spec-content-guidance.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Specs instructions include the spec content guidance from the concepts docs, so generated specs follow the requirement/scenario format. diff --git a/.changeset/store-aware-main-specs.md b/.changeset/store-aware-main-specs.md new file mode 100644 index 000000000..4de9d453d --- /dev/null +++ b/.changeset/store-aware-main-specs.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Sync and archive workflows resolve main specs through the store-aware root instead of assuming `openspec/specs` in the repo. diff --git a/.changeset/symlinked-schema-dirs.md b/.changeset/symlinked-schema-dirs.md new file mode 100644 index 000000000..1b1c80bf6 --- /dev/null +++ b/.changeset/symlinked-schema-dirs.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": minor +--- + +Resolve symlinked schema directories so schemas shared via symlink (e.g. from a dotfiles repo) are discovered. diff --git a/.changeset/update-check-redirect-teardown.md b/.changeset/update-check-redirect-teardown.md new file mode 100644 index 000000000..069f445f1 --- /dev/null +++ b/.changeset/update-check-redirect-teardown.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +The stale-CLI check tears down a redirected registry connection when its time budget expires instead of leaving the socket open. diff --git a/.changeset/windows-welcome-input.md b/.changeset/windows-welcome-input.md new file mode 100644 index 000000000..423362ed3 --- /dev/null +++ b/.changeset/windows-welcome-input.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Preserve keyboard input on Windows after the welcome screen instead of dropping the first keystrokes. From 9985d289f23531e5318df4edccc18dee23ddeebc Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 16:43:01 -0500 Subject: [PATCH 04/23] fix(adapters): escape TOML-active characters in Gemini command files The gemini adapter interpolated the description into a TOML basic string and the body into a multiline basic string with no escaping. Every current template value happens to be safe; the first description with a double quote or backslash would silently produce invalid TOML for all Gemini command files. Escape both contexts (#1447 fixed the same class for the YAML adapters but scoped itself to YAML). Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/gemini-toml-escaping.md | 5 +++ .../command-generation/adapters/gemini.ts | 33 +++++++++++++++++-- test/core/command-generation/adapters.test.ts | 23 +++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 .changeset/gemini-toml-escaping.md diff --git a/.changeset/gemini-toml-escaping.md b/.changeset/gemini-toml-escaping.md new file mode 100644 index 000000000..4df52a851 --- /dev/null +++ b/.changeset/gemini-toml-escaping.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Gemini command files escape TOML-active characters (quotes, backslashes, control characters) in the description and prompt, so a template value containing them can no longer produce an invalid `.toml` file. diff --git a/src/core/command-generation/adapters/gemini.ts b/src/core/command-generation/adapters/gemini.ts index 2c08656f4..96857f4bf 100644 --- a/src/core/command-generation/adapters/gemini.ts +++ b/src/core/command-generation/adapters/gemini.ts @@ -7,6 +7,35 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +/** + * Control characters (C0 except tab/newline/carriage return, plus DEL) are + * invalid inside TOML strings and must be written as escapes. + */ +const TOML_CONTROL_CHARS = new RegExp('[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f]', 'g'); + +/** + * TOML basic strings are escape-active: a backslash or double quote in the + * value breaks the file if written raw. Newlines cannot appear in a + * single-line basic string at all, so they are escaped too. + */ +function escapeTomlBasicString(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') + .replace(TOML_CONTROL_CHARS, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`); +} + +/** + * Multiline basic strings keep raw newlines and tabs, but backslashes are + * still escape-active and any run of three quotes would end the string. + */ +function escapeTomlMultilineBasicString(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"'); +} + /** * Gemini adapter for command generation. * File path: .gemini/commands/opsx/.toml @@ -20,10 +49,10 @@ export const geminiAdapter: ToolCommandAdapter = { }, formatFile(content: CommandContent): string { - return `description = "${content.description}" + return `description = "${escapeTomlBasicString(content.description)}" prompt = """ -${content.body} +${escapeTomlMultilineBasicString(content.body)} """ `; }, diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index 43dbea5d4..dff8008df 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -414,6 +414,29 @@ describe('command-generation/adapters', () => { expect(output).toContain('This is the command body.'); expect(output).toContain('"""'); }); + + it('escapes TOML-active characters in the description', () => { + const output = geminiAdapter.formatFile({ + ...sampleContent, + description: 'Say "hi" to C:\\Users and\nmore', + }); + // Basic strings are escape-active: quotes, backslashes, and newlines + // must be written as escapes or the file stops parsing as TOML. + expect(output).toContain('description = "Say \\"hi\\" to C:\\\\Users and\\nmore"'); + }); + + it('keeps the prompt a single multiline string when the body carries fences and backslashes', () => { + const output = geminiAdapter.formatFile({ + ...sampleContent, + body: 'Windows path C:\\temp and a quote run: """ done', + }); + // Backslashes must be escaped and no unescaped quote-triple may remain, + // or the """ delimiter ends the prompt early. + expect(output).toContain('C:\\\\temp'); + expect(output).toContain('""\\" done'); + const delimiters = output.match(/(? { From aabe58974d204cf40d8076308be193ed41a37446 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 16:43:02 -0500 Subject: [PATCH 05/23] fix(update): harden install detection and redirect handling Three follow-ups from the release audit: - A path segment literally named volta (a user or project directory) classified the install as volta-managed and swallowed the upgrade offer. The undotted spelling now requires volta's own tools/image layout, matching how pnpm and yarn already demand corroboration. - The Windows npm-ownership fallback checked that the npm prefix exists, which is true of any X\node_modules\pkg tree, hand-copied ones included. Corroborate with the openspec.cmd shim npm actually writes. - A https registry redirecting to plain http was followed; a MITM on that reply controls the newer-version answer. Refuse the downgrade. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/update-check-detection-hardening.md | 5 +++++ src/core/version-check.ts | 18 +++++++++++++++--- test/core/version-check.test.ts | 7 +++++++ 3 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 .changeset/update-check-detection-hardening.md diff --git a/.changeset/update-check-detection-hardening.md b/.changeset/update-check-detection-hardening.md new file mode 100644 index 000000000..fa54f8cef --- /dev/null +++ b/.changeset/update-check-detection-hardening.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +The stale-CLI check hardens its install detection: a directory merely named `volta` no longer changes the upgrade hint, the Windows npm-ownership check corroborates against the `openspec.cmd` shim npm actually writes, and a registry redirect from https to plain http is no longer followed. diff --git a/src/core/version-check.ts b/src/core/version-check.ts index 92b23edc2..19ed600a5 100644 --- a/src/core/version-check.ts +++ b/src/core/version-check.ts @@ -182,7 +182,10 @@ function fetchLatestVersion(): Promise { redirectsLeft -= 1; try { const next = new URL(location, target); - if (next.protocol === 'http:' || next.protocol === 'https:') { + // Never follow a downgrade to plain http: a MITM on the reply + // would control the "newer version" answer. + const downgrade = target.protocol === 'https:' && next.protocol === 'http:'; + if (!downgrade && (next.protocol === 'http:' || next.protocol === 'https:')) { send(next); return; } @@ -409,7 +412,13 @@ export function isNpmGlobalInstall( const prefix = npmPrefixFromInstallDir(installDir); if (!prefix) return false; try { - return fs.existsSync(process.platform === 'win32' ? prefix : path.join(prefix, 'bin')); + // Corroborate with something npm itself wrote: the bin dir on POSIX, the + // .cmd shim on Windows. The prefix alone proves nothing — it is just the + // parent of the node_modules dir the CLI resolved from, so a hand-copied + // portable tree would pass and be offered an npm upgrade it never had. + return fs.existsSync( + process.platform === 'win32' ? path.join(prefix, 'openspec.cmd') : path.join(prefix, 'bin') + ); } catch { return false; } @@ -440,7 +449,10 @@ export function detectPackageManager(installDir: string | null): PackageManager const segments = (installDir ?? '').split(/[\\/]/).map((segment) => segment.toLowerCase()); const has = (...names: string[]) => names.some((name) => segments.includes(name)); - if (has('.volta', 'volta')) return 'volta'; + // The undotted spelling exists for Windows (%LOCALAPPDATA%\Volta), whose + // layout nests tools\image; require it so a user or project directory + // merely named "volta" does not steal the install. + if (has('.volta') || (has('volta') && has('tools', 'image'))) return 'volta'; if (has('.bun')) return 'bun'; // These two need a corroborating segment: a directory merely named "pnpm" or // "yarn" (a user's home, a project) is not a global install of one. diff --git a/test/core/version-check.test.ts b/test/core/version-check.test.ts index c8607cd5c..5e2deceaf 100644 --- a/test/core/version-check.test.ts +++ b/test/core/version-check.test.ts @@ -520,6 +520,13 @@ describe('offerCliUpgrade', () => { expect(detectPackageManager(null)).toBe('npm'); }); + it('does not let a user or project directory named after a manager steal the install', () => { + // A person named volta with a plain npm prefix in their home directory: + // the undotted segment alone must not turn the hint into `volta install`. + expect(detectPackageManager('/home/volta/.npm-global/lib/node_modules/pkg')).toBe('npm'); + expect(detectPackageManager('/srv/volta/apps/node_modules/pkg')).toBe('npm'); + }); + it('recognizes the Windows spellings of those install directories', () => { // %LOCALAPPDATA%\Volta, \Yarn\Data, \pnpm-cache — capitalized, undotted, // and nothing like their POSIX equivalents. From d34578181cd51a3f2af606d5511c558a5a5c1fc7 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 16:43:02 -0500 Subject: [PATCH 06/23] chore(cli): export zcodeAdapter from the barrel and sync a completion description zcode was registered but missing from the adapters barrel (its test imported the module directly), and the completion registry still carried the pre-#1062 description for the instructions command. Co-Authored-By: Claude Opus 5 (1M context) --- src/core/command-generation/adapters/index.ts | 1 + src/core/completions/command-registry.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/command-generation/adapters/index.ts b/src/core/command-generation/adapters/index.ts index 358bc8276..43c2e36e6 100644 --- a/src/core/command-generation/adapters/index.ts +++ b/src/core/command-generation/adapters/index.ts @@ -31,3 +31,4 @@ export { lingmaAdapter } from './lingma.js'; export { qwenAdapter } from './qwen.js'; export { roocodeAdapter } from './roocode.js'; export { traeAdapter } from './trae.js'; +export { zcodeAdapter } from './zcode.js'; diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 15bca6120..33db57e87 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -184,7 +184,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ }, { name: 'instructions', - description: 'Output enriched instructions for creating an artifact or applying tasks', + description: 'Output enriched instructions for artifacts, apply, or archive', acceptsPositional: true, positionals: [{ name: 'artifact', optional: true }], flags: [ From b4224ee6258246472a96860ae29dbb72f9f8478e Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 17:08:18 -0500 Subject: [PATCH 07/23] fix(parser): strip a UTF-8 BOM before parsing specs and deltas A BOM-prefixed delta spec (Windows editors, PowerShell Out-File) failed validate and archive with 'No delta sections found' because the first line never matched '## ADDED Requirements'. Strip the BOM in both normalizers, the same way tool detection already does. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/bom-delta-parsing.md | 5 +++++ src/core/parsers/markdown-parser.ts | 3 ++- src/core/parsers/requirement-blocks.ts | 4 +++- test/core/parsers/requirement-blocks.test.ts | 11 +++++++++++ 4 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 .changeset/bom-delta-parsing.md diff --git a/.changeset/bom-delta-parsing.md b/.changeset/bom-delta-parsing.md new file mode 100644 index 000000000..ee6e7307a --- /dev/null +++ b/.changeset/bom-delta-parsing.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Delta and main-spec parsers strip a UTF-8 BOM, so files saved by Windows editors or PowerShell redirects no longer fail with "No delta sections found". diff --git a/src/core/parsers/markdown-parser.ts b/src/core/parsers/markdown-parser.ts index 8dca1ef64..4834f4795 100644 --- a/src/core/parsers/markdown-parser.ts +++ b/src/core/parsers/markdown-parser.ts @@ -21,7 +21,8 @@ export class MarkdownParser { } protected static normalizeContent(content: string): string { - return content.replace(/\r\n?/g, '\n'); + // Strip a UTF-8 BOM so a header on the first line still matches. + return content.replace(/^/, '').replace(/\r\n?/g, '\n'); } parseSpec(name: string): Spec { diff --git a/src/core/parsers/requirement-blocks.ts b/src/core/parsers/requirement-blocks.ts index b47b9ecd4..cb0e79b75 100644 --- a/src/core/parsers/requirement-blocks.ts +++ b/src/core/parsers/requirement-blocks.ts @@ -141,7 +141,9 @@ export interface DeltaPlan { } function normalizeLineEndings(content: string): string { - return content.replace(/\r\n?/g, '\n'); + // Strip a UTF-8 BOM: Windows editors and PowerShell redirects prepend one, + // and it would keep the first line's `## ADDED Requirements` from matching. + return content.replace(/^/, '').replace(/\r\n?/g, '\n'); } /** diff --git a/test/core/parsers/requirement-blocks.test.ts b/test/core/parsers/requirement-blocks.test.ts index 798d70c59..d0f9712cf 100644 --- a/test/core/parsers/requirement-blocks.test.ts +++ b/test/core/parsers/requirement-blocks.test.ts @@ -37,6 +37,17 @@ describe('extractRequirementsSection', () => { }); describe('parseDeltaSpec', () => { + it('strips a UTF-8 BOM so a delta section on the first line still parses', () => { + // Windows editors and PowerShell redirects prepend a BOM; without + // stripping it the first line never matches "## ADDED Requirements" and + // validate reports "No delta sections found" for a well-formed file. + const content = `## ADDED Requirements\n### Requirement: BOM survivor\nThe system SHALL parse.\n\n#### Scenario: Parses\n- **WHEN** a BOM prefixes the file\n- **THEN** the delta is found\n`; + const result = parseDeltaSpec(content); + expect(result.sectionPresence.added).toBe(true); + expect(result.added.length).toBe(1); + expect(result.added[0].name).toBe('BOM survivor'); + }); + it('regression: parses ###Requirement: header with no space in delta ADDED section', () => { const content = `## ADDED Requirements\n###Requirement: NoSpace\nThe system SHALL foo.\n`; const result = parseDeltaSpec(content); From de4249858e93a195c8a550f2484825e1ef345814 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 17:08:18 -0500 Subject: [PATCH 08/23] fix(cli): reject over-long change names with a validation message A 300-character change name surfaced two raw ENAMETOOLONG errno dumps from stat and mkdir. Bound the name at 200 characters in validateChangeName so the failure is a normal validation error. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/change-name-length.md | 5 +++++ src/utils/change-utils.ts | 7 +++++++ test/utils/change-utils.test.ts | 9 +++++++++ 3 files changed, 21 insertions(+) create mode 100644 .changeset/change-name-length.md diff --git a/.changeset/change-name-length.md b/.changeset/change-name-length.md new file mode 100644 index 000000000..cd053ba25 --- /dev/null +++ b/.changeset/change-name-length.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +`openspec new change` rejects names over 200 characters with a validation message instead of surfacing a raw ENAMETOOLONG filesystem error. diff --git a/src/utils/change-utils.ts b/src/utils/change-utils.ts index 11c678baa..f73ba61bc 100644 --- a/src/utils/change-utils.ts +++ b/src/utils/change-utils.ts @@ -67,6 +67,13 @@ export function validateChangeName(name: string): ValidationResult { return { valid: false, error: 'Change name cannot be empty' }; } + // Filesystem directory components cap at 255 bytes and archive prepends a + // date prefix; bounding here turns the failure into a validation message + // instead of a raw ENAMETOOLONG from mkdir. + if (name.length > 200) { + return { valid: false, error: 'Change name is too long (200 characters max)' }; + } + if (!isKebabId(name)) { // Provide specific error messages for common mistakes if (/[A-Z]/.test(name)) { diff --git a/test/utils/change-utils.test.ts b/test/utils/change-utils.test.ts index d07edc396..4f32914aa 100644 --- a/test/utils/change-utils.test.ts +++ b/test/utils/change-utils.test.ts @@ -11,6 +11,15 @@ describe('validateChangeName', () => { expect(result).toEqual({ valid: true }); }); + it('should accept a long-but-bounded name and reject one past the cap', () => { + // Past the cap the failure must be a validation message, not a raw + // ENAMETOOLONG once mkdir hits the 255-byte component limit. + expect(validateChangeName('a'.repeat(200))).toEqual({ valid: true }); + const result = validateChangeName('a'.repeat(201)); + expect(result.valid).toBe(false); + expect(result.error).toContain('too long'); + }); + it('should accept name with multiple segments', () => { const result = validateChangeName('add-user-auth'); expect(result).toEqual({ valid: true }); From 40de2bc69f5194754f164a0fc62a280ec10f5400 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 17:08:18 -0500 Subject: [PATCH 09/23] fix(archive): finish the early-sync no-op rules for MODIFIED and RENAMED Two asymmetries left over from the #1376/#1386/#1437 no-op work: - MODIFIED counted every delta as applied even when the block was byte-equal to the main spec, so a fully early-synced change rewrote the file (normalization churn), printed '~ N modified', and reported specsUpdated: true where its ADDED/REMOVED/RENAMED twins print 'Specs already in sync; no files changed.' Count only real replacements. - RENAMED's already-synced skip (source gone, target present) had no near-miss guard: a case/whitespace variant of the source still in the spec means a typo'd header, and REMOVED already hard-aborts on that signal. Apply the same guard, excluding the target itself so a case-only rename still no-ops. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/modified-noop-counting.md | 5 +++ .changeset/renamed-near-miss-guard.md | 5 +++ src/core/specs-apply.ts | 21 ++++++++- test/core/archive.test.ts | 62 +++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 .changeset/modified-noop-counting.md create mode 100644 .changeset/renamed-near-miss-guard.md diff --git a/.changeset/modified-noop-counting.md b/.changeset/modified-noop-counting.md new file mode 100644 index 000000000..7072974d0 --- /dev/null +++ b/.changeset/modified-noop-counting.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Archive treats a MODIFIED delta whose content already matches the main spec as a no-op: a fully early-synced change now reports "Specs already in sync" instead of rewriting the file and claiming modifications. diff --git a/.changeset/renamed-near-miss-guard.md b/.changeset/renamed-near-miss-guard.md new file mode 100644 index 000000000..b852a8ef1 --- /dev/null +++ b/.changeset/renamed-near-miss-guard.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +An already-synced RENAMED delta aborts when a case/whitespace variant of the source requirement still exists — the same typo guard REMOVED deltas have. diff --git a/src/core/specs-apply.ts b/src/core/specs-apply.ts index b41a80da0..e8d2f3910 100644 --- a/src/core/specs-apply.ts +++ b/src/core/specs-apply.ts @@ -294,6 +294,17 @@ export async function buildUpdatedSpec( // to the baseline (early-sync pattern) — re-applying it is a no-op, // not a failure. Only a missing source AND target is a genuine error. if (nameToBlock.has(to)) { + // Unless a case/whitespace variant of the source still exists (and is + // not the target itself, as in a case-only rename): that is a typo'd + // header, not an early-synced rename — same guard REMOVED applies. + const nearMiss = [...nameToBlock.keys()].find( + (k) => k !== to && foldRequirementName(k) === foldRequirementName(from) + ); + if (nearMiss !== undefined) { + throw new Error( + `${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found, but "### Requirement: ${nameToBlock.get(nearMiss)!.name}" exists; fix the header to match it exactly` + ); + } continue; } throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found`); @@ -344,6 +355,7 @@ export async function buildUpdatedSpec( } // MODIFIED + let modifiedApplied = 0; for (const mod of plan.modified) { const key = normalizeRequirementName(mod.name); const currentBlock = nameToBlock.get(key); @@ -363,6 +375,13 @@ export async function buildUpdatedSpec( `${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${missingScenarios.map(name => `"${name}"`).join(', ')}. Refresh the change spec before archiving to avoid dropping scenarios.` ); } + // Identical content means the modification was already synced to the + // baseline (early-sync pattern) — count only real replacements, so a + // fully synced change still takes the "already in sync" write skip + // instead of churning normalization differences into the file. + if (normalizeBlockRaw(currentBlock.raw) !== normalizeBlockRaw(mod.raw)) { + modifiedApplied++; + } nameToBlock.set(key, mod); } @@ -419,7 +438,7 @@ export async function buildUpdatedSpec( rebuilt, counts: { added: addedApplied, - modified: plan.modified.length, + modified: modifiedApplied, removed: removedApplied, renamed: renamedApplied, }, diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index 8169c3aa8..8937eef39 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -488,6 +488,68 @@ Then expected result happens`; expect(process.exitCode).toBeUndefined(); }); + it('should archive when MODIFIED requirements were already synced to the baseline', async () => { + const changeName = 'early-synced-modify'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'mod-layer'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + const block = `### Requirement: Session handling\nThe system SHALL keep sessions.\n\n#### Scenario: Session persists\n- **WHEN** a user returns\n- **THEN** the session is restored`; + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `# Mod Layer - Changes\n\n## MODIFIED Requirements\n\n${block}\n` + ); + + // Early-sync pattern: the modification is already applied to main. + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'mod-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainSpecContent = `# mod-layer Specification\n\n## Purpose\nSession layer behavior.\n\n## Requirements\n\n${block}\n`; + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpecContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + // An identical MODIFIED block is a no-op: no churned rewrite, no + // claimed update, no "~ 1 modified" in the totals. + const updatedContent = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(updatedContent).toBe(mainSpecContent); + expect(console.log).toHaveBeenCalledWith('Specs already in sync; no files changed.'); + expect(console.log).not.toHaveBeenCalledWith('Specs updated successfully.'); + + const archives = await fs.readdir(path.join(tempDir, 'openspec', 'changes', 'archive')); + expect(archives.some(a => a.includes(changeName))).toBe(true); + expect(process.exitCode).toBeUndefined(); + }); + + it('should abort an already-synced RENAMED when a case variant of the source still exists', async () => { + // FROM missing + TO present normally means the rename was early-synced, + // but a fold-variant of FROM still in the spec means the header is a + // typo - the same near-miss guard REMOVED applies. + const changeName = 'typo-rename'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'rename-layer'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `# Rename Layer - Changes\n\n## RENAMED Requirements\n- FROM: \`### Requirement: cache policy\`\n- TO: \`### Requirement: Eviction policy\`\n` + ); + + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'rename-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainSpecContent = `# rename-layer Specification\n\n## Purpose\nCache behavior.\n\n## Requirements\n\n### Requirement: Cache Policy\nThe system SHALL cache.\n\n#### Scenario: Cached\n- **WHEN** data repeats\n- **THEN** it is served from cache\n\n### Requirement: Eviction policy\nThe system SHALL evict.\n\n#### Scenario: Evicted\n- **WHEN** the cache is full\n- **THEN** old entries are dropped\n`; + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpecContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('RENAMED failed for header "### Requirement: cache policy" - source not found, but "### Requirement: Cache Policy" exists') + ); + expect(process.exitCode).toBe(1); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + const untouched = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(untouched).toBe(mainSpecContent); + }); + it('should abort when a REMOVED header near-misses an existing requirement (case/whitespace typo)', async () => { // A fold-insensitive match in the current spec means the header is a // typo, not an early-synced removal - that case must stay a hard abort. From 2bc03459ec09e11d1e891ee27b70110880e1d5dd Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 17:08:18 -0500 Subject: [PATCH 10/23] fix(validate): stop reporting an unreadable specs dir as 'no deltas' The delta-validation loop swallowed every error as 'if no specs dir, treat as no deltas', so an EACCES capability folder produced the misleading 'Change must have at least one delta' while archive let the same error propagate. Tolerate only ENOENT and ENOTDIR (a stray specs file); anything else stays loud, matching discoverSpecFiles' documented fail-loud contract. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/validator-unreadable-specs.md | 5 +++++ src/core/validation/validator.ts | 12 ++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 .changeset/validator-unreadable-specs.md diff --git a/.changeset/validator-unreadable-specs.md b/.changeset/validator-unreadable-specs.md new file mode 100644 index 000000000..0f584da9c --- /dev/null +++ b/.changeset/validator-unreadable-specs.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +`openspec validate` reports an unreadable specs/ directory as the error it is instead of misdiagnosing it as "no deltas found". diff --git a/src/core/validation/validator.ts b/src/core/validation/validator.ts index 25989f86e..0086c1276 100644 --- a/src/core/validation/validator.ts +++ b/src/core/validation/validator.ts @@ -334,8 +334,16 @@ export class Validator { } } } - } catch { - // If no specs dir, treat as no deltas + } catch (error) { + // A missing specs dir (or a stray `specs` file) means no deltas; + // anything else (EACCES, EIO) must stay loud — discoverSpecFiles + // documents that silently dropping an unreadable capability recreates + // the data-loss class it prevents, and archive lets the same error + // propagate. + const code = (error as NodeJS.ErrnoException)?.code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw error; + } } for (const { path: specPath, sections } of emptySectionSpecs) { From e636339231c63c1316b8dea9af6049f69ba2c962 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 17:08:33 -0500 Subject: [PATCH 11/23] fix(update): say when commands-only delivery leaves a tool with nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under delivery: commands, update removed the skills of adapterless skills-only tools (Hermes, Kimi Code, Vibe, CodeArts, ForgeCode) without a word — leaving zero OpenSpec artifacts while the tool's detection dir kept re-suggesting an init that would also generate nothing. Print the same per-tool configuration correction init already prints, pointing at 'openspec config set delivery both'. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/update-zero-artifact-notice.md | 5 +++++ src/core/update.ts | 18 ++++++++++++++++++ test/core/update.test.ts | 8 ++++++++ 3 files changed, 31 insertions(+) create mode 100644 .changeset/update-zero-artifact-notice.md diff --git a/.changeset/update-zero-artifact-notice.md b/.changeset/update-zero-artifact-notice.md new file mode 100644 index 000000000..e84c1a6bc --- /dev/null +++ b/.changeset/update-zero-artifact-notice.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +`openspec update` with `delivery: commands` prints the same configuration correction as init when it removes the skills of a tool that supports only skills, instead of deleting them silently. diff --git a/src/core/update.ts b/src/core/update.ts index 9d48f621e..a39d42d7a 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -247,6 +247,7 @@ export class UpdateCommand { const updatedTools: string[] = []; const failedTools: Array<{ name: string; error: string }> = []; const skillsInvocableCommandSkips: string[] = []; + const zeroArtifactTools: string[] = []; let removedCommandCount = 0; let removedSkillCount = 0; let removedDeselectedCommandCount = 0; @@ -288,6 +289,13 @@ export class UpdateCommand { // Delete skill directories if delivery is commands-only if (shouldRemoveSkillsForTool(tool.value, delivery)) { removedSkillCount += await this.removeSkillDirs(skillsDir); + // A tool with no command adapter now has zero OpenSpec artifacts; + // say so like init does, rather than deleting its skills silently + // and letting tool detection re-suggest an init that would also + // generate nothing under this delivery setting. + if (!shouldGenerateCommandsForTool(tool.value, delivery)) { + zeroArtifactTools.push(tool.name); + } } // Generate commands if delivery includes commands @@ -348,6 +356,16 @@ export class UpdateCommand { if (removedSkillCount > 0) { console.log(chalk.dim(`Removed: ${removedSkillCount} skill directories (delivery: commands)`)); } + if (zeroArtifactTools.length > 0) { + const names = zeroArtifactTools.join(', '); + console.log( + chalk.yellow( + `No skills or commands remain for ${names}: delivery is set to 'commands' but ` + + `${zeroArtifactTools.length === 1 ? 'it supports' : 'they support'} only skills. ` + + `Run 'openspec config set delivery both' to generate skills.` + ) + ); + } if (removedDeselectedCommandCount > 0) { console.log(chalk.dim(`Removed: ${removedDeselectedCommandCount} command files (deselected workflows)`)); } diff --git a/test/core/update.test.ts b/test/core/update.test.ts index c6f6a6a3e..52a669c09 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -2414,11 +2414,19 @@ More user content after markers. await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + const consoleSpy = vi.spyOn(console, 'log'); await expect(updateCommand.execute(testDir)).resolves.toBeUndefined(); expect(await FileSystemUtils.fileExists( path.join(skillsDir, 'openspec-explore', 'SKILL.md') )).toBe(false); + + // The tool now has zero OpenSpec artifacts; the removal must not be + // silent — update prints the same configuration correction init does. + const logCalls = consoleSpy.mock.calls.flat().map(String); + const correction = logCalls.find((entry) => entry.includes('No skills or commands remain')); + expect(correction).toBeTruthy(); + expect(correction).toContain("openspec config set delivery both"); }); it('should apply config sync when templates are up to date', async () => { From 0d786e8798096f8d3760ca8647d83fd63858bdb6 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 17:08:33 -0500 Subject: [PATCH 12/23] fix(completion): honor $ZSH and $ZSH_CUSTOM for Oh My Zsh installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installer used a set $ZSH only as an is-installed signal and then wrote to ~/.oh-my-zsh regardless, so a custom OMZ location got a freshly created ~/.oh-my-zsh tree that no shell ever loads — and isInstalled/uninstall looked in the same wrong place. Route every path through the $ZSH/$ZSH_CUSTOM-aware helpers. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/zsh-completions-custom-omz.md | 5 +++++ .../completions/installers/zsh-installer.ts | 21 +++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 .changeset/zsh-completions-custom-omz.md diff --git a/.changeset/zsh-completions-custom-omz.md b/.changeset/zsh-completions-custom-omz.md new file mode 100644 index 000000000..12bcc08f1 --- /dev/null +++ b/.changeset/zsh-completions-custom-omz.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +zsh completion install honors `$ZSH` and `$ZSH_CUSTOM`, so Oh My Zsh setups at custom locations get the completion where their shell actually loads it. diff --git a/src/core/completions/installers/zsh-installer.ts b/src/core/completions/installers/zsh-installer.ts index a405af133..b92263e31 100644 --- a/src/core/completions/installers/zsh-installer.ts +++ b/src/core/completions/installers/zsh-installer.ts @@ -35,16 +35,29 @@ export class ZshInstaller { } // Fall back to checking for ~/.oh-my-zsh directory - const ohMyZshPath = path.join(this.homeDir, '.oh-my-zsh'); - try { - const stat = await fs.stat(ohMyZshPath); + const stat = await fs.stat(this.ohMyZshRoot()); return stat.isDirectory(); } catch { return false; } } + /** + * Oh My Zsh exports its root as $ZSH; honor a custom location, or the + * completion lands in a ~/.oh-my-zsh tree that nothing ever loads. + */ + private ohMyZshRoot(): string { + return process.env.ZSH || path.join(this.homeDir, '.oh-my-zsh'); + } + + /** + * The custom dir is separately relocatable via $ZSH_CUSTOM. + */ + private ohMyZshCustomDir(): string { + return process.env.ZSH_CUSTOM || path.join(this.ohMyZshRoot(), 'custom'); + } + /** * Get the appropriate installation path for the completion script * @@ -56,7 +69,7 @@ export class ZshInstaller { if (isOhMyZsh) { // Oh My Zsh custom completions directory return { - path: path.join(this.homeDir, '.oh-my-zsh', 'custom', 'completions', '_openspec'), + path: path.join(this.ohMyZshCustomDir(), 'completions', '_openspec'), isOhMyZsh: true, }; } else { From da45c6851d33669093a063f6dc4b709bfca30d87 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 17:08:33 -0500 Subject: [PATCH 13/23] fix(init): make the static welcome screen wait for the Enter it asks for The static branch printed 'Press Enter to select tools...' and returned immediately, so the Enter landed in the tool picker and submitted the pre-selected set sight-unseen. #1462 routed reduced-motion, OPENSPEC_NO_ANIMATION, --no-animation, NO_COLOR, and narrow-terminal users onto this path. Wait in a TTY; drop the prompt line when there is no TTY to wait on. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/static-welcome-waits.md | 5 +++++ src/ui/welcome-screen.ts | 11 +++++++++-- test/ui/welcome-screen.test.ts | 11 +++++++---- 3 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 .changeset/static-welcome-waits.md diff --git a/.changeset/static-welcome-waits.md b/.changeset/static-welcome-waits.md new file mode 100644 index 000000000..25dfe5535 --- /dev/null +++ b/.changeset/static-welcome-waits.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +The static welcome screen (reduced motion, `--no-animation`, narrow terminals) now waits for the Enter it asks for instead of letting the keystroke submit the tool picker unseen. diff --git a/src/ui/welcome-screen.ts b/src/ui/welcome-screen.ts index e8e9fef2c..9e33dcf4a 100644 --- a/src/ui/welcome-screen.ts +++ b/src/ui/welcome-screen.ts @@ -184,9 +184,16 @@ export async function showWelcomeScreen( const textLines = getWelcomeText(workflows); if (options.animate === false || !canAnimate()) { - // Fallback: show static welcome + // Fallback: show static welcome. The "Press Enter" line is only honest + // when we actually wait; in a TTY, returning immediately would let the + // Enter it asks for fall through into the tool picker and submit the + // pre-selected tools sight-unseen. Without a TTY, drop the line instead. + const staticLines = process.stdin.isTTY + ? textLines + : textLines.filter((line) => !line.includes('Press Enter')); const frame = WELCOME_ANIMATION.frames[3]; // Peak frame - process.stdout.write('\n' + renderFrame(frame, textLines) + '\n\n'); + process.stdout.write('\n' + renderFrame(frame, staticLines) + '\n\n'); + await waitForEnter(); return; } diff --git a/test/ui/welcome-screen.test.ts b/test/ui/welcome-screen.test.ts index 0a4ce5f13..263bb6eea 100644 --- a/test/ui/welcome-screen.test.ts +++ b/test/ui/welcome-screen.test.ts @@ -184,9 +184,12 @@ describe('welcome screen', () => { await showWelcomeScreen(CORE_WORKFLOWS); - expect(useKeypressMock).not.toHaveBeenCalled(); + // Static rendering still waits for the Enter the prompt line asks for; + // otherwise the keystroke falls through into the tool picker (#1462). + expect(useKeypressMock).toHaveBeenCalledOnce(); const output = writtenOutput(); expect(output).toContain('Welcome to OpenSpec'); + expect(output).toContain('Press Enter'); // No cursor-up repaints: the frame is drawn exactly once. expect(output).not.toMatch(/\x1b\[\d+A/); }); @@ -197,7 +200,7 @@ describe('welcome screen', () => { await showWelcomeScreen(CORE_WORKFLOWS); - expect(useKeypressMock).not.toHaveBeenCalled(); + expect(useKeypressMock).toHaveBeenCalledOnce(); expect(writtenOutput()).not.toMatch(/\x1b\[\d+A/); }); @@ -206,7 +209,7 @@ describe('welcome screen', () => { await showWelcomeScreen(CORE_WORKFLOWS, { animate: false }); - expect(useKeypressMock).not.toHaveBeenCalled(); + expect(useKeypressMock).toHaveBeenCalledOnce(); const output = writtenOutput(); expect(output).toContain('Welcome to OpenSpec'); expect(output).not.toMatch(/\x1b\[\d+A/); @@ -222,7 +225,7 @@ describe('welcome screen', () => { await showWelcomeScreen(CORE_WORKFLOWS); - expect(useKeypressMock).not.toHaveBeenCalled(); + expect(useKeypressMock).toHaveBeenCalledOnce(); expect(writtenOutput()).toContain('Welcome to OpenSpec'); } ); From 16594f0c9728fe6f05f3301b2381fa5bc92d3c9a Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 17:08:33 -0500 Subject: [PATCH 14/23] fix(feedback): keep the manual fallback on every gh failure Only missing-gh and unauthenticated flows showed the formatted feedback and pre-filled submission URL; issues-disabled, network, or rate-limit failures printed gh's stderr and discarded the path to submit what the user had already typed. Route those through the same manual fallback, preserving gh's exit code. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/feedback-manual-fallback.md | 5 +++++ src/commands/feedback.ts | 18 ++++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 .changeset/feedback-manual-fallback.md diff --git a/.changeset/feedback-manual-fallback.md b/.changeset/feedback-manual-fallback.md new file mode 100644 index 000000000..1ef59f800 --- /dev/null +++ b/.changeset/feedback-manual-fallback.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +`openspec feedback` shows the formatted text and a pre-filled submission URL on any gh failure (issues disabled, network, rate limit), not only when gh is missing or unauthenticated. diff --git a/src/commands/feedback.ts b/src/commands/feedback.ts index 529260401..86d25042b 100644 --- a/src/commands/feedback.ts +++ b/src/commands/feedback.ts @@ -131,9 +131,13 @@ function isMissingLabelError(error: any): boolean { } /** - * Report a gh CLI failure and exit, preserving gh's exit code + * Report a gh CLI failure and exit, preserving gh's exit code. + * + * gh failed after the user already typed their feedback (issues disabled, + * network, rate limit, ...), so show the same manual-submission path the + * missing-gh and unauthenticated flows get instead of discarding the text. */ -function reportGhFailure(error: any): void { +function reportGhFailure(error: any, title: string, body: string): void { // Display the error output from gh CLI if (error.stderr) { console.error(error.stderr.toString()); @@ -141,6 +145,12 @@ function reportGhFailure(error: any): void { console.error(error.message); } + displayFormattedFeedback(title, body); + + const manualUrl = generateManualSubmissionUrl(title, body); + console.log('Please submit your feedback manually:'); + console.log(manualUrl); + // Exit with the same code as gh CLI process.exit(error.status ?? 1); } @@ -181,7 +191,7 @@ function submitViaGhCli(title: string, body: string): void { issueUrl = createIssue(title, body, ['feedback']); } catch (error: any) { if (!isMissingLabelError(error)) { - reportGhFailure(error); + reportGhFailure(error, title, body); return; } @@ -191,7 +201,7 @@ function submitViaGhCli(title: string, body: string): void { issueUrl = createIssue(title, body, []); labelApplied = false; } catch (retryError: any) { - reportGhFailure(retryError); + reportGhFailure(retryError, title, body); return; } } From fd8985823320da267a93083c493b7016214cb794 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 17:26:24 -0500 Subject: [PATCH 15/23] test(update): model the npm shim in the Windows prefix fixture The ownership corroboration now checks for the openspec.cmd shim npm writes beside node_modules; the Homebrew-prefix fixture built the layout without it, so the test failed on windows-pwsh. Write the shim in the fixture and pin the inverse: the same shape with nothing npm wrote (a hand-copied portable tree) is not an npm install. Co-Authored-By: Claude Opus 5 (1M context) --- test/core/version-check.test.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/test/core/version-check.test.ts b/test/core/version-check.test.ts index 5e2deceaf..048358ace 100644 --- a/test/core/version-check.test.ts +++ b/test/core/version-check.test.ts @@ -440,7 +440,13 @@ describe('offerCliUpgrade', () => { ? path.join(prefix, 'node_modules', '@fission-ai', 'openspec') : path.join(prefix, 'lib', 'node_modules', '@fission-ai', 'openspec'); fs.mkdirSync(installed, { recursive: true }); - fs.mkdirSync(path.join(prefix, 'bin'), { recursive: true }); + if (isWindows) { + // npm writes the .cmd shim beside node_modules; it is what separates + // a real prefix from a hand-copied portable tree. + fs.writeFileSync(path.join(prefix, 'openspec.cmd'), '@echo off\n'); + } else { + fs.mkdirSync(path.join(prefix, 'bin'), { recursive: true }); + } expect(npmPrefixFromInstallDir(installed)).toBe(prefix); // Deliberately an unrelated root, standing in for the Cellar path. @@ -450,6 +456,21 @@ describe('offerCliUpgrade', () => { expect(npmPrefixFromInstallDir(path.join(HOME_ROOT, 'not', 'an', 'install'))).toBeNull(); expect(npmPrefixFromInstallDir(null)).toBeNull(); + + // The same shape with nothing npm wrote (no bin dir, no .cmd shim) is a + // hand-copied portable tree, not an npm install — no upgrade offer. + const portable = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-portable-')); + try { + const copied = isWindows + ? path.join(portable, 'node_modules', '@fission-ai', 'openspec') + : path.join(portable, 'lib', 'node_modules', '@fission-ai', 'openspec'); + fs.mkdirSync(copied, { recursive: true }); + expect( + isNpmGlobalInstall(copied, [path.join(GLOBAL_ROOT, 'lib', 'node_modules')]) + ).toBe(false); + } finally { + fs.rmSync(portable, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } } finally { fs.rmSync(prefix, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } From 07607d69628eda79cf1e1e13915dc7959370d153 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 17:48:29 -0500 Subject: [PATCH 16/23] fix(update): require volta's full tools/image layout for the undotted spelling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The corroboration used has('tools', 'image'), which is some() — volta AND (tools OR image) — so /srv/volta/tools/apps/... still classified as a Volta install and swallowed the upgrade offer. Require both segments, matching the real %LOCALAPPDATA%\Volta\tools\image layout. Co-Authored-By: Claude Opus 5 (1M context) --- src/core/version-check.ts | 7 ++++--- test/core/version-check.test.ts | 3 +++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/core/version-check.ts b/src/core/version-check.ts index 19ed600a5..5033e3bb6 100644 --- a/src/core/version-check.ts +++ b/src/core/version-check.ts @@ -450,9 +450,10 @@ export function detectPackageManager(installDir: string | null): PackageManager const has = (...names: string[]) => names.some((name) => segments.includes(name)); // The undotted spelling exists for Windows (%LOCALAPPDATA%\Volta), whose - // layout nests tools\image; require it so a user or project directory - // merely named "volta" does not steal the install. - if (has('.volta') || (has('volta') && has('tools', 'image'))) return 'volta'; + // layout nests tools\image; require both segments so a user or project + // directory merely named "volta" (even one with its own "tools" dir) does + // not steal the install. + if (has('.volta') || (has('volta') && has('tools') && has('image'))) return 'volta'; if (has('.bun')) return 'bun'; // These two need a corroborating segment: a directory merely named "pnpm" or // "yarn" (a user's home, a project) is not a global install of one. diff --git a/test/core/version-check.test.ts b/test/core/version-check.test.ts index 048358ace..3f3231649 100644 --- a/test/core/version-check.test.ts +++ b/test/core/version-check.test.ts @@ -546,6 +546,9 @@ describe('offerCliUpgrade', () => { // the undotted segment alone must not turn the hint into `volta install`. expect(detectPackageManager('/home/volta/.npm-global/lib/node_modules/pkg')).toBe('npm'); expect(detectPackageManager('/srv/volta/apps/node_modules/pkg')).toBe('npm'); + // Even alongside a generic "tools" dir — only volta's full tools/image + // layout counts. + expect(detectPackageManager('/srv/volta/tools/apps/node_modules/pkg')).toBe('npm'); }); it('recognizes the Windows spellings of those install directories', () => { From 2ca3354c9918fab07452dcef381d26c2375669d8 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 17:48:29 -0500 Subject: [PATCH 17/23] fix(adapters): escape control characters in Gemini multiline prompts escapeTomlMultilineBasicString handled backslashes and quote-triples but not the C0 controls that are as invalid in a multiline basic string as in a single-line one. Reuse TOML_CONTROL_CHARS, applied last so the escapes it introduces are not re-doubled. Co-Authored-By: Claude Opus 5 (1M context) --- src/core/command-generation/adapters/gemini.ts | 10 ++++++++-- test/core/command-generation/adapters.test.ts | 10 ++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/core/command-generation/adapters/gemini.ts b/src/core/command-generation/adapters/gemini.ts index 96857f4bf..9689c293f 100644 --- a/src/core/command-generation/adapters/gemini.ts +++ b/src/core/command-generation/adapters/gemini.ts @@ -30,10 +30,16 @@ function escapeTomlBasicString(value: string): string { /** * Multiline basic strings keep raw newlines and tabs, but backslashes are - * still escape-active and any run of three quotes would end the string. + * still escape-active, any run of three quotes would end the string, and the + * same control characters are invalid as in single-line basic strings. + * Control chars are escaped last so their introduced backslashes are not + * re-doubled. */ function escapeTomlMultilineBasicString(value: string): string { - return value.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"'); + return value + .replace(/\\/g, '\\\\') + .replace(/"""/g, '""\\"') + .replace(TOML_CONTROL_CHARS, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`); } /** diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index dff8008df..b0348f6fd 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -437,6 +437,16 @@ describe('command-generation/adapters', () => { const delimiters = output.match(/(? { + const output = geminiAdapter.formatFile({ + ...sampleContent, + body: 'null:\u0000 vt:\u000b end', + }); + expect(output).toContain('null:\\u0000 vt:\\u000b end'); + expect(output).not.toContain('\u0000'); + expect(output).not.toContain('\u000b'); + }); }); describe('githubCopilotAdapter', () => { From a52b64d4024475d6d6e39f02675acaa205714a91 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 17:48:29 -0500 Subject: [PATCH 18/23] fix(completion): finish the $ZSH_CUSTOM support and isolate it in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fpath verification advice still grepped the literal custom/completions, which a relocated $ZSH_CUSTOM need never contain — grep the actual directory instead. The installer tests cleared only $ZSH, so on a machine exporting $ZSH_CUSTOM they would have written into (and deleted from) the developer's real OMZ custom dir — the same leakage class #1400 fixed for $ZSH. Clear/restore both, and pin the custom-location paths with two new tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../completions/installers/zsh-installer.ts | 4 +- .../installers/zsh-installer.test.ts | 37 ++++++++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/core/completions/installers/zsh-installer.ts b/src/core/completions/installers/zsh-installer.ts index b92263e31..2b1dd0acc 100644 --- a/src/core/completions/installers/zsh-installer.ts +++ b/src/core/completions/installers/zsh-installer.ts @@ -343,7 +343,9 @@ export class ZshInstaller { return [ 'Note: Oh My Zsh typically auto-loads completions from custom/completions.', `Verify that ${completionsDir} is in your fpath by running:`, - ' echo $fpath | grep "custom/completions"', + // Grep for the actual directory: a relocated $ZSH_CUSTOM need not + // contain the literal "custom/completions". + ` echo $fpath | grep "${completionsDir}"`, '', 'If not found, completions may not work. Restart your shell to ensure changes take effect.', ]; diff --git a/test/core/completions/installers/zsh-installer.test.ts b/test/core/completions/installers/zsh-installer.test.ts index 91100d03e..07348ee9b 100644 --- a/test/core/completions/installers/zsh-installer.test.ts +++ b/test/core/completions/installers/zsh-installer.test.ts @@ -8,12 +8,16 @@ describe('ZshInstaller', () => { let testHomeDir: string; let installer: ZshInstaller; let originalZsh: string | undefined; + let originalZshCustom: string | undefined; beforeEach(async () => { - // Clear $ZSH (set by a real Oh My Zsh install) so isOhMyZshInstalled() - // falls through to the isolated test home directory + // Clear $ZSH and $ZSH_CUSTOM (set by a real Oh My Zsh install) so the + // installer resolves against the isolated test home directory instead of + // reading — or writing into — the developer's real OMZ tree originalZsh = process.env.ZSH; delete process.env.ZSH; + originalZshCustom = process.env.ZSH_CUSTOM; + delete process.env.ZSH_CUSTOM; // Create a temporary home directory for testing testHomeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-zsh-test-')); @@ -27,6 +31,11 @@ describe('ZshInstaller', () => { } else { delete process.env.ZSH; } + if (originalZshCustom !== undefined) { + process.env.ZSH_CUSTOM = originalZshCustom; + } else { + delete process.env.ZSH_CUSTOM; + } // Clean up test directory await fs.rm(testHomeDir, { recursive: true, force: true }); @@ -83,6 +92,30 @@ describe('ZshInstaller', () => { expect(result.isOhMyZsh).toBe(false); expect(result.path).toBe(path.join(testHomeDir, '.zsh', 'completions', '_openspec')); }); + + it('should honor $ZSH for an Oh My Zsh install at a custom location', async () => { + // A relocated OMZ exports $ZSH; writing under ~/.oh-my-zsh instead + // would create a tree that no shell ever loads. + const customRoot = path.join(testHomeDir, 'dotfiles', 'omz'); + process.env.ZSH = customRoot; + + const result = await installer.getInstallationPath(); + + expect(result.isOhMyZsh).toBe(true); + expect(result.path).toBe(path.join(customRoot, 'custom', 'completions', '_openspec')); + }); + + it('should honor $ZSH_CUSTOM over the derived custom dir', async () => { + process.env.ZSH = path.join(testHomeDir, 'dotfiles', 'omz'); + process.env.ZSH_CUSTOM = path.join(testHomeDir, 'dotfiles', 'omz-custom'); + + const result = await installer.getInstallationPath(); + + expect(result.isOhMyZsh).toBe(true); + expect(result.path).toBe( + path.join(testHomeDir, 'dotfiles', 'omz-custom', 'completions', '_openspec') + ); + }); }); describe('backupExistingFile', () => { From e033a4d4e435ee907dac44d3b0bc52aa07453ec9 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 17:48:30 -0500 Subject: [PATCH 19/23] chore(release): correct the hermes and zcode changeset wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes is skills-only (no command adapter), and zcode's namespaced commands register /opsx:, not /opsx-* — the release notes must not reintroduce the invocation-spelling confusion #1471 removed. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/add-hermes-tool.md | 2 +- .changeset/add-zcode-tool.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/add-hermes-tool.md b/.changeset/add-hermes-tool.md index 38b51340a..eed0b2213 100644 --- a/.changeset/add-hermes-tool.md +++ b/.changeset/add-hermes-tool.md @@ -2,4 +2,4 @@ "@fission-ai/openspec": minor --- -Add Hermes Agent as a supported AI tool with skills and command generation. +Add Hermes Agent as a supported AI tool: `openspec init --tools hermes` installs the workflow skills (Hermes is skills-only and invokes them directly). diff --git a/.changeset/add-zcode-tool.md b/.changeset/add-zcode-tool.md index 8caf5759c..4a39b7703 100644 --- a/.changeset/add-zcode-tool.md +++ b/.changeset/add-zcode-tool.md @@ -2,4 +2,4 @@ "@fission-ai/openspec": minor --- -Add ZCode as a supported AI tool: `openspec init --tools zcode` generates its skills and `/opsx-*` commands. +Add ZCode as a supported AI tool: `openspec init --tools zcode` generates its skills and `/opsx:*` commands. From 40657aa6896581d4711f6d5565e6b0bfed8f10d1 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 17:48:30 -0500 Subject: [PATCH 20/23] test(feedback): pin the manual fallback on a non-label gh failure The new reportGhFailure output (formatted feedback + pre-filled URL) had no coverage; the network-failure test now asserts it. Co-Authored-By: Claude Opus 5 (1M context) --- test/commands/feedback.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/commands/feedback.test.ts b/test/commands/feedback.test.ts index 7545ecc52..51fe40cd9 100644 --- a/test/commands/feedback.test.ts +++ b/test/commands/feedback.test.ts @@ -344,6 +344,16 @@ describe('FeedbackCommand', () => { // A non-label failure must NOT be retried expect(mockExecFileSync).toHaveBeenCalledTimes(1); + + // ...and must not discard the typed feedback: the manual-submission + // fallback (formatted text + pre-filled URL) is shown like the + // missing-gh and unauthenticated flows. + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Please submit your feedback manually:') + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('github.com/Fission-AI/OpenSpec/issues/new') + ); }); it('should not retry when the feedback text mentions the label error', async () => { From 3de75e022a20e763cdfa103c415e65c2edad4ef2 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 18:19:54 -0500 Subject: [PATCH 21/23] fix(completion): match fpath entries as literal strings in the OMZ guidance The verification advice interpolated the completions dir into grep "" where regex metacharacters make the check unreliable and quotes could break the displayed command. Print one fpath entry per line and match with grep -F on a shell-quoted literal. Co-Authored-By: Claude Opus 5 (1M context) --- src/core/completions/installers/zsh-installer.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/core/completions/installers/zsh-installer.ts b/src/core/completions/installers/zsh-installer.ts index 2b1dd0acc..3b6d87d67 100644 --- a/src/core/completions/installers/zsh-installer.ts +++ b/src/core/completions/installers/zsh-installer.ts @@ -340,12 +340,14 @@ export class ZshInstaller { * @returns Array of guidance strings, or undefined if not needed */ private generateOhMyZshFpathGuidance(completionsDir: string): string[] | undefined { + // One fpath entry per line, matched as a literal: a relocated $ZSH_CUSTOM + // need not contain "custom/completions", and the path may hold characters + // grep would otherwise read as a pattern. Single-quoted for the shell. + const quotedDir = `'${completionsDir.replace(/'/g, `'\\''`)}'`; return [ 'Note: Oh My Zsh typically auto-loads completions from custom/completions.', `Verify that ${completionsDir} is in your fpath by running:`, - // Grep for the actual directory: a relocated $ZSH_CUSTOM need not - // contain the literal "custom/completions". - ` echo $fpath | grep "${completionsDir}"`, + ` printf '%s\\n' $fpath | grep -F ${quotedDir}`, '', 'If not found, completions may not work. Restart your shell to ensure changes take effect.', ]; From 2f7352fbe659da340aa0c34f298c6be0767996e3 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 18:41:11 -0500 Subject: [PATCH 22/23] fix(adapters): never emit a bare carriage return in Gemini TOML prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lone CR is illegal in a multiline basic string — Python 3.13 tomllib rejects the file — and the control-char pass deliberately skipped it on the assumption it only appears as CRLF. Normalize CRLF to LF and escape any remaining CR as \r. The escaping guarantee is now parser-backed: smol-toml (new devDependency) round-trips every hostile body in the regression matrix (lone CR, CRLF, CR before a quote run, NUL/VT/FF, trailing backslash, four- and five-quote runs), and the same outputs were verified against Python tomllib. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 1 + pnpm-lock.yaml | 9 ++++ .../command-generation/adapters/gemini.ts | 9 ++-- test/core/command-generation/adapters.test.ts | 43 +++++++++++++------ 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 4f7522c8e..fc89329e1 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@types/node": "^20.19.43", "@vitest/ui": "^3.2.6", "eslint": "^10.5.0", + "smol-toml": "^1.7.1", "typescript": "^6.0.3", "typescript-eslint": "^8.65.0", "vitest": "^3.2.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3706b240d..83f71fa15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: eslint: specifier: ^10.5.0 version: 10.7.0 + smol-toml: + specifier: ^1.7.1 + version: 1.7.1 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -1382,6 +1385,10 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + smol-toml@1.7.1: + resolution: {integrity: sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2896,6 +2903,8 @@ snapshots: slash@3.0.0: {} + smol-toml@1.7.1: {} + source-map-js@1.2.1: {} spawndamnit@3.0.1: diff --git a/src/core/command-generation/adapters/gemini.ts b/src/core/command-generation/adapters/gemini.ts index 9689c293f..d3a403051 100644 --- a/src/core/command-generation/adapters/gemini.ts +++ b/src/core/command-generation/adapters/gemini.ts @@ -31,14 +31,17 @@ function escapeTomlBasicString(value: string): string { /** * Multiline basic strings keep raw newlines and tabs, but backslashes are * still escape-active, any run of three quotes would end the string, and the - * same control characters are invalid as in single-line basic strings. - * Control chars are escaped last so their introduced backslashes are not - * re-doubled. + * same control characters are invalid as in single-line basic strings — a + * lone carriage return included (only LF and CRLF may appear raw; CRLF is + * normalized away so the emitted file is single-convention). Escapes are + * introduced after backslash-doubling so they are not re-doubled. */ function escapeTomlMultilineBasicString(value: string): string { return value + .replace(/\r\n/g, '\n') .replace(/\\/g, '\\\\') .replace(/"""/g, '""\\"') + .replace(/\r/g, '\\r') .replace(TOML_CONTROL_CHARS, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`); } diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index b0348f6fd..f4d946e56 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -35,6 +35,7 @@ import type { import { CommandAdapterRegistry } from '../../../src/core/command-generation/registry.js'; import { generateCommand } from '../../../src/core/command-generation/generator.js'; import { parse as parseYaml } from 'yaml'; +import { parse as parseToml } from 'smol-toml'; describe('command-generation/adapters', () => { const sampleContent: CommandContent = { @@ -423,30 +424,46 @@ describe('command-generation/adapters', () => { // Basic strings are escape-active: quotes, backslashes, and newlines // must be written as escapes or the file stops parsing as TOML. expect(output).toContain('description = "Say \\"hi\\" to C:\\\\Users and\\nmore"'); + expect((parseToml(output) as { description: string }).description).toBe( + 'Say "hi" to C:\\Users and\nmore' + ); }); it('keeps the prompt a single multiline string when the body carries fences and backslashes', () => { - const output = geminiAdapter.formatFile({ - ...sampleContent, - body: 'Windows path C:\\temp and a quote run: """ done', - }); + const body = 'Windows path C:\\temp and a quote run: """ done'; + const output = geminiAdapter.formatFile({ ...sampleContent, body }); // Backslashes must be escaped and no unescaped quote-triple may remain, // or the """ delimiter ends the prompt early. expect(output).toContain('C:\\\\temp'); expect(output).toContain('""\\" done'); const delimiters = output.match(/(? = [ + ['control characters', 'null:\u0000 vt:\u000b ff:\u000c end', 'null:\u0000 vt:\u000b ff:\u000c end'], + // A lone CR is illegal raw in a multiline basic string (only LF and + // CRLF may appear); Python tomllib rejects it — so must never be + // emitted bare. + ['a lone carriage return', 'a\rb', 'a\rb'], + ['CRLF line endings (normalized to LF)', 'line one\r\nline two\r\n', 'line one\nline two\n'], + ['a CR before a quote run', 'x\r""" y', 'x\r""" y'], + ['a trailing backslash', 'ends with a backslash \\', 'ends with a backslash \\'], + ['quote runs of four and five', 'four """" five """""', 'four """" five """""'], + ]; - it('escapes control characters invalid inside a multiline basic string', () => { - const output = geminiAdapter.formatFile({ - ...sampleContent, - body: 'null:\u0000 vt:\u000b end', + for (const [label, body, expected] of HOSTILE_BODIES) { + it(`emits parseable TOML for a body with ${label}`, () => { + const output = geminiAdapter.formatFile({ ...sampleContent, body }); + const parsed = parseToml(output) as { description: string; prompt: string }; + expect(parsed.prompt).toBe(`${expected}\n`); + expect(parsed.description).toBe(sampleContent.description); }); - expect(output).toContain('null:\\u0000 vt:\\u000b end'); - expect(output).not.toContain('\u0000'); - expect(output).not.toContain('\u000b'); - }); + } }); describe('githubCopilotAdapter', () => { From 60498218e7bf75507a196ad9c11e2877d984e281 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 18:44:46 -0500 Subject: [PATCH 23/23] build(nix): update the pnpm deps hash for the smol-toml devDependency The lockfile changed, so the fixed-output derivation hash moved; value taken from the CI mismatch report. Co-Authored-By: Claude Opus 5 (1M context) --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index dab23fe8e..dc02ee814 100644 --- a/flake.nix +++ b/flake.nix @@ -51,7 +51,7 @@ inherit (finalAttrs) pname version src; pnpm = pkgs.pnpm_9; fetcherVersion = 3; - hash = "sha256-OUK3rXD0xjw2PPGQSzEeRnzN06SSKFRIkT0XHSIgDBU="; + hash = "sha256-z9NIWAY1KODgALBML1bBFpM2K9N7Z4L9jFBJC/t+Mww="; }; nativeBuildInputs = with pkgs; [