@fission-ai/openspec 1.13.1 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/dist/cli/index.js +39 -1
- package/dist/commands/spec.js +1 -0
- package/dist/commands/workflow/instructions.js +66 -18
- package/dist/commands/workflow/shared.d.ts +7 -0
- package/dist/commands/workflow/status.js +15 -2
- package/dist/core/archive.js +258 -78
- package/dist/core/artifact-graph/instruction-loader.d.ts +7 -0
- package/dist/core/artifact-graph/instruction-loader.js +18 -1
- package/dist/core/artifact-graph/outputs.d.ts +1 -1
- package/dist/core/artifact-graph/outputs.js +38 -8
- package/dist/core/change-metadata/schema.d.ts +3 -0
- package/dist/core/change-metadata/schema.js +10 -0
- package/dist/core/change-status-policy.d.ts +8 -0
- package/dist/core/change-status-policy.js +33 -2
- package/dist/core/command-generation/adapters/atomcode.d.ts +24 -0
- package/dist/core/command-generation/adapters/atomcode.js +46 -0
- package/dist/core/command-generation/adapters/codestudio.d.ts +13 -0
- package/dist/core/command-generation/adapters/codestudio.js +27 -0
- package/dist/core/command-generation/adapters/continue.js +4 -0
- package/dist/core/command-generation/adapters/easycode.d.ts +17 -0
- package/dist/core/command-generation/adapters/easycode.js +28 -0
- package/dist/core/command-generation/adapters/gemini.js +1 -35
- package/dist/core/command-generation/adapters/gigacode.d.ts +17 -0
- package/dist/core/command-generation/adapters/gigacode.js +31 -0
- package/dist/core/command-generation/adapters/index.d.ts +4 -0
- package/dist/core/command-generation/adapters/index.js +4 -0
- package/dist/core/command-generation/adapters/kilocode.d.ts +1 -1
- package/dist/core/command-generation/adapters/kilocode.js +2 -2
- package/dist/core/command-generation/registry.js +8 -0
- package/dist/core/command-generation/toml.d.ts +19 -0
- package/dist/core/command-generation/toml.js +39 -0
- package/dist/core/command-surface.js +1 -1
- package/dist/core/completions/command-registry.js +19 -0
- package/dist/core/completions/installers/zsh-installer.js +4 -2
- package/dist/core/config-prompts.js +6 -6
- package/dist/core/config.js +11 -1
- package/dist/core/init.js +44 -14
- package/dist/core/legacy-cleanup.d.ts +1 -1
- package/dist/core/legacy-cleanup.js +40 -11
- package/dist/core/list.d.ts +2 -0
- package/dist/core/list.js +49 -23
- package/dist/core/parsers/markdown-parser.js +5 -0
- package/dist/core/parsers/requirement-blocks.d.ts +40 -3
- package/dist/core/parsers/requirement-blocks.js +74 -25
- package/dist/core/project-config.js +45 -1
- package/dist/core/references.js +3 -1
- package/dist/core/root-selection.d.ts +6 -0
- package/dist/core/root-selection.js +16 -0
- package/dist/core/schemas/base.schema.d.ts +3 -0
- package/dist/core/schemas/base.schema.js +6 -0
- package/dist/core/schemas/change.schema.d.ts +8 -0
- package/dist/core/schemas/spec.schema.d.ts +2 -0
- package/dist/core/specs-apply.d.ts +20 -0
- package/dist/core/specs-apply.js +19 -6
- package/dist/core/templates/workflows/apply-change.js +5 -2
- package/dist/core/templates/workflows/archive-change.js +56 -20
- package/dist/core/templates/workflows/bulk-archive-change.js +34 -16
- package/dist/core/templates/workflows/continue-change.js +0 -2
- package/dist/core/templates/workflows/explore.js +2 -2
- package/dist/core/templates/workflows/ff-change.js +2 -2
- package/dist/core/templates/workflows/onboard.js +9 -4
- package/dist/core/templates/workflows/update-change.js +21 -9
- package/dist/core/templates/workflows/verify-change.js +115 -52
- package/dist/core/update.js +20 -7
- package/dist/core/validation/constants.d.ts +1 -1
- package/dist/core/validation/constants.js +1 -1
- package/dist/core/validation/purpose-placeholder.js +24 -9
- package/dist/core/validation/validator.js +15 -6
- package/dist/core/version-check.d.ts +23 -1
- package/dist/core/version-check.js +112 -26
- package/dist/core/view.d.ts +2 -0
- package/dist/core/view.js +62 -4
- package/dist/utils/change-metadata.d.ts +20 -1
- package/dist/utils/change-metadata.js +57 -1
- package/dist/utils/command-references.d.ts +1 -1
- package/dist/utils/command-references.js +2 -2
- package/dist/utils/file-system.js +20 -3
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/line-endings.d.ts +34 -0
- package/dist/utils/line-endings.js +46 -0
- package/package.json +2 -2
- package/schemas/spec-driven/schema.yaml +10 -1
- package/schemas/spec-driven/templates/proposal.md +6 -3
package/dist/core/config.js
CHANGED
|
@@ -19,29 +19,37 @@ export const OPENSPEC_MARKERS = {
|
|
|
19
19
|
};
|
|
20
20
|
export const AI_TOOLS = [
|
|
21
21
|
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq', requiresIdeRestart: true },
|
|
22
|
+
{ name: 'Amp', value: 'amp', available: true, successLabel: 'Amp', skillsDir: '.agents', detectionPaths: ['.amp', '.agents/skills'] },
|
|
22
23
|
// Antigravity moved workspace skills and workflows from `.agent` to the
|
|
23
24
|
// shared `.agents` root in v1.20.5. Detection keys off `.agent` and
|
|
24
25
|
// `.agents/workflows` rather than the bare `.agents` root: that root is
|
|
25
26
|
// shared with Codex, Zed, and the vendor-neutral target, so its presence
|
|
26
27
|
// alone says nothing about Antigravity.
|
|
27
28
|
{ name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agents', legacySkillsDirs: ['.agent'], detectionPaths: ['.agent', '.agents/workflows'], requiresIdeRestart: true },
|
|
29
|
+
{ name: 'AtomCode', value: 'atomcode', available: true, successLabel: 'AtomCode', skillsDir: '.atomcode' },
|
|
28
30
|
{ name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie', skillsDir: '.augment' },
|
|
29
|
-
{ name: 'Bob
|
|
31
|
+
{ name: 'IBM Bob', value: 'bob', available: true, successLabel: 'IBM Bob', skillsDir: '.bob' },
|
|
30
32
|
{ name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' },
|
|
31
33
|
{ name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline', requiresIdeRestart: true },
|
|
32
34
|
{ name: 'Command Code', value: 'command-code', available: true, successLabel: 'Command Code', skillsDir: '.commandcode' },
|
|
33
35
|
{ name: 'CodeArts', value: 'codeartsagent', available: true, successLabel: 'CodeArts', skillsDir: '.codeartsdoer' },
|
|
34
36
|
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.agents', legacySkillsDirs: ['.codex'], detectionPaths: ['.agents/skills', '.codex/skills'] },
|
|
37
|
+
{ name: 'DeepSeek Harness', value: 'dsh', available: true, successLabel: 'DeepSeek Harness', skillsDir: '.dsh' },
|
|
35
38
|
{ name: 'Devin Desktop (formerly Windsurf)', value: 'devin', available: true, successLabel: 'Devin Desktop', skillsDir: '.devin', detectionPaths: ['.devin', '.windsurf'], requiresIdeRestart: true },
|
|
36
39
|
{ name: 'ForgeCode', value: 'forgecode', available: true, successLabel: 'ForgeCode', skillsDir: '.forge' },
|
|
37
40
|
{ name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code', skillsDir: '.codebuddy' },
|
|
41
|
+
{ name: 'Code Studio', value: 'codestudio', available: true, successLabel: 'Code Studio', skillsDir: '.codestudio', requiresIdeRestart: true },
|
|
38
42
|
{ name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)', skillsDir: '.continue', requiresIdeRestart: true },
|
|
39
43
|
{ name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict', skillsDir: '.cospec', requiresIdeRestart: true },
|
|
40
44
|
{ name: 'Crush', value: 'crush', available: true, successLabel: 'Crush', skillsDir: '.crush' },
|
|
41
45
|
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor', skillsDir: '.cursor', requiresIdeRestart: true },
|
|
46
|
+
{ name: 'EasyCode', value: 'easycode', available: true, successLabel: 'EasyCode', skillsDir: '.easycode' },
|
|
42
47
|
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory' },
|
|
43
48
|
{ name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' },
|
|
44
49
|
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github', detectionPaths: ['.github/copilot-instructions.md', '.github/instructions', '.github/workflows/copilot-setup-steps.yml', '.github/prompts', '.github/agents', '.github/skills', '.github/.mcp.json'], requiresIdeRestart: true },
|
|
50
|
+
{ name: 'GigaCode', value: 'gigacode', available: true, successLabel: 'GigaCode', skillsDir: '.gigacode' },
|
|
51
|
+
{ name: 'Grok Build', value: 'grok', available: true, successLabel: 'Grok Build', skillsDir: '.grok' },
|
|
52
|
+
{ name: 'GSD', value: 'gsd', available: true, successLabel: 'GSD', skillsDir: '.agents', detectionPaths: ['.gsd'] },
|
|
45
53
|
{ name: 'Hermes Agent', value: 'hermes', available: true, successLabel: 'Hermes Agent', skillsDir: '.hermes', detectionPaths: ['.hermes', 'HERMES.md', '.hermes.md'], setupNote: "Hermes only loads skills from ~/.hermes/skills by default. Add this project's .hermes/skills directory to skills.external_dirs in ~/.hermes/config.yaml so Hermes picks up the generated OpenSpec skills." },
|
|
46
54
|
{ name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' },
|
|
47
55
|
{ name: 'Junie', value: 'junie', available: true, successLabel: 'Junie', skillsDir: '.junie', requiresIdeRestart: true },
|
|
@@ -60,6 +68,8 @@ export const AI_TOOLS = [
|
|
|
60
68
|
{ name: 'Rovo Dev CLI', value: 'rovodev', available: true, successLabel: 'Rovo Dev CLI', skillsDir: '.rovodev', detectionPaths: ['.rovodev/skills', '.rovodev'] },
|
|
61
69
|
{ name: 'Zoo Code', value: 'roocode', available: true, successLabel: 'Zoo Code', skillsDir: '.roo', requiresIdeRestart: true },
|
|
62
70
|
{ name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae', requiresIdeRestart: true },
|
|
71
|
+
{ name: 'Veai', value: 'veai', available: true, successLabel: 'Veai', skillsDir: '.veai' },
|
|
72
|
+
{ name: 'Warp', value: 'warp', available: true, successLabel: 'Warp', skillsDir: '.warp', detectionPaths: ['.warp', 'WARP.md'] },
|
|
63
73
|
{ name: 'Zed Agent', value: 'zed', available: true, successLabel: 'Zed Agent', skillsDir: '.agents', detectionPaths: ['.zed', '.agents/skills'] },
|
|
64
74
|
{ name: 'ZCode', value: 'zcode', available: true, successLabel: 'ZCode', skillsDir: '.zcode' },
|
|
65
75
|
// Vendor-neutral target for assistants that read the shared `.agents` root.
|
package/dist/core/init.js
CHANGED
|
@@ -12,6 +12,7 @@ import { createRequire } from 'module';
|
|
|
12
12
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
13
13
|
import { classifyOpenSpecDir, MAX_CONTEXT_SIZE, readProjectConfig, storePointerProblem, } from './project-config.js';
|
|
14
14
|
import { findRepoPlanningRootSync } from './planning-home.js';
|
|
15
|
+
import { resolveOpenSpecRoot } from './root-selection.js';
|
|
15
16
|
import { ANCHORED_OPENSPEC_DIRS, ensureDirectoryAnchor } from './openspec-root.js';
|
|
16
17
|
import { getSkillReferenceTransformer, getTransformerForTool, usesNaturalLanguageSkillReferences } from '../utils/command-references.js';
|
|
17
18
|
import { AI_TOOLS, getUniversalTool, universalToolFallbackHint, OPENSPEC_DIR_NAME, resolveToolIdAlias, } from './config.js';
|
|
@@ -81,7 +82,7 @@ export class InitCommand {
|
|
|
81
82
|
this.copilotCloudOption = options.copilotCloud;
|
|
82
83
|
}
|
|
83
84
|
async execute(targetPath) {
|
|
84
|
-
const projectPath =
|
|
85
|
+
const projectPath = FileSystemUtils.canonicalizeExistingPath(targetPath);
|
|
85
86
|
const openspecDir = OPENSPEC_DIR_NAME;
|
|
86
87
|
const openspecPath = path.join(projectPath, openspecDir);
|
|
87
88
|
// Validation happens silently in the background
|
|
@@ -94,6 +95,7 @@ export class InitCommand {
|
|
|
94
95
|
// finds the nearest ancestor root (so pointer-repo subdirectories
|
|
95
96
|
// refuse exactly where a normal command would resolve the pointer).
|
|
96
97
|
const guardRoot = findRepoPlanningRootSync(projectPath);
|
|
98
|
+
let integrationsOnly = false;
|
|
97
99
|
if (guardRoot) {
|
|
98
100
|
const { hasPlanningShape, pointer } = classifyOpenSpecDir(guardRoot);
|
|
99
101
|
if (!hasPlanningShape) {
|
|
@@ -103,14 +105,30 @@ export class InitCommand {
|
|
|
103
105
|
`). Fix or remove the store: line before running openspec init.`);
|
|
104
106
|
}
|
|
105
107
|
if (pointer.value !== undefined) {
|
|
106
|
-
|
|
107
|
-
`
|
|
108
|
+
if (path.resolve(guardRoot) !== projectPath) {
|
|
109
|
+
throw new Error(`This repo's planning is externalized to store '${pointer.value}' (${pointer.filePath}). ` +
|
|
110
|
+
'Run openspec init from the pointer repo root to install integrations.');
|
|
111
|
+
}
|
|
112
|
+
// A valid pointer repo already has its planning root in the declared
|
|
113
|
+
// store. Init should still be able to install agent integrations in
|
|
114
|
+
// the code repo, without creating a second local planning root.
|
|
115
|
+
await resolveOpenSpecRoot({ startPath: projectPath });
|
|
116
|
+
integrationsOnly = true;
|
|
108
117
|
}
|
|
109
118
|
}
|
|
110
119
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
120
|
+
if (!integrationsOnly) {
|
|
121
|
+
await this.assertLanguageCanBeApplied(projectPath, openspecPath);
|
|
122
|
+
}
|
|
123
|
+
else if (this.language) {
|
|
124
|
+
throw new Error('--language cannot update an external store through a pointer repo. ' +
|
|
125
|
+
'Run init in the store root, or edit the store config directly.');
|
|
126
|
+
}
|
|
127
|
+
// Pointer repos keep their local planning files untouched. Normal init may
|
|
128
|
+
// still remove OpenSpec-managed artifacts from older layouts.
|
|
129
|
+
const deferredLegacyCleanup = integrationsOnly
|
|
130
|
+
? null
|
|
131
|
+
: await this.handleLegacyCleanup(projectPath, extendMode);
|
|
114
132
|
// Migrate OpenSpec-managed skills left in renamed tool directories
|
|
115
133
|
// (e.g. .kimi -> .kimi-code) before detection so they stay recognized.
|
|
116
134
|
migrateLegacyToolDirs(projectPath);
|
|
@@ -154,8 +172,11 @@ export class InitCommand {
|
|
|
154
172
|
// is made before generation so the write can be gated, and persisted after
|
|
155
173
|
// config.yaml exists so future non-interactive updates honor it.
|
|
156
174
|
const copilotDecision = await this.resolveCopilotCloudDecision(projectPath, validatedTools);
|
|
157
|
-
//
|
|
158
|
-
|
|
175
|
+
// Pointer repos only receive integrations. Their planning structure and
|
|
176
|
+
// config stay in the declared store.
|
|
177
|
+
if (!integrationsOnly) {
|
|
178
|
+
await this.createDirectoryStructure(openspecPath, extendMode);
|
|
179
|
+
}
|
|
159
180
|
// Generate skills and commands for each tool
|
|
160
181
|
const results = await this.generateSkillsAndCommands(projectPath, validatedTools, copilotDecision.write);
|
|
161
182
|
// Legacy cleanup was deferred to avoid interfering with skill/command generation;
|
|
@@ -163,12 +184,15 @@ export class InitCommand {
|
|
|
163
184
|
if (deferredLegacyCleanup) {
|
|
164
185
|
await this.finalizeDeferredLegacyCleanup(projectPath, deferredLegacyCleanup);
|
|
165
186
|
}
|
|
166
|
-
// Create config.yaml if needed
|
|
167
|
-
|
|
187
|
+
// Create config.yaml if needed. A pointer repo already has the config that
|
|
188
|
+
// declares its store, so preserve it byte-for-byte.
|
|
189
|
+
const configStatus = integrationsOnly
|
|
190
|
+
? 'exists'
|
|
191
|
+
: await this.createConfig(openspecPath, extendMode);
|
|
168
192
|
// Persist an explicit Copilot cloud decision so `openspec update` (which
|
|
169
193
|
// never prompts) honors it. Best-effort: a config-write failure must not
|
|
170
194
|
// fail an otherwise-successful init.
|
|
171
|
-
if (copilotDecision.persist !== undefined) {
|
|
195
|
+
if (!integrationsOnly && copilotDecision.persist !== undefined) {
|
|
172
196
|
try {
|
|
173
197
|
await persistCopilotCloudOptIn(projectPath, copilotDecision.persist);
|
|
174
198
|
}
|
|
@@ -992,9 +1016,15 @@ export class InitCommand {
|
|
|
992
1016
|
// Tools with no slash surface (e.g. Rovo Dev) reference skills as
|
|
993
1017
|
// prose ("the openspec-propose skill"); phrase the hint so it reads
|
|
994
1018
|
// as an instruction rather than a dead command with an argument.
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1019
|
+
if (usesNaturalLanguageSkillReferences(tool.value)) {
|
|
1020
|
+
hint = `Start your first change: ask ${tool.name} to use ${skillReference} with "your idea"`;
|
|
1021
|
+
}
|
|
1022
|
+
else if (tool.value === 'codex') {
|
|
1023
|
+
hint = `Start your first change: ${skillReference} "your idea" (Codex CLI or IDE); in the Codex desktop app, select ${skillReference.slice(1)} from Skills in the sidebar`;
|
|
1024
|
+
}
|
|
1025
|
+
else {
|
|
1026
|
+
hint = `Start your first change: ${skillReference} "your idea"`;
|
|
1027
|
+
}
|
|
998
1028
|
}
|
|
999
1029
|
else {
|
|
1000
1030
|
continue;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Legacy cleanup module for detecting and removing OpenSpec artifacts
|
|
3
3
|
* from previous init versions during the migration to the skill-based workflow.
|
|
4
4
|
*/
|
|
5
|
-
import type
|
|
5
|
+
import { type WorkflowId } from './profiles.js';
|
|
6
6
|
/**
|
|
7
7
|
* Legacy config file names from the old ToolRegistry.
|
|
8
8
|
* These were config files created at project root with OpenSpec markers.
|
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import os from 'os';
|
|
7
|
-
import { promises as fs } from 'fs';
|
|
7
|
+
import { promises as fs, constants as fsConstants } from 'fs';
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import { FileSystemUtils, removeMarkerBlock as removeMarkerBlockUtil } from '../utils/file-system.js';
|
|
10
10
|
import { OPENSPEC_MARKERS } from './config.js';
|
|
11
|
+
import { ALL_WORKFLOWS } from './profiles.js';
|
|
11
12
|
/**
|
|
12
13
|
* Legacy config file names from the old ToolRegistry.
|
|
13
14
|
* These were config files created at project root with OpenSpec markers.
|
|
@@ -24,6 +25,13 @@ export const LEGACY_CONFIG_FILES = [
|
|
|
24
25
|
];
|
|
25
26
|
/** The three commands the old SlashCommandRegistry wrote into each directory. */
|
|
26
27
|
const LEGACY_DIRECTORY_COMMAND_FILES = ['proposal.md', 'apply.md', 'archive.md'];
|
|
28
|
+
/** Exact Kilo workflow files written by OpenSpec before the command path moved. */
|
|
29
|
+
const LEGACY_KILOCODE_COMMAND_FILES = [
|
|
30
|
+
...ALL_WORKFLOWS.map(workflow => `.kilocode/workflows/opsx-${workflow}.md`),
|
|
31
|
+
'.kilocode/workflows/openspec-proposal.md',
|
|
32
|
+
'.kilocode/workflows/openspec-apply.md',
|
|
33
|
+
'.kilocode/workflows/openspec-archive.md',
|
|
34
|
+
];
|
|
27
35
|
/**
|
|
28
36
|
* Legacy slash command patterns from the old SlashCommandRegistry.
|
|
29
37
|
* These map toolId to the path pattern where legacy commands were created.
|
|
@@ -48,7 +56,12 @@ export const LEGACY_SLASH_COMMAND_PATHS = {
|
|
|
48
56
|
// belong to `devin` — the id Windsurf became. Only `.windsurf/` is listed:
|
|
49
57
|
// `.devin/` postdates the opsx rename and never held `openspec-*` files.
|
|
50
58
|
'devin': { type: 'files', pattern: '.windsurf/workflows/openspec-*.md' },
|
|
51
|
-
|
|
59
|
+
// Kilo now writes commands under `.kilo/command/`. Clean up both generations
|
|
60
|
+
// of OpenSpec workflows from Kilo's legacy `.kilocode/workflows/` folder.
|
|
61
|
+
'kilocode': {
|
|
62
|
+
type: 'files',
|
|
63
|
+
pattern: LEGACY_KILOCODE_COMMAND_FILES,
|
|
64
|
+
},
|
|
52
65
|
'kiro': { type: 'files', pattern: '.kiro/prompts/openspec-*.prompt.md' },
|
|
53
66
|
'github-copilot': { type: 'files', pattern: '.github/prompts/openspec-*.prompt.md' },
|
|
54
67
|
'amazon-q': { type: 'files', pattern: '.amazonq/prompts/openspec-*.md' },
|
|
@@ -331,15 +344,27 @@ async function settleLegacyCommandDir(projectPath, dirPath, result) {
|
|
|
331
344
|
* a same-named file without them is the user's.
|
|
332
345
|
*/
|
|
333
346
|
async function isGeneratedLegacyCommand(filePath) {
|
|
347
|
+
// Judge the opened handle, not the path, so the file checked is the file
|
|
348
|
+
// read. O_NOFOLLOW refuses a link and O_NONBLOCK keeps a FIFO from hanging;
|
|
349
|
+
// Windows has neither flag, so a link is refused there by lstat instead.
|
|
350
|
+
const { O_RDONLY, O_NOFOLLOW, O_NONBLOCK } = fsConstants;
|
|
351
|
+
let handle;
|
|
334
352
|
try {
|
|
335
|
-
|
|
353
|
+
handle = await fs.open(filePath, O_RDONLY | (O_NOFOLLOW ?? 0) | (O_NONBLOCK ?? 0));
|
|
354
|
+
if (O_NOFOLLOW === undefined && (await fs.lstat(filePath)).isSymbolicLink()) {
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
if (!(await handle.stat()).isFile()) {
|
|
336
358
|
return false;
|
|
337
359
|
}
|
|
338
|
-
return hasOpenSpecMarkers(await
|
|
360
|
+
return hasOpenSpecMarkers(await handle.readFile('utf-8'));
|
|
339
361
|
}
|
|
340
362
|
catch {
|
|
341
363
|
return false;
|
|
342
364
|
}
|
|
365
|
+
finally {
|
|
366
|
+
await handle?.close();
|
|
367
|
+
}
|
|
343
368
|
}
|
|
344
369
|
/**
|
|
345
370
|
* Detects legacy global slash command files.
|
|
@@ -720,10 +745,10 @@ export function formatDetectionSummary(detection) {
|
|
|
720
745
|
lines.push('agents. This simplifies your setup while keeping everything working');
|
|
721
746
|
lines.push('as before.');
|
|
722
747
|
lines.push('');
|
|
723
|
-
// Section 1: Files to remove
|
|
748
|
+
// Section 1: Files to remove entirely
|
|
724
749
|
if (removals.length > 0) {
|
|
725
750
|
lines.push(chalk.bold('Files to remove'));
|
|
726
|
-
lines.push(chalk.dim('
|
|
751
|
+
lines.push(chalk.dim('These files will be deleted entirely. Back up any custom content before proceeding:'));
|
|
727
752
|
for (const { path } of removals) {
|
|
728
753
|
lines.push(` • ${path}`);
|
|
729
754
|
}
|
|
@@ -928,12 +953,16 @@ export function formatProjectMdMigrationHint() {
|
|
|
928
953
|
lines.push(' • openspec/project.md');
|
|
929
954
|
lines.push(chalk.dim(' We won\'t delete this file. It may contain useful project context.'));
|
|
930
955
|
lines.push('');
|
|
931
|
-
lines.push(chalk.dim('
|
|
932
|
-
lines.push(
|
|
933
|
-
lines.push(chalk.dim('
|
|
956
|
+
lines.push(chalk.dim(' Ask your AI assistant:'));
|
|
957
|
+
lines.push('');
|
|
958
|
+
lines.push(chalk.dim(' Review openspec/project.md and migrate its useful content to'));
|
|
959
|
+
lines.push(chalk.dim(' openspec/config.yaml. Keep context concise: include only project-wide'));
|
|
960
|
+
lines.push(chalk.dim(' facts needed during artifact creation, apply, and archive. Move'));
|
|
961
|
+
lines.push(chalk.dim(' artifact-specific guidance into rules for the matching artifacts.'));
|
|
962
|
+
lines.push(chalk.dim(' Move guidance for apply or archive into the matching operations entry.'));
|
|
963
|
+
lines.push(chalk.dim(' Leave out generic, outdated, or verbose material. Do not delete project.md.'));
|
|
934
964
|
lines.push('');
|
|
935
|
-
lines.push(chalk.dim(' Review
|
|
936
|
-
lines.push(chalk.dim(' section, then delete the file when ready.'));
|
|
965
|
+
lines.push(chalk.dim(' Review config.yaml, then delete project.md when ready.'));
|
|
937
966
|
return lines.join('\n');
|
|
938
967
|
}
|
|
939
968
|
//# sourceMappingURL=legacy-cleanup.js.map
|
package/dist/core/list.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ interface ListOptions {
|
|
|
3
3
|
sort?: 'recent' | 'name';
|
|
4
4
|
json?: boolean;
|
|
5
5
|
root?: RootOutput;
|
|
6
|
+
archived?: boolean;
|
|
7
|
+
all?: boolean;
|
|
6
8
|
}
|
|
7
9
|
export declare class ListCommand {
|
|
8
10
|
execute(targetPath?: string, mode?: 'changes' | 'specs', options?: ListOptions): Promise<void>;
|
package/dist/core/list.js
CHANGED
|
@@ -35,8 +35,9 @@ async function readChangeDirectoryEntries(changesDir) {
|
|
|
35
35
|
/**
|
|
36
36
|
* Get the most recent modification time of any file in a directory (recursive).
|
|
37
37
|
* Falls back to the directory's own mtime if no files are found.
|
|
38
|
+
* Archived links use their own mtime: moving a change can break relative targets.
|
|
38
39
|
*/
|
|
39
|
-
async function getLastModified(dirPath) {
|
|
40
|
+
async function getLastModified(dirPath, archived = false) {
|
|
40
41
|
let latest = null;
|
|
41
42
|
async function walk(dir) {
|
|
42
43
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
@@ -47,7 +48,9 @@ async function getLastModified(dirPath) {
|
|
|
47
48
|
await walk(fullPath);
|
|
48
49
|
}
|
|
49
50
|
else {
|
|
50
|
-
const stat =
|
|
51
|
+
const stat = archived && entry.isSymbolicLink()
|
|
52
|
+
? await fs.lstat(fullPath)
|
|
53
|
+
: await fs.stat(fullPath);
|
|
51
54
|
if (latest === null || stat.mtime > latest) {
|
|
52
55
|
latest = stat.mtime;
|
|
53
56
|
}
|
|
@@ -96,20 +99,31 @@ function formatRelativeTime(date) {
|
|
|
96
99
|
}
|
|
97
100
|
export class ListCommand {
|
|
98
101
|
async execute(targetPath = '.', mode = 'changes', options = {}) {
|
|
99
|
-
const { sort = 'recent', json = false, root } = options;
|
|
102
|
+
const { sort = 'recent', json = false, root, archived = false, all = false } = options;
|
|
103
|
+
if (mode === 'specs' && (archived || all)) {
|
|
104
|
+
throw new Error('--archived and --all can only be used when listing changes.');
|
|
105
|
+
}
|
|
100
106
|
if (mode === 'changes') {
|
|
101
107
|
const changesDir = path.join(targetPath, 'openspec', 'changes');
|
|
102
|
-
|
|
108
|
+
const archiveDir = path.join(changesDir, 'archive');
|
|
109
|
+
const includeArchived = archived || all;
|
|
110
|
+
// Read the parent even for --archived: Windows can report ENOENT for
|
|
111
|
+
// changes/archive when changes is a file, hiding a malformed root.
|
|
103
112
|
const entries = await readChangeDirectoryEntries(changesDir);
|
|
104
|
-
const
|
|
113
|
+
const activeDirs = !archived || all ? entries
|
|
105
114
|
.filter(entry => entry.isDirectory() && entry.name !== 'archive')
|
|
106
|
-
.map(entry => entry.name);
|
|
115
|
+
.map(entry => ({ name: entry.name, parent: changesDir, archived: false })) : [];
|
|
116
|
+
const archiveEntries = includeArchived ? await readChangeDirectoryEntries(archiveDir) : [];
|
|
117
|
+
const archivedDirs = archiveEntries
|
|
118
|
+
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
119
|
+
.map(entry => ({ name: entry.name, parent: archiveDir, archived: true }));
|
|
120
|
+
const changeDirs = [...activeDirs, ...archivedDirs];
|
|
107
121
|
if (changeDirs.length === 0) {
|
|
108
122
|
if (json) {
|
|
109
123
|
console.log(JSON.stringify({ changes: [], ...(root ? { root } : {}) }, null, 2));
|
|
110
124
|
}
|
|
111
125
|
else {
|
|
112
|
-
console.log('No active changes found.');
|
|
126
|
+
console.log(all ? 'No changes found.' : archived ? 'No archived changes found.' : 'No active changes found.');
|
|
113
127
|
}
|
|
114
128
|
return;
|
|
115
129
|
}
|
|
@@ -118,18 +132,21 @@ export class ListCommand {
|
|
|
118
132
|
// A directory that only wraps nested change directories is still listed -
|
|
119
133
|
// hiding it would hide a real change whenever the probe is wrong - but it
|
|
120
134
|
// is listed as what it is, so the nesting stops failing silently (#1846).
|
|
121
|
-
const nestedFindings = await findNestedChanges(changesDir,
|
|
135
|
+
const nestedFindings = await findNestedChanges(changesDir, activeDirs.map((changeDir) => changeDir.name));
|
|
122
136
|
const nestedByName = new Map(nestedFindings.map((finding) => [finding.name, finding]));
|
|
123
137
|
for (const changeDir of changeDirs) {
|
|
124
|
-
const progress = await getTaskProgressForChange(
|
|
125
|
-
const changePath = path.join(
|
|
126
|
-
const lastModified = await getLastModified(changePath);
|
|
138
|
+
const progress = await getTaskProgressForChange(changeDir.parent, changeDir.name, targetPath);
|
|
139
|
+
const changePath = path.join(changeDir.parent, changeDir.name);
|
|
140
|
+
const lastModified = await getLastModified(changePath, changeDir.archived);
|
|
127
141
|
changes.push({
|
|
128
|
-
name: changeDir,
|
|
142
|
+
name: changeDir.name,
|
|
129
143
|
completedTasks: progress.completed,
|
|
130
144
|
totalTasks: progress.total,
|
|
131
145
|
lastModified,
|
|
132
|
-
|
|
146
|
+
archived: changeDir.archived,
|
|
147
|
+
...(!changeDir.archived && nestedByName.has(changeDir.name)
|
|
148
|
+
? { nested: nestedByName.get(changeDir.name).nested }
|
|
149
|
+
: {})
|
|
133
150
|
});
|
|
134
151
|
}
|
|
135
152
|
// Sort by preference (default: recent first)
|
|
@@ -147,6 +164,7 @@ export class ListCommand {
|
|
|
147
164
|
totalTasks: c.totalTasks,
|
|
148
165
|
lastModified: c.lastModified.toISOString(),
|
|
149
166
|
status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress',
|
|
167
|
+
...(includeArchived ? { archived: c.archived } : {}),
|
|
150
168
|
...(c.nested ? { nested: c.nested } : {})
|
|
151
169
|
}));
|
|
152
170
|
// Additive: the entries keep their shape so existing consumers are
|
|
@@ -165,16 +183,24 @@ export class ListCommand {
|
|
|
165
183
|
return;
|
|
166
184
|
}
|
|
167
185
|
// Display results
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
186
|
+
const groups = [
|
|
187
|
+
{ heading: 'Changes:', changes: changes.filter(change => !change.archived) },
|
|
188
|
+
{ heading: 'Archived Changes:', changes: changes.filter(change => change.archived) }
|
|
189
|
+
].filter(group => group.changes.length > 0);
|
|
190
|
+
for (const [index, group] of groups.entries()) {
|
|
191
|
+
if (index > 0)
|
|
192
|
+
console.log('');
|
|
193
|
+
console.log(group.heading);
|
|
194
|
+
const padding = ' ';
|
|
195
|
+
const nameWidth = Math.max(...group.changes.map(c => c.name.length));
|
|
196
|
+
for (const change of group.changes) {
|
|
197
|
+
const paddedName = change.name.padEnd(nameWidth);
|
|
198
|
+
const status = change.nested
|
|
199
|
+
? 'not a change'
|
|
200
|
+
: formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
|
|
201
|
+
const timeAgo = formatRelativeTime(change.lastModified);
|
|
202
|
+
console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}`);
|
|
203
|
+
}
|
|
178
204
|
}
|
|
179
205
|
for (const finding of nestedFindings) {
|
|
180
206
|
console.log('');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildCodeFenceMask, extractRequirementText, hasScenarioBody } from './requirement-text.js';
|
|
2
|
+
import { normalizeRequirementName, scenarioNameFromHeaderText } from './requirement-blocks.js';
|
|
2
3
|
export class MarkdownParser {
|
|
3
4
|
lines;
|
|
4
5
|
codeFenceLineMask;
|
|
@@ -121,6 +122,9 @@ export class MarkdownParser {
|
|
|
121
122
|
const text = extractRequirementText(child.title, child.content.split('\n'));
|
|
122
123
|
const scenarios = this.parseScenarios(child);
|
|
123
124
|
requirements.push({
|
|
125
|
+
// The name archive matches on, so a JSON reader can cite a requirement
|
|
126
|
+
// the way a MODIFIED or REMOVED header must.
|
|
127
|
+
name: normalizeRequirementName(child.title.replace(/^Requirement:\s*/i, '')),
|
|
124
128
|
text,
|
|
125
129
|
scenarios,
|
|
126
130
|
});
|
|
@@ -134,6 +138,7 @@ export class MarkdownParser {
|
|
|
134
138
|
// body is not a scenario; the delta counter applies the same rule.
|
|
135
139
|
if (hasScenarioBody(scenarioSection.content)) {
|
|
136
140
|
scenarios.push({
|
|
141
|
+
name: scenarioNameFromHeaderText(scenarioSection.title),
|
|
137
142
|
rawText: scenarioSection.content
|
|
138
143
|
});
|
|
139
144
|
}
|
|
@@ -95,14 +95,51 @@ export interface DeltaPlan {
|
|
|
95
95
|
* Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
|
|
96
96
|
*/
|
|
97
97
|
export declare function parseDeltaSpec(content: string): DeltaPlan;
|
|
98
|
+
/** Both directions of the scenario-name comparison, plus the two totals. */
|
|
99
|
+
export interface ScenarioNameDiff {
|
|
100
|
+
/** Names the current block has that the incoming block does not cover. */
|
|
101
|
+
missing: string[];
|
|
102
|
+
/** Names the incoming block introduces that the current block does not have. */
|
|
103
|
+
added: string[];
|
|
104
|
+
/** Level-4 headers in the current block. */
|
|
105
|
+
currentCount: number;
|
|
106
|
+
/** Level-4 headers in the incoming block. */
|
|
107
|
+
incomingCount: number;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Compare the scenario names of a current requirement block and an incoming
|
|
111
|
+
* (MODIFIED) one, in both directions.
|
|
112
|
+
*
|
|
113
|
+
* `missing` is the loss the guard exists to catch: a MODIFIED requirement
|
|
114
|
+
* replaces the whole block, so every name there would be dropped from the main
|
|
115
|
+
* spec. `added` and the two counts are reported alongside it, because they are
|
|
116
|
+
* the first thing a reader checks once it fires (#1697) - a block that omits
|
|
117
|
+
* two names and introduces two is shaped like a rename, one that omits two and
|
|
118
|
+
* introduces none is shaped like a truncation. Neither is proof, and intent is
|
|
119
|
+
* not recoverable from structure, so this decides nothing and only says what
|
|
120
|
+
* the two blocks contain.
|
|
121
|
+
*/
|
|
122
|
+
export declare function diffScenarioNames(current: RequirementBlock, incoming: RequirementBlock): ScenarioNameDiff;
|
|
98
123
|
/**
|
|
99
124
|
* Scenario names the current requirement block has and the incoming
|
|
100
125
|
* (MODIFIED) block does not. A MODIFIED requirement replaces the whole block,
|
|
101
126
|
* so every name reported here would be dropped from the main spec.
|
|
102
127
|
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* about what counts as a dropped
|
|
128
|
+
* The `missing` half of diffScenarioNames, which archive (refusing to apply
|
|
129
|
+
* the block) and validate (reporting the same loss at authoring time, #1477)
|
|
130
|
+
* both go through, so the two cannot disagree about what counts as a dropped
|
|
131
|
+
* scenario.
|
|
106
132
|
*/
|
|
107
133
|
export declare function findMissingCurrentScenarios(current: RequirementBlock, incoming: RequirementBlock): string[];
|
|
134
|
+
/**
|
|
135
|
+
* The one sentence archive and validate both append when the guard fires, so
|
|
136
|
+
* the counts a reader sees cannot differ between the two commands.
|
|
137
|
+
*/
|
|
138
|
+
export declare function describeScenarioBalance(diff: ScenarioNameDiff): string;
|
|
139
|
+
/**
|
|
140
|
+
* scenarioNameAt for header text whose leading `####` is already gone, as the
|
|
141
|
+
* section parser (MarkdownParser) holds it, so `show --json` names a scenario
|
|
142
|
+
* exactly as the MODIFIED loss check does.
|
|
143
|
+
*/
|
|
144
|
+
export declare function scenarioNameFromHeaderText(headerText: string): string;
|
|
108
145
|
//# sourceMappingURL=requirement-blocks.d.ts.map
|
|
@@ -380,37 +380,79 @@ function parseRenamedPairs(sectionBody, unpaired) {
|
|
|
380
380
|
drop('FROM', pending.name, pending.line);
|
|
381
381
|
return pairs;
|
|
382
382
|
}
|
|
383
|
+
/**
|
|
384
|
+
* Compare the scenario names of a current requirement block and an incoming
|
|
385
|
+
* (MODIFIED) one, in both directions.
|
|
386
|
+
*
|
|
387
|
+
* `missing` is the loss the guard exists to catch: a MODIFIED requirement
|
|
388
|
+
* replaces the whole block, so every name there would be dropped from the main
|
|
389
|
+
* spec. `added` and the two counts are reported alongside it, because they are
|
|
390
|
+
* the first thing a reader checks once it fires (#1697) - a block that omits
|
|
391
|
+
* two names and introduces two is shaped like a rename, one that omits two and
|
|
392
|
+
* introduces none is shaped like a truncation. Neither is proof, and intent is
|
|
393
|
+
* not recoverable from structure, so this decides nothing and only says what
|
|
394
|
+
* the two blocks contain.
|
|
395
|
+
*/
|
|
396
|
+
export function diffScenarioNames(current, incoming) {
|
|
397
|
+
const currentNames = parseScenarioBlocks(current.raw).map((scenario) => scenario.name);
|
|
398
|
+
const incomingNames = parseScenarioBlocks(incoming.raw).map((scenario) => scenario.name);
|
|
399
|
+
// Multiplicity-aware: a name present N times on one side and M times on the
|
|
400
|
+
// other leaves max(0, N - M) instances unmatched. Set membership would treat
|
|
401
|
+
// N>M as fully covered and let archive silently drop duplicates (residual
|
|
402
|
+
// #1246 / duplicate-scenario-name blind spot).
|
|
403
|
+
const unmatched = (names, against) => {
|
|
404
|
+
const remaining = new Map();
|
|
405
|
+
for (const name of against)
|
|
406
|
+
remaining.set(name, (remaining.get(name) ?? 0) + 1);
|
|
407
|
+
const out = [];
|
|
408
|
+
for (const name of names) {
|
|
409
|
+
const left = remaining.get(name) ?? 0;
|
|
410
|
+
if (left > 0)
|
|
411
|
+
remaining.set(name, left - 1);
|
|
412
|
+
else
|
|
413
|
+
out.push(name);
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
};
|
|
417
|
+
return {
|
|
418
|
+
missing: unmatched(currentNames, incomingNames),
|
|
419
|
+
added: unmatched(incomingNames, currentNames),
|
|
420
|
+
currentCount: currentNames.length,
|
|
421
|
+
incomingCount: incomingNames.length,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
383
424
|
/**
|
|
384
425
|
* Scenario names the current requirement block has and the incoming
|
|
385
426
|
* (MODIFIED) block does not. A MODIFIED requirement replaces the whole block,
|
|
386
427
|
* so every name reported here would be dropped from the main spec.
|
|
387
428
|
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
* about what counts as a dropped
|
|
429
|
+
* The `missing` half of diffScenarioNames, which archive (refusing to apply
|
|
430
|
+
* the block) and validate (reporting the same loss at authoring time, #1477)
|
|
431
|
+
* both go through, so the two cannot disagree about what counts as a dropped
|
|
432
|
+
* scenario.
|
|
391
433
|
*/
|
|
392
434
|
export function findMissingCurrentScenarios(current, incoming) {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
if (remaining > 0) {
|
|
407
|
-
remainingIncoming.set(name, remaining - 1);
|
|
408
|
-
}
|
|
409
|
-
else {
|
|
410
|
-
missing.push(name);
|
|
411
|
-
}
|
|
435
|
+
return diffScenarioNames(current, incoming).missing;
|
|
436
|
+
}
|
|
437
|
+
/** At most this many added names are listed before the rest are counted. */
|
|
438
|
+
const MAX_LISTED_ADDED_SCENARIOS = 3;
|
|
439
|
+
/**
|
|
440
|
+
* The one sentence archive and validate both append when the guard fires, so
|
|
441
|
+
* the counts a reader sees cannot differ between the two commands.
|
|
442
|
+
*/
|
|
443
|
+
export function describeScenarioBalance(diff) {
|
|
444
|
+
const count = (value) => `${value} ${value === 1 ? 'scenario' : 'scenarios'}`;
|
|
445
|
+
const scale = `The modified block has ${count(diff.incomingCount)}; the current spec has ${count(diff.currentCount)}.`;
|
|
446
|
+
if (diff.added.length === 0) {
|
|
447
|
+
return `${scale} It adds none.`;
|
|
412
448
|
}
|
|
413
|
-
|
|
449
|
+
const listed = diff.added
|
|
450
|
+
.slice(0, MAX_LISTED_ADDED_SCENARIOS)
|
|
451
|
+
.map((name) => `"${name}"`)
|
|
452
|
+
.join(', ');
|
|
453
|
+
const rest = diff.added.length - MAX_LISTED_ADDED_SCENARIOS;
|
|
454
|
+
const names = rest > 0 ? `${listed} and ${rest} more` : listed;
|
|
455
|
+
return `${scale} It adds ${count(diff.added.length)} not in the current spec: ${names}.`;
|
|
414
456
|
}
|
|
415
457
|
/**
|
|
416
458
|
* Any non-fenced level-4 header on the given (masked) line. Reuses the spec
|
|
@@ -429,8 +471,15 @@ function scenarioHeaderAt(lines, mask, index) {
|
|
|
429
471
|
* ATX-closed, one not) are not mistaken for a dropped scenario.
|
|
430
472
|
*/
|
|
431
473
|
function scenarioNameAt(line) {
|
|
432
|
-
return line
|
|
433
|
-
|
|
474
|
+
return scenarioNameFromHeaderText(line.replace(SCENARIO_HEADER, ''));
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* scenarioNameAt for header text whose leading `####` is already gone, as the
|
|
478
|
+
* section parser (MarkdownParser) holds it, so `show --json` names a scenario
|
|
479
|
+
* exactly as the MODIFIED loss check does.
|
|
480
|
+
*/
|
|
481
|
+
export function scenarioNameFromHeaderText(headerText) {
|
|
482
|
+
return headerText
|
|
434
483
|
// Optional ATX closing sequence. CommonMark only treats a trailing `#` run
|
|
435
484
|
// as a close when it is preceded by a space or tab — not any Unicode space —
|
|
436
485
|
// so this uses `[ \t]`, not `\s`. A looser `\s` could strip a `#` run after
|