@fission-ai/openspec 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/cli/index.js +11 -2
  2. package/dist/commands/change.d.ts +24 -0
  3. package/dist/commands/change.js +223 -0
  4. package/dist/commands/schema.d.ts +5 -0
  5. package/dist/commands/schema.js +201 -35
  6. package/dist/commands/show.js +1 -1
  7. package/dist/commands/workflow/index.d.ts +1 -1
  8. package/dist/commands/workflow/index.js +1 -1
  9. package/dist/commands/workflow/status.d.ts +2 -0
  10. package/dist/commands/workflow/status.js +76 -12
  11. package/dist/core/artifact-graph/resolver.js +15 -9
  12. package/dist/core/available-tools.js +16 -1
  13. package/dist/core/command-generation/adapters/antigravity.d.ts +1 -1
  14. package/dist/core/command-generation/adapters/antigravity.js +2 -2
  15. package/dist/core/completions/command-registry.js +15 -0
  16. package/dist/core/completions/generators/fish-generator.d.ts +12 -0
  17. package/dist/core/completions/generators/fish-generator.js +79 -47
  18. package/dist/core/completions/templates/fish-templates.d.ts +1 -1
  19. package/dist/core/completions/templates/fish-templates.js +107 -10
  20. package/dist/core/completions/types.d.ts +4 -0
  21. package/dist/core/config.js +6 -1
  22. package/dist/core/init.js +50 -31
  23. package/dist/core/legacy-cleanup.js +6 -0
  24. package/dist/core/migration.js +42 -12
  25. package/dist/core/parsers/requirement-blocks.d.ts +1 -0
  26. package/dist/core/parsers/requirement-blocks.js +2 -0
  27. package/dist/core/shared/tool-detection.js +9 -3
  28. package/dist/core/shared-skill-target.d.ts +13 -0
  29. package/dist/core/shared-skill-target.js +43 -1
  30. package/dist/core/specs-apply.js +16 -8
  31. package/dist/core/templates/workflows/explore.js +79 -73
  32. package/dist/core/update.js +24 -22
  33. package/dist/core/validation/constants.d.ts +3 -0
  34. package/dist/core/validation/constants.js +12 -0
  35. package/dist/core/validation/purpose-placeholder.d.ts +45 -0
  36. package/dist/core/validation/purpose-placeholder.js +117 -0
  37. package/dist/core/validation/validator.js +16 -1
  38. package/dist/utils/requirement-diff.d.ts +42 -0
  39. package/dist/utils/requirement-diff.js +61 -0
  40. package/package.json +2 -1
@@ -5,7 +5,7 @@ export function getExploreSkillTemplate() {
5
5
  description: 'Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.',
6
6
  instructions: `Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
7
7
 
8
- **IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing. For a new change, scaffold it first as described below.
8
+ **IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. For a new change, scaffold it first as described below.
9
9
 
10
10
  **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
11
11
 
@@ -48,22 +48,25 @@ Depending on what the user brings, you might:
48
48
 
49
49
  **Visualize**
50
50
  \`\`\`
51
- ┌─────────────────────────────────────────┐
52
- │ Use ASCII diagrams liberally │
53
- ├─────────────────────────────────────────┤
54
- │ │
55
- │ ┌────────┐ ┌────────┐ │
56
- │ │ State │────────▶│ State │ │
57
- │ │ A │ │ B │ │
58
- │ └────────┘ └────────┘ │
59
- │ │
60
- │ System diagrams, state machines, │
61
- │ data flows, architecture sketches, │
62
- │ dependency graphs, comparison tables │
63
- │ │
64
- └─────────────────────────────────────────┘
51
+ +------------------------------------------+
52
+ | Use ASCII diagrams liberally |
53
+ +------------------------------------------+
54
+ | |
55
+ | [State A] -------> [State B] |
56
+ | | |
57
+ | v |
58
+ | [State C] |
59
+ | |
60
+ | System diagrams, state machines, |
61
+ | data flows, architecture sketches, |
62
+ | dependency graphs, comparison tables |
63
+ | |
64
+ +------------------------------------------+
65
65
  \`\`\`
66
66
 
67
+ **Draw with plain ASCII only** — borders \`+\` \`-\` \`|\`, arrows \`-->\` \`<--\` \`^\` \`v\`, markers \`*\` \`x\`.
68
+ Unicode diagram glyphs can render at different widths across terminals, fonts, and locales, so padded boxes and aligned tables can drift. Keep every diagram character ASCII.
69
+
67
70
  **Surface risks and unknowns**
68
71
  - Identify what could go wrong
69
72
  - Find gaps in understanding
@@ -164,17 +167,17 @@ User: I'm thinking about adding real-time collaboration
164
167
  You: Real-time collab is a big space. Let me think about this...
165
168
 
166
169
  COLLABORATION SPECTRUM
167
- ════════════════════════════════════════════
170
+ ============================================
168
171
 
169
172
  Awareness Coordination Sync
170
- │ │ │
171
- ▼ ▼ ▼
172
- ┌────────┐ ┌────────┐ ┌────────┐
173
- │Presence│ │Cursors │ │ CRDT │
174
- │ "3 │ │ Multi │ │Conflict│
175
- │online" │ │ select │ │ free │
176
- └────────┘ └────────┘ └────────┘
177
- │ │ │
173
+ | | |
174
+ v v v
175
+ +--------+ +--------+ +--------+
176
+ |Presence| |Cursors | | CRDT |
177
+ | "3 | | Multi | |Conflict|
178
+ |online" | | select | | free |
179
+ +--------+ +--------+ +--------+
180
+ | | |
178
181
  trivial moderate complex
179
182
 
180
183
  Where's your head at?
@@ -186,27 +189,27 @@ User: The auth system is a mess
186
189
 
187
190
  You: [reads codebase]
188
191
 
189
- ┌─────────────────────────────────────────────┐
190
- │ CURRENT AUTH FLOW │
191
- └─────────────────────────────────────────────┘
192
- │
193
- ┌─────────────┼─────────────┐
194
- ▼ ▼ ▼
195
- ┌─────────┐ ┌─────────┐ ┌─────────┐
196
- │ Google │ │ GitHub │ │ Email │
197
- │ OAuth │ │ OAuth │ │ Magic │
198
- └────┬────┘ └────┬────┘ └────┬────┘
199
- │ │ │
200
- └─────────────┼─────────────┘
201
- ▼
202
- ┌───────────┐
203
- │ Session │
204
- └─────┬─────┘
205
- │
206
- ▼
207
- ┌───────────┐
208
- │ Perms │
209
- └───────────┘
192
+ +---------------------------------------------+
193
+ | CURRENT AUTH FLOW |
194
+ +---------------------------------------------+
195
+ |
196
+ +-------------+-------------+
197
+ v v v
198
+ +---------+ +---------+ +---------+
199
+ | Google | | GitHub | | Email |
200
+ | OAuth | | OAuth | | Magic |
201
+ +----+----+ +----+----+ +----+----+
202
+ | | |
203
+ +-------------+-------------+
204
+ v
205
+ +-----------+
206
+ | Session |
207
+ +-----+-----+
208
+ |
209
+ v
210
+ +-----------+
211
+ | Perms |
212
+ +-----------+
210
213
 
211
214
  I see three tangles. Which one's burning?
212
215
  \`\`\`
@@ -238,19 +241,19 @@ User: A CLI tool that tracks local dev environments
238
241
 
239
242
  You: That changes everything.
240
243
 
241
- ┌─────────────────────────────────────────────────┐
242
- │ CLI TOOL DATA STORAGE │
243
- └─────────────────────────────────────────────────┘
244
+ +-------------------------------------------------+
245
+ | CLI TOOL DATA STORAGE |
246
+ +-------------------------------------------------+
244
247
 
245
248
  Key constraints:
246
- • No daemon running
247
- • Must work offline
248
- • Single user
249
+ - No daemon running
250
+ - Must work offline
251
+ - Single user
249
252
 
250
253
  SQLite Postgres
251
- Deployment embedded ✓ needs server ✗
252
- Offline yes ✓ no ✗
253
- Single file yes ✓ no ✗
254
+ Deployment embedded needs server
255
+ Offline yes no
256
+ Single file yes no
254
257
 
255
258
  SQLite. Not even close.
256
259
 
@@ -290,11 +293,11 @@ But this summary is optional. Sometimes the thinking IS the value.
290
293
 
291
294
  ## Guardrails
292
295
 
293
- - **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
296
+ - **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or \`openspec/config.yaml\` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not.
294
297
  - **Don't fake understanding** - If something is unclear, dig deeper
295
298
  - **Don't rush** - Discovery is thinking time, not task time
296
299
  - **Don't force structure** - Let patterns emerge naturally
297
- - **Don't auto-capture** - Offer to save insights, don't just do it
300
+ - **Don't auto-capture** - Offer to save insights, don't just do it. Read-only commands and tools need no confirmation. Before the first write-capable action—including \`openspec new change\` or another command that writes files—name the artifacts or files and proposed changes, ask a direct yes/no question, and wait for explicit confirmation in a separate user message. That confirmation covers only the described scope; ask again before expanding it. Answers to design or clarifying questions are never consent to write.
298
301
  - **Don't manually scaffold changes** - Never create a new change directory under \`openspec/changes/\` by hand. Always use \`openspec new change "<name>"\` (with \`--store <id>\` when applicable) so required metadata such as \`.openspec.yaml\` is created before writing artifacts.
299
302
  - **Do visualize** - A good diagram is worth many paragraphs
300
303
  - **Do explore the codebase** - Ground discussions in reality
@@ -312,7 +315,7 @@ export function getOpsxExploreCommandTemplate() {
312
315
  tags: ['workflow', 'explore', 'experimental', 'thinking'],
313
316
  content: `Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
314
317
 
315
- **IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing. For a new change, scaffold it first as described below.
318
+ **IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. For a new change, scaffold it first as described below.
316
319
 
317
320
  **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
318
321
 
@@ -362,22 +365,25 @@ Depending on what the user brings, you might:
362
365
 
363
366
  **Visualize**
364
367
  \`\`\`
365
- ┌─────────────────────────────────────────┐
366
- │ Use ASCII diagrams liberally │
367
- ├─────────────────────────────────────────┤
368
- │ │
369
- │ ┌────────┐ ┌────────┐ │
370
- │ │ State │────────▶│ State │ │
371
- │ │ A │ │ B │ │
372
- │ └────────┘ └────────┘ │
373
- │ │
374
- │ System diagrams, state machines, │
375
- │ data flows, architecture sketches, │
376
- │ dependency graphs, comparison tables │
377
- │ │
378
- └─────────────────────────────────────────┘
368
+ +------------------------------------------+
369
+ | Use ASCII diagrams liberally |
370
+ +------------------------------------------+
371
+ | |
372
+ | [State A] -------> [State B] |
373
+ | | |
374
+ | v |
375
+ | [State C] |
376
+ | |
377
+ | System diagrams, state machines, |
378
+ | data flows, architecture sketches, |
379
+ | dependency graphs, comparison tables |
380
+ | |
381
+ +------------------------------------------+
379
382
  \`\`\`
380
383
 
384
+ **Draw with plain ASCII only** — borders \`+\` \`-\` \`|\`, arrows \`-->\` \`<--\` \`^\` \`v\`, markers \`*\` \`x\`.
385
+ Unicode diagram glyphs can render at different widths across terminals, fonts, and locales, so padded boxes and aligned tables can drift. Keep every diagram character ASCII.
386
+
381
387
  **Surface risks and unknowns**
382
388
  - Identify what could go wrong
383
389
  - Find gaps in understanding
@@ -486,11 +492,11 @@ When things crystallize, you might offer a summary - but it's optional. Sometime
486
492
 
487
493
  ## Guardrails
488
494
 
489
- - **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
495
+ - **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or \`openspec/config.yaml\` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not.
490
496
  - **Don't fake understanding** - If something is unclear, dig deeper
491
497
  - **Don't rush** - Discovery is thinking time, not task time
492
498
  - **Don't force structure** - Let patterns emerge naturally
493
- - **Don't auto-capture** - Offer to save insights, don't just do it
499
+ - **Don't auto-capture** - Offer to save insights, don't just do it. Read-only commands and tools need no confirmation. Before the first write-capable action—including \`openspec new change\` or another command that writes files—name the artifacts or files and proposed changes, ask a direct yes/no question, and wait for explicit confirmation in a separate user message. That confirmation covers only the described scope; ask again before expanding it. Answers to design or clarifying questions are never consent to write.
494
500
  - **Don't manually scaffold changes** - Never create a new change directory under \`openspec/changes/\` by hand. Always use \`openspec new change "<name>"\` (with \`--store <id>\` when applicable) so required metadata such as \`.openspec.yaml\` is created before writing artifacts.
495
501
  - **Do visualize** - A good diagram is worth many paragraphs
496
502
  - **Do explore the codebase** - Ground discussions in reality
@@ -23,7 +23,7 @@ import { getAvailableTools } from './available-tools.js';
23
23
  import { WORKFLOW_TO_SKILL_DIR, getConfiguredToolsForProfileSync, getToolsNeedingProfileSync, } from './profile-sync-drift.js';
24
24
  import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, findLegacyToolMigrations, migrateLegacyToolDirs, describeLegacyMigration, legacyMigrationNotice, keptInPlaceNotice, hasMovableContent, } from './migration.js';
25
25
  import { resolveCommandSurfaceCapability, resolveCommandInvocation, shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js';
26
- import { writeSharedSkillTarget, sharedSkillRootOwner } from './shared-skill-target.js';
26
+ import { resolveSharedSkillWriters, writeSharedSkillTarget, } from './shared-skill-target.js';
27
27
  import { includesGitHubCopilot, writeCopilotCloudFiles, removeCopilotCloudFiles, isCopilotCloudEnabled, readCopilotCloudOptIn, findUnmanagedCloudFiles } from './github-copilot/cloud-agent.js';
28
28
  const require = createRequire(import.meta.url);
29
29
  const { version: OPENSPEC_VERSION } = require('../../package.json');
@@ -154,6 +154,9 @@ export class UpdateCommand {
154
154
  const deliveryIncludesCommands = delivery !== 'skills';
155
155
  // 10. Update tools (all if force, otherwise only those needing update)
156
156
  const toolsToUpdate = this.force ? configuredTools : [...toolsToUpdateSet];
157
+ const sharedSkillWriters = resolveSharedSkillWriters(resolvedProjectPath, configuredAndNewTools
158
+ .map((toolId) => AI_TOOLS.find((tool) => tool.value === toolId))
159
+ .filter((tool) => tool !== undefined));
157
160
  const updatedTools = [];
158
161
  const updatedToolIds = [];
159
162
  const failedTools = [];
@@ -173,11 +176,12 @@ export class UpdateCommand {
173
176
  const skillsRoot = hasGlobalSkillTarget(tool) ? skillsDir : resolvedProjectPath;
174
177
  const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery);
175
178
  const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery);
179
+ const writesSkills = !tool.skillsDir || sharedSkillWriters.has(tool.value);
176
180
  const toolWorkflows = legacyWorkflowOverrides[tool.value] ?? desiredWorkflows;
177
181
  const skillTemplates = getSkillTemplates(toolWorkflows);
178
182
  const commandContents = getCommandContents(toolWorkflows);
179
183
  // Generate skill files if delivery includes skills
180
- if (shouldGenerateSkills) {
184
+ if (shouldGenerateSkills && writesSkills) {
181
185
  for (const { template, dirName } of skillTemplates) {
182
186
  const skillDir = path.join(skillsDir, dirName);
183
187
  const skillFile = path.join(skillDir, 'SKILL.md');
@@ -190,7 +194,9 @@ export class UpdateCommand {
190
194
  removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsRoot, skillsDir, toolWorkflows);
191
195
  }
192
196
  // Delete skill directories if delivery is commands-only
193
- if (shouldRemoveSkillsForTool(tool.value, delivery) && !hasGlobalSkillTarget(tool)) {
197
+ if (shouldRemoveSkillsForTool(tool.value, delivery) &&
198
+ writesSkills &&
199
+ !hasGlobalSkillTarget(tool)) {
194
200
  removedSkillCount += await this.removeSkillDirs(skillsRoot, skillsDir);
195
201
  // Persist the selected owner even when commands-only delivery leaves
196
202
  // this target with no generated skills.
@@ -803,6 +809,10 @@ export class UpdateCommand {
803
809
  const newlyConfigured = [];
804
810
  const skippedSharedSkillTools = [];
805
811
  const workflowOverrides = {};
812
+ const arbitrationTools = [...new Set([...configuredTools, ...selectedTools])]
813
+ .map((toolId) => AI_TOOLS.find((tool) => tool.value === toolId))
814
+ .filter((tool) => tool !== undefined);
815
+ const sharedSkillWriters = resolveSharedSkillWriters(projectPath, arbitrationTools);
806
816
  for (const toolId of selectedTools) {
807
817
  const tool = AI_TOOLS.find((t) => t.value === toolId);
808
818
  if (!tool || !toolSupportsSkills(tool))
@@ -813,6 +823,7 @@ export class UpdateCommand {
813
823
  const skillsRoot = hasGlobalSkillTarget(tool) ? skillsDir : projectPath;
814
824
  const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery);
815
825
  const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery);
826
+ const writesSkills = !tool.skillsDir || sharedSkillWriters.has(tool.value);
816
827
  const toolWorkflows = (tool.value === 'codex' && inferredCodexWorkflows.length > 0
817
828
  ? inferredCodexWorkflows
818
829
  : desiredWorkflows);
@@ -821,32 +832,23 @@ export class UpdateCommand {
821
832
  }
822
833
  const skillTemplates = getSkillTemplates(toolWorkflows);
823
834
  const commandContents = getCommandContents(toolWorkflows);
824
- // A shared skills root (e.g. `.agents`) already owned by another tool
825
- // must not be overwritten by a tool inferred from legacy artifacts: a
826
- // Codex install detected only from global `~/.codex/prompts` would
827
- // otherwise rewrite an existing vendor-neutral `agents` tree with
828
- // Codex-specific syntax and flip its ownership marker `agents → codex`.
829
- // Leave the established owner in place. (init applies the same
830
- // one-writer rule up front when both targets are selected.)
831
- //
832
- // Skipping here means the tool is never recorded as configured, so a
833
- // persistent legacy signal re-offers it on later runs. Because no
834
- // replacement is written, this tool is also exempted from immediate
835
- // legacy cleanup (see skippedSharedSkillTools) — otherwise a repo-local
836
- // `.codex/prompts` would be deleted with nothing put in its place. That
837
- // repeat is idempotent and harmless — the alternative is the silent
838
- // hijack this prevents.
839
- const sharedOwner = shouldGenerateSkills
840
- ? sharedSkillRootOwner(projectPath, tool.value)
835
+ // Never overwrite a shared skills root owned by another tool. A tool
836
+ // with its own command surface can still install those commands: this
837
+ // is how a legacy Antigravity install gains `.agents/workflows` beside
838
+ // Codex-owned `.agents/skills`. A skills-only tool has no safe artifact
839
+ // to install, so preserve its legacy files and re-offer it later.
840
+ const sharedOwner = shouldGenerateSkills && !writesSkills
841
+ ? arbitrationTools.find((candidate) => candidate.skillsDir === tool.skillsDir &&
842
+ sharedSkillWriters.has(candidate.value))?.value
841
843
  : undefined;
842
- if (sharedOwner) {
844
+ if (sharedOwner && !shouldGenerateCommands) {
843
845
  const ownerName = AI_TOOLS.find((candidate) => candidate.value === sharedOwner)?.name ?? sharedOwner;
844
846
  spinner.info(`Skipped ${tool.name}: ${tool.skillsDir}/skills is already managed by another tool (${ownerName}).`);
845
847
  skippedSharedSkillTools.push(tool.value);
846
848
  continue;
847
849
  }
848
850
  // Create skill files when delivery includes skills
849
- if (shouldGenerateSkills) {
851
+ if (shouldGenerateSkills && writesSkills) {
850
852
  for (const { template, dirName } of skillTemplates) {
851
853
  const skillDir = path.join(skillsDir, dirName);
852
854
  const skillFile = path.join(skillDir, 'SKILL.md');
@@ -6,6 +6,8 @@ export declare const MIN_PURPOSE_LENGTH = 50;
6
6
  export declare const MAX_WHY_SECTION_LENGTH = 1000;
7
7
  export declare const MAX_REQUIREMENT_TEXT_LENGTH = 500;
8
8
  export declare const MAX_DELTAS_PER_CHANGE = 10;
9
+ export declare const PURPOSE_PLACEHOLDER_PREFIX = "TBD - created by archiving change ";
10
+ export declare const PURPOSE_PLACEHOLDER_SUFFIX = ". Update Purpose after archive.";
9
11
  export declare const VALIDATION_MESSAGES: {
10
12
  readonly SCENARIO_EMPTY: "Scenario text cannot be empty";
11
13
  readonly REQUIREMENT_EMPTY: "Requirement text cannot be empty";
@@ -26,6 +28,7 @@ export declare const VALIDATION_MESSAGES: {
26
28
  readonly DELTA_SPEC_EMPTY: "Spec name cannot be empty";
27
29
  readonly DELTA_DESCRIPTION_EMPTY: "Delta description cannot be empty";
28
30
  readonly PURPOSE_TOO_BRIEF: "Purpose section is too brief (less than 50 characters)";
31
+ readonly PURPOSE_IS_PLACEHOLDER: string;
29
32
  readonly REQUIREMENT_TOO_LONG: "Requirement text is very long (>500 characters). Consider breaking it down.";
30
33
  readonly DELTA_DESCRIPTION_TOO_BRIEF: "Delta description is too brief";
31
34
  readonly DELTA_MISSING_REQUIREMENTS: "Delta should include requirements";
@@ -8,6 +8,14 @@ export const MIN_PURPOSE_LENGTH = 50;
8
8
  export const MAX_WHY_SECTION_LENGTH = 1000;
9
9
  export const MAX_REQUIREMENT_TEXT_LENGTH = 500;
10
10
  export const MAX_DELTAS_PER_CHANGE = 10;
11
+ // The Purpose `openspec archive` writes into a main spec it creates when the
12
+ // delta introduced the capability without a usable `## Purpose`. Named here, and
13
+ // composed from these two halves at the write site, so validation recognises the
14
+ // placeholder through the same definition that produces it: a second, hand-copied
15
+ // spelling would stop matching the day the wording changed, and a check that
16
+ // matches nothing looks exactly like a check that found nothing.
17
+ export const PURPOSE_PLACEHOLDER_PREFIX = 'TBD - created by archiving change ';
18
+ export const PURPOSE_PLACEHOLDER_SUFFIX = '. Update Purpose after archive.';
11
19
  // Validation messages
12
20
  export const VALIDATION_MESSAGES = {
13
21
  // Required content
@@ -31,6 +39,10 @@ export const VALIDATION_MESSAGES = {
31
39
  DELTA_DESCRIPTION_EMPTY: 'Delta description cannot be empty',
32
40
  // Warnings
33
41
  PURPOSE_TOO_BRIEF: `Purpose section is too brief (less than ${MIN_PURPOSE_LENGTH} characters)`,
42
+ PURPOSE_IS_PLACEHOLDER: 'Purpose section is still a placeholder rather than a Purpose anyone wrote (the sentence `openspec archive` ' +
43
+ 'writes for a new capability, or a `TBD`/`TODO` marker left in its place). Replace it with what this ' +
44
+ 'capability is for, editing the main spec directly: a `## Purpose` in a delta is read only when the ' +
45
+ 'capability is created, so it cannot replace this one.',
34
46
  REQUIREMENT_TOO_LONG: `Requirement text is very long (>${MAX_REQUIREMENT_TEXT_LENGTH} characters). Consider breaking it down.`,
35
47
  DELTA_DESCRIPTION_TOO_BRIEF: 'Delta description is too brief',
36
48
  DELTA_MISSING_REQUIREMENTS: 'Delta should include requirements',
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Detects a `## Purpose` that is still a placeholder, rather than one somebody
3
+ * wrote.
4
+ *
5
+ * When a delta introduces a capability with no usable `## Purpose`, archive
6
+ * stamps the placeholder into the new main spec. That text is over
7
+ * `MIN_PURPOSE_LENGTH`, so the brevity check cannot reach it: the one rule that
8
+ * exists to catch a Purpose nobody wrote is satisfied by the exact string
9
+ * meaning nobody wrote one. Nothing else reads it afterwards, so the capability
10
+ * keeps a to-do in it while every command reports success.
11
+ *
12
+ * Two things count, and deliberately nothing else:
13
+ *
14
+ * - the placeholder this tool generates, recognised through the same constants
15
+ * the writer composes it from, wherever it sits in the Purpose - nobody types
16
+ * that sentence by accident;
17
+ * - a `TBD` or `TODO` **opening** the Purpose, which is the marker left behind
18
+ * when someone is told to leave "a brief TBD placeholder" and never comes
19
+ * back. Which of the two words got typed says nothing about whether the
20
+ * Purpose was written, so both are read the same way.
21
+ *
22
+ * A marker inside a sentence is left alone. "The retry budget is TBD pending
23
+ * benchmarks" is a real Purpose with an open question in it, and reporting it
24
+ * would teach people to ignore the warning - which costs more than the findings
25
+ * it would add.
26
+ *
27
+ * Fenced code inside the Purpose is quoted material rather than the Purpose
28
+ * speaking, so it is read out first. Without that, a Purpose documenting the
29
+ * sentence archive writes is reported as being that sentence: a document about
30
+ * the placeholder, failing for carrying one.
31
+ */
32
+ export interface PurposePlaceholderIssue {
33
+ /** 1-based line of the placeholder text, when it can be located. */
34
+ line?: number;
35
+ }
36
+ /**
37
+ * Reports the Purpose of a main spec as an unwritten placeholder, or null when
38
+ * it reads as authored content.
39
+ *
40
+ * An empty Purpose is not reported here - `SPEC_PURPOSE_EMPTY` already covers
41
+ * it, and reporting both would put two findings on one line. That falls out of
42
+ * the two rules rather than needing a case of its own.
43
+ */
44
+ export declare function findPurposePlaceholderIssue(overview: string, content?: string): PurposePlaceholderIssue | null;
45
+ //# sourceMappingURL=purpose-placeholder.d.ts.map
@@ -0,0 +1,117 @@
1
+ import { buildCodeFenceMask } from '../parsers/code-fence.js';
2
+ import { PURPOSE_PLACEHOLDER_PREFIX, PURPOSE_PLACEHOLDER_SUFFIX } from './constants.js';
3
+ /**
4
+ * A `TBD` or `TODO` opening the Purpose. The lookahead keeps it off a longer
5
+ * word that merely begins with those letters, like "TBDs" or "TODOs", while
6
+ * still allowing the punctuation a marker is usually written with: `TODO:`,
7
+ * `TBD -`. It rejects any letter, digit or combining mark rather than only the
8
+ * ASCII ones `\b` knows about, because a Purpose is prose and prose is not
9
+ * always written in Latin script - `TBD` followed by an Arabic-Indic digit is
10
+ * as much a longer word as `TBDs` is.
11
+ */
12
+ const LEADING_MARKER = /^(?:TBD|TODO)(?![\p{L}\p{N}\p{M}_])/iu;
13
+ const PURPOSE_HEADER = /^ {0,3}##(?!#)[ \t]+Purpose[ \t]*$/i;
14
+ const TOP_LEVEL_HEADER = /^ {0,3}#{1,2}(?!#)[ \t]+/;
15
+ /**
16
+ * The lines of `text` that sit outside a fenced code block, with line endings
17
+ * normalised first.
18
+ *
19
+ * `buildCodeFenceMask` is the masker the requirement and structure parsers
20
+ * already share, and its own reason for existing is that a second, private
21
+ * notion of what a fence is drifts from the first. This check reads Markdown
22
+ * for the same purpose they do, so it reads fences the same way they do.
23
+ */
24
+ function unfencedLines(text) {
25
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
26
+ const fenced = buildCodeFenceMask(lines);
27
+ return lines.filter((_, index) => !fenced[index]);
28
+ }
29
+ /**
30
+ * True when the text carries the sentence archive writes. Matched as its two
31
+ * fixed halves in order, because the change name between them varies - so the
32
+ * check follows the writer's own definition instead of a second copy of it.
33
+ */
34
+ function generatedPlaceholderPrefixIndex(text) {
35
+ let suffixAt = text.indexOf(PURPOSE_PLACEHOLDER_SUFFIX);
36
+ while (suffixAt !== -1) {
37
+ // Use the closest prefix before this suffix. An authored explanation can
38
+ // mention the prefix above the real placeholder; choosing the first prefix
39
+ // would then point the diagnostic at the explanation instead of the text
40
+ // the user needs to replace.
41
+ const prefixAt = text.lastIndexOf(PURPOSE_PLACEHOLDER_PREFIX, suffixAt);
42
+ if (prefixAt !== -1)
43
+ return prefixAt;
44
+ suffixAt = text.indexOf(PURPOSE_PLACEHOLDER_SUFFIX, suffixAt + 1);
45
+ }
46
+ return undefined;
47
+ }
48
+ /**
49
+ * Reports the Purpose of a main spec as an unwritten placeholder, or null when
50
+ * it reads as authored content.
51
+ *
52
+ * An empty Purpose is not reported here - `SPEC_PURPOSE_EMPTY` already covers
53
+ * it, and reporting both would put two findings on one line. That falls out of
54
+ * the two rules rather than needing a case of its own.
55
+ */
56
+ export function findPurposePlaceholderIssue(overview, content) {
57
+ // An empty Purpose needs no branch of its own: neither rule matches empty
58
+ // text, so it falls through to null on the line below. An early return for it
59
+ // would be a guard no test could hold, which is worse than none. A Purpose
60
+ // that is nothing but a fenced block reduces to the same empty text here, and
61
+ // is left to the brevity and empty-Purpose rules for the same reason.
62
+ const prose = unfencedLines(overview).join('\n').trim();
63
+ const leading = LEADING_MARKER.test(prose);
64
+ if (!leading && generatedPlaceholderPrefixIndex(prose) === undefined)
65
+ return null;
66
+ // Which rule matched decides where the placeholder is, so the locator is told.
67
+ // When both match the leading marker wins: it sits at or above the generated
68
+ // sentence, and the earliest marker is the one a reader scanning down meets.
69
+ return { line: content === undefined ? undefined : findPlaceholderLine(content, leading) };
70
+ }
71
+ /**
72
+ * The line inside the `## Purpose` section carrying the placeholder, so the
73
+ * warning points at the text to replace rather than at the file.
74
+ *
75
+ * Which line that is depends on the rule that matched. A leading marker is the
76
+ * section's first non-blank line by definition. The generated sentence is not:
77
+ * it can follow prose somebody wrote, and naming the first non-blank line then
78
+ * points at that prose - a line the reader can see is fine, which reads as the
79
+ * check being wrong rather than the Purpose being unwritten.
80
+ *
81
+ * Fenced lines are skipped on the way, for the reason detection skips them, and
82
+ * so a `## Requirements` quoted inside a fence cannot end the section early.
83
+ *
84
+ * Undefined when the placeholder cannot be located - no section header, or a
85
+ * generated sentence no single line carries. The caller then reports the finding
86
+ * without a line rather than with a guessed one, since a wrong line number is
87
+ * worse than none.
88
+ *
89
+ * Line endings are normalised first, so the same spec reports the same line
90
+ * whether it was saved on Windows or on macOS/Linux.
91
+ */
92
+ function findPlaceholderLine(content, leading) {
93
+ const lines = content.replace(/\r\n?/g, '\n').split('\n');
94
+ const fenced = buildCodeFenceMask(lines);
95
+ const headerIndex = lines.findIndex((line, index) => !fenced[index] && PURPOSE_HEADER.test(line));
96
+ if (headerIndex === -1)
97
+ return undefined;
98
+ const purposeLines = [];
99
+ for (let i = headerIndex + 1; i < lines.length; i++) {
100
+ if (fenced[i])
101
+ continue;
102
+ if (TOP_LEVEL_HEADER.test(lines[i]))
103
+ break;
104
+ if (leading && lines[i].trim())
105
+ return i + 1;
106
+ purposeLines.push({ line: i + 1, text: lines[i] });
107
+ }
108
+ if (leading)
109
+ return undefined;
110
+ const purpose = purposeLines.map(({ text }) => text).join('\n');
111
+ const prefixAt = generatedPlaceholderPrefixIndex(purpose);
112
+ if (prefixAt === undefined)
113
+ return undefined;
114
+ const lineOffset = purpose.slice(0, prefixAt).split('\n').length - 1;
115
+ return purposeLines[lineOffset]?.line;
116
+ }
117
+ //# sourceMappingURL=purpose-placeholder.js.map
@@ -12,6 +12,7 @@ import { discoverSpecFiles, hasAnyFileUnder } from '../../utils/spec-discovery.j
12
12
  import { METADATA_FILENAME, readSkipSpecsMarker, resolveSchemaForChange, } from '../../utils/change-metadata.js';
13
13
  import { resolveTaskFilesForChange } from '../../utils/task-progress.js';
14
14
  import { findTaskNumberingIssues } from './task-numbering.js';
15
+ import { findPurposePlaceholderIssue } from './purpose-placeholder.js';
15
16
  import { getPackageSchemasDir, getSchemaDir } from '../artifact-graph/index.js';
16
17
  export class Validator {
17
18
  strictMode;
@@ -557,7 +558,21 @@ export class Validator {
557
558
  message: structuralIssue.message,
558
559
  });
559
560
  }
560
- if (spec.overview.length < MIN_PURPOSE_LENGTH) {
561
+ // The placeholder is longer than MIN_PURPOSE_LENGTH, so the brevity check
562
+ // below cannot reach it; it is reported on its own terms instead. Checked
563
+ // first because a hand-written "TBD" is both a placeholder and too brief,
564
+ // and only one of those two tells the author what to do. (A "TODO" opening
565
+ // the Purpose reads the same way, so it is the same finding.)
566
+ const placeholder = findPurposePlaceholderIssue(spec.overview, content);
567
+ if (placeholder) {
568
+ issues.push({
569
+ level: 'WARNING',
570
+ path: 'overview',
571
+ line: placeholder.line,
572
+ message: VALIDATION_MESSAGES.PURPOSE_IS_PLACEHOLDER,
573
+ });
574
+ }
575
+ else if (spec.overview.length < MIN_PURPOSE_LENGTH) {
561
576
  issues.push({
562
577
  level: 'WARNING',
563
578
  path: 'overview',
@@ -0,0 +1,42 @@
1
+ /** A main-spec requirement block, and how its header matched the delta's. */
2
+ export interface MatchedRequirementBlock {
3
+ raw: string;
4
+ /** The header name as the main spec spells it. */
5
+ name: string;
6
+ /**
7
+ * False when the two headers differ only in case or interior spacing. Archive
8
+ * matches names exactly, so an inexact match is a real problem to report even
9
+ * though the diff below it is still the one the author meant.
10
+ */
11
+ exact: boolean;
12
+ }
13
+ /**
14
+ * Find the raw markdown block for a requirement by name in a spec file.
15
+ *
16
+ * Delegates to extractRequirementsSection() which already handles code fences,
17
+ * section boundaries, and requirement header parsing. We just look up by name:
18
+ * exactly first, then falling back to the shared case/whitespace fold so a
19
+ * header that differs only in spelling still shows its diff, flagged inexact.
20
+ *
21
+ * Returns null if no requirement header matches either way.
22
+ */
23
+ export declare function extractRequirementBlock(specContent: string, requirementName: string): MatchedRequirementBlock | null;
24
+ /**
25
+ * Compute a unified diff between a main-spec requirement block and the delta
26
+ * block that replaces it. A null main block (new capability) diffs against the
27
+ * empty string, so every line reads as an addition.
28
+ *
29
+ * Uses structuredPatch to retain unified-diff hunk ranges while omitting the
30
+ * synthetic file headers, since the caller provides its own labeling.
31
+ */
32
+ export declare function diffRequirementBlock(baseBlock: string | null, deltaBlock: string, label: string): string;
33
+ /**
34
+ * Build a map from folded RENAMED TO name to the original main-spec name.
35
+ * Renames apply in source order, so a chain such as A -> B -> C maps C back to
36
+ * A. That is the block a later MODIFIED C replaces.
37
+ */
38
+ export declare function buildRenameMap(renames: Array<{
39
+ from: string;
40
+ to: string;
41
+ }>): Map<string, string>;
42
+ //# sourceMappingURL=requirement-diff.d.ts.map