@fission-ai/openspec 1.9.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 (72) hide show
  1. package/dist/cli/index.d.ts +17 -0
  2. package/dist/cli/index.js +60 -9
  3. package/dist/commands/change.d.ts +24 -0
  4. package/dist/commands/change.js +223 -0
  5. package/dist/commands/config.js +14 -3
  6. package/dist/commands/feedback.js +28 -7
  7. package/dist/commands/schema.d.ts +5 -0
  8. package/dist/commands/schema.js +201 -35
  9. package/dist/commands/show.js +1 -1
  10. package/dist/commands/workflow/index.d.ts +1 -1
  11. package/dist/commands/workflow/index.js +1 -1
  12. package/dist/commands/workflow/status.d.ts +2 -0
  13. package/dist/commands/workflow/status.js +76 -12
  14. package/dist/core/archive.js +64 -7
  15. package/dist/core/artifact-graph/instruction-loader.js +2 -7
  16. package/dist/core/artifact-graph/outputs.d.ts +4 -0
  17. package/dist/core/artifact-graph/outputs.js +7 -0
  18. package/dist/core/artifact-graph/resolver.js +15 -9
  19. package/dist/core/available-tools.js +16 -1
  20. package/dist/core/command-generation/adapters/antigravity.d.ts +1 -1
  21. package/dist/core/command-generation/adapters/antigravity.js +2 -2
  22. package/dist/core/command-generation/adapters/opencode.d.ts +2 -1
  23. package/dist/core/command-generation/adapters/opencode.js +13 -2
  24. package/dist/core/completion-tip.d.ts +15 -0
  25. package/dist/core/completion-tip.js +133 -0
  26. package/dist/core/completions/command-registry.js +20 -0
  27. package/dist/core/completions/factory.d.ts +11 -0
  28. package/dist/core/completions/generators/fish-generator.d.ts +12 -0
  29. package/dist/core/completions/generators/fish-generator.js +79 -47
  30. package/dist/core/completions/installers/bash-installer.d.ts +7 -0
  31. package/dist/core/completions/installers/bash-installer.js +15 -0
  32. package/dist/core/completions/installers/fish-installer.d.ts +7 -0
  33. package/dist/core/completions/installers/fish-installer.js +15 -0
  34. package/dist/core/completions/installers/powershell-installer.d.ts +7 -0
  35. package/dist/core/completions/installers/powershell-installer.js +15 -0
  36. package/dist/core/completions/templates/fish-templates.d.ts +1 -1
  37. package/dist/core/completions/templates/fish-templates.js +107 -10
  38. package/dist/core/completions/types.d.ts +4 -0
  39. package/dist/core/config-prompts.js +19 -10
  40. package/dist/core/config-schema.d.ts +1 -0
  41. package/dist/core/config-schema.js +2 -0
  42. package/dist/core/config.js +7 -1
  43. package/dist/core/global-config.d.ts +2 -0
  44. package/dist/core/init.d.ts +5 -0
  45. package/dist/core/init.js +117 -14
  46. package/dist/core/legacy-cleanup.js +6 -0
  47. package/dist/core/migration.js +42 -12
  48. package/dist/core/parsers/requirement-blocks.d.ts +1 -0
  49. package/dist/core/parsers/requirement-blocks.js +2 -0
  50. package/dist/core/profiles.d.ts +1 -1
  51. package/dist/core/profiles.js +11 -2
  52. package/dist/core/shared/tool-detection.js +9 -3
  53. package/dist/core/shared-skill-target.d.ts +13 -0
  54. package/dist/core/shared-skill-target.js +43 -1
  55. package/dist/core/specs-apply.js +16 -8
  56. package/dist/core/templates/workflows/explore.js +79 -73
  57. package/dist/core/templates/workflows/feedback.js +1 -0
  58. package/dist/core/templates/workflows/onboard.js +4 -4
  59. package/dist/core/update.js +39 -26
  60. package/dist/core/validation/constants.d.ts +3 -0
  61. package/dist/core/validation/constants.js +12 -0
  62. package/dist/core/validation/purpose-placeholder.d.ts +45 -0
  63. package/dist/core/validation/purpose-placeholder.js +117 -0
  64. package/dist/core/validation/validator.js +16 -1
  65. package/dist/telemetry/index.js +3 -2
  66. package/dist/utils/change-metadata.js +17 -13
  67. package/dist/utils/change-utils.js +5 -0
  68. package/dist/utils/requirement-diff.d.ts +42 -0
  69. package/dist/utils/requirement-diff.js +61 -0
  70. package/package.json +4 -6
  71. package/schemas/spec-driven/schema.yaml +17 -7
  72. package/scripts/postinstall.js +0 -83
@@ -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');
@@ -135,7 +135,7 @@ export class UpdateCommand {
135
135
  // Still check for new tool directories and extra workflows
136
136
  this.detectNewTools(resolvedProjectPath, configuredTools);
137
137
  this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows);
138
- this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows);
138
+ this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows);
139
139
  this.displaySetupNotes(configuredTools);
140
140
  return;
141
141
  }
@@ -154,7 +154,11 @@ 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 = [];
161
+ const updatedToolIds = [];
158
162
  const failedTools = [];
159
163
  const skillsInvocableCommandSkips = [];
160
164
  const zeroArtifactTools = [];
@@ -172,11 +176,12 @@ export class UpdateCommand {
172
176
  const skillsRoot = hasGlobalSkillTarget(tool) ? skillsDir : resolvedProjectPath;
173
177
  const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery);
174
178
  const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery);
179
+ const writesSkills = !tool.skillsDir || sharedSkillWriters.has(tool.value);
175
180
  const toolWorkflows = legacyWorkflowOverrides[tool.value] ?? desiredWorkflows;
176
181
  const skillTemplates = getSkillTemplates(toolWorkflows);
177
182
  const commandContents = getCommandContents(toolWorkflows);
178
183
  // Generate skill files if delivery includes skills
179
- if (shouldGenerateSkills) {
184
+ if (shouldGenerateSkills && writesSkills) {
180
185
  for (const { template, dirName } of skillTemplates) {
181
186
  const skillDir = path.join(skillsDir, dirName);
182
187
  const skillFile = path.join(skillDir, 'SKILL.md');
@@ -189,7 +194,9 @@ export class UpdateCommand {
189
194
  removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsRoot, skillsDir, toolWorkflows);
190
195
  }
191
196
  // Delete skill directories if delivery is commands-only
192
- if (shouldRemoveSkillsForTool(tool.value, delivery) && !hasGlobalSkillTarget(tool)) {
197
+ if (shouldRemoveSkillsForTool(tool.value, delivery) &&
198
+ writesSkills &&
199
+ !hasGlobalSkillTarget(tool)) {
193
200
  removedSkillCount += await this.removeSkillDirs(skillsRoot, skillsDir);
194
201
  // Persist the selected owner even when commands-only delivery leaves
195
202
  // this target with no generated skills.
@@ -223,6 +230,7 @@ export class UpdateCommand {
223
230
  }
224
231
  spinner.succeed(`Updated ${tool.name}`);
225
232
  updatedTools.push(tool.name);
233
+ updatedToolIds.push(tool.value);
226
234
  for (const migration of migrateLegacyToolDirs(resolvedProjectPath, [tool.value], 'after-generation')) {
227
235
  if (hasMovableContent(migration)) {
228
236
  console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`));
@@ -310,7 +318,7 @@ export class UpdateCommand {
310
318
  this.detectNewTools(resolvedProjectPath, configuredAndNewTools);
311
319
  // 14. Display note about extra workflows not in profile
312
320
  this.displayExtraWorkflowsNote(resolvedProjectPath, configuredAndNewTools, desiredWorkflows);
313
- this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows);
321
+ this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows);
314
322
  this.displaySetupNotes(configuredAndNewTools);
315
323
  // 15. List affected tools
316
324
  if (updatedTools.length > 0) {
@@ -318,7 +326,16 @@ export class UpdateCommand {
318
326
  console.log(chalk.dim(`Tools: ${toolDisplayNames.join(', ')}`));
319
327
  }
320
328
  console.log();
321
- console.log(chalk.dim('Restart your IDE for changes to take effect.'));
329
+ const affectedToolIds = [...new Set([...newlyConfiguredTools, ...updatedToolIds])];
330
+ const shouldRestartIde = affectedToolIds.some((toolId) => {
331
+ const tool = AI_TOOLS.find((candidate) => candidate.value === toolId);
332
+ return Boolean(tool?.requiresIdeRestart &&
333
+ (shouldGenerateCommandsForTool(toolId, delivery) ||
334
+ shouldGenerateSkillsForTool(toolId, delivery)));
335
+ });
336
+ if (shouldRestartIde) {
337
+ console.log(chalk.dim('Restart your IDE for changes to take effect.'));
338
+ }
322
339
  if (failedTools.length > 0) {
323
340
  throw new Error(`OpenSpec update failed for: ${failedTools.map((tool) => tool.name).join(', ')}`);
324
341
  }
@@ -787,11 +804,15 @@ export class UpdateCommand {
787
804
  return { newlyConfiguredTools: [], workflowOverrides: {} };
788
805
  }
789
806
  }
790
- const inferredCodexWorkflows = getLegacyWorkflowIdsForTool(detection, 'codex');
807
+ const inferredCodexWorkflows = getProfileWorkflows('custom', getLegacyWorkflowIdsForTool(detection, 'codex')).filter((workflow) => ALL_WORKFLOWS.includes(workflow));
791
808
  // Create skills/commands for selected tools using effective profile+delivery.
792
809
  const newlyConfigured = [];
793
810
  const skippedSharedSkillTools = [];
794
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);
795
816
  for (const toolId of selectedTools) {
796
817
  const tool = AI_TOOLS.find((t) => t.value === toolId);
797
818
  if (!tool || !toolSupportsSkills(tool))
@@ -802,6 +823,7 @@ export class UpdateCommand {
802
823
  const skillsRoot = hasGlobalSkillTarget(tool) ? skillsDir : projectPath;
803
824
  const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery);
804
825
  const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery);
826
+ const writesSkills = !tool.skillsDir || sharedSkillWriters.has(tool.value);
805
827
  const toolWorkflows = (tool.value === 'codex' && inferredCodexWorkflows.length > 0
806
828
  ? inferredCodexWorkflows
807
829
  : desiredWorkflows);
@@ -810,32 +832,23 @@ export class UpdateCommand {
810
832
  }
811
833
  const skillTemplates = getSkillTemplates(toolWorkflows);
812
834
  const commandContents = getCommandContents(toolWorkflows);
813
- // A shared skills root (e.g. `.agents`) already owned by another tool
814
- // must not be overwritten by a tool inferred from legacy artifacts: a
815
- // Codex install detected only from global `~/.codex/prompts` would
816
- // otherwise rewrite an existing vendor-neutral `agents` tree with
817
- // Codex-specific syntax and flip its ownership marker `agents → codex`.
818
- // Leave the established owner in place. (init applies the same
819
- // one-writer rule up front when both targets are selected.)
820
- //
821
- // Skipping here means the tool is never recorded as configured, so a
822
- // persistent legacy signal re-offers it on later runs. Because no
823
- // replacement is written, this tool is also exempted from immediate
824
- // legacy cleanup (see skippedSharedSkillTools) — otherwise a repo-local
825
- // `.codex/prompts` would be deleted with nothing put in its place. That
826
- // repeat is idempotent and harmless — the alternative is the silent
827
- // hijack this prevents.
828
- const sharedOwner = shouldGenerateSkills
829
- ? 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
830
843
  : undefined;
831
- if (sharedOwner) {
844
+ if (sharedOwner && !shouldGenerateCommands) {
832
845
  const ownerName = AI_TOOLS.find((candidate) => candidate.value === sharedOwner)?.name ?? sharedOwner;
833
846
  spinner.info(`Skipped ${tool.name}: ${tool.skillsDir}/skills is already managed by another tool (${ownerName}).`);
834
847
  skippedSharedSkillTools.push(tool.value);
835
848
  continue;
836
849
  }
837
850
  // Create skill files when delivery includes skills
838
- if (shouldGenerateSkills) {
851
+ if (shouldGenerateSkills && writesSkills) {
839
852
  for (const { template, dirName } of skillTemplates) {
840
853
  const skillDir = path.join(skillsDir, dirName);
841
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',
@@ -174,8 +174,9 @@ export async function maybeShowTelemetryNotice(options = {}) {
174
174
  if (options.silent) {
175
175
  return;
176
176
  }
177
- // Display notice
178
- console.log('Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0 or openspec config set telemetry.enabled false');
177
+ // Display notice on stderr, not stdout: stdout is reserved for command
178
+ // output (raw passthrough text, JSON, etc.) and must stay parser/pipe-safe.
179
+ console.error('Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0 or openspec config set telemetry.enabled false');
179
180
  // Mark as seen
180
181
  await updateTelemetryConfig({ noticeSeen: true });
181
182
  }
@@ -179,6 +179,18 @@ export function readRetireCapabilitiesMarker(changeDir) {
179
179
  * name. One body rather than two, so a marker can never drift into honoring
180
180
  * metadata the other rejects - the whole point of the contract described above.
181
181
  */
182
+ /**
183
+ * A marker that cannot be honored, with its reason made safe to print.
184
+ *
185
+ * Every reason quotes something the author wrote - a schema name, a parser
186
+ * message carrying one, a filesystem error carrying a path - and callers print
187
+ * it straight to a terminal (`openspec archive`, `openspec validate`). A raw CR
188
+ * could forge a line of its own and an ESC could redraw the screen, so control
189
+ * characters never leave this function.
190
+ */
191
+ function unhonorable(reason) {
192
+ return { declared: false, invalidReason: reason.replace(/[\u0000-\u001f\u007f]/g, '?') };
193
+ }
182
194
  function readBooleanMarker(changeDir, key) {
183
195
  let raw;
184
196
  try {
@@ -193,10 +205,7 @@ function readBooleanMarker(changeDir, key) {
193
205
  // set cannot be determined - fail closed rather than let archive treat
194
206
  // the change as unmarked while every metadata-reading surface errors.
195
207
  const message = err instanceof Error ? err.message : String(err);
196
- return {
197
- declared: false,
198
- invalidReason: `the metadata file cannot be read (${message})`,
199
- };
208
+ return unhonorable(`the metadata file cannot be read (${message})`);
200
209
  }
201
210
  let parsed;
202
211
  try {
@@ -206,9 +215,7 @@ function readBooleanMarker(changeDir, key) {
206
215
  // Anchored so a comment like "# maybe add skip_specs later" does not
207
216
  // claim the marker was set.
208
217
  const mentioned = new RegExp(`^\\s*(['"]?)${key}\\1\\s*:`, 'm').test(raw);
209
- return mentioned
210
- ? { declared: false, invalidReason: 'the file is not valid YAML' }
211
- : { declared: false };
218
+ return mentioned ? unhonorable('the file is not valid YAML') : { declared: false };
212
219
  }
213
220
  const result = ChangeMetadataSchema.safeParse(parsed);
214
221
  if (result.success) {
@@ -225,16 +232,13 @@ function readBooleanMarker(changeDir, key) {
225
232
  try {
226
233
  const projectRoot = path.resolve(changeDir, '../../..');
227
234
  if (!listSchemas(projectRoot).includes(result.data.schema)) {
228
- return {
229
- declared: false,
230
- invalidReason: `schema: unknown schema '${result.data.schema}'`,
231
- };
235
+ return unhonorable(`schema: unknown schema '${result.data.schema}'`);
232
236
  }
233
237
  resolveSchema(result.data.schema, projectRoot);
234
238
  }
235
239
  catch (err) {
236
240
  const message = err instanceof Error ? err.message : String(err);
237
- return { declared: false, invalidReason: message };
241
+ return unhonorable(message);
238
242
  }
239
243
  return { declared: true };
240
244
  }
@@ -250,7 +254,7 @@ function readBooleanMarker(changeDir, key) {
250
254
  if (markerMentioned) {
251
255
  const first = result.error.issues[0];
252
256
  const where = first.path.length > 0 ? `${first.path.join('.')}: ` : '';
253
- return { declared: false, invalidReason: `${where}${first.message}` };
257
+ return unhonorable(`${where}${first.message}`);
254
258
  }
255
259
  return { declared: false };
256
260
  }
@@ -4,6 +4,8 @@ import { writeChangeMetadata, validateSchemaName } from './change-metadata.js';
4
4
  import { formatLocalDate } from './date.js';
5
5
  import { readProjectConfig } from '../core/project-config.js';
6
6
  import { isKebabId } from '../core/id.js';
7
+ import { resolveSchema } from '../core/artifact-graph/resolver.js';
8
+ import { isSpecsArtifactPath } from '../core/artifact-graph/outputs.js';
7
9
  const DEFAULT_SCHEMA = 'spec-driven';
8
10
  /**
9
11
  * Validates that a change name follows kebab-case conventions.
@@ -117,6 +119,8 @@ export async function createChange(projectRoot, name, options = {}) {
117
119
  if (await FileSystemUtils.directoryExists(changeDir)) {
118
120
  throw new Error(`Change '${name}' already exists at ${changeDir}`);
119
121
  }
122
+ const schema = resolveSchema(schemaName, projectRoot);
123
+ const skipsSpecs = !schema.artifacts.some(artifact => isSpecsArtifactPath(artifact.generates));
120
124
  // Creating a change may scaffold or complete the root itself (an
121
125
  // implicit root, or a config-only/incomplete clone). Never leave a
122
126
  // half-root behind that doctor immediately calls unhealthy: ensure
@@ -138,6 +142,7 @@ export async function createChange(projectRoot, name, options = {}) {
138
142
  writeChangeMetadata(changeDir, {
139
143
  schema: schemaName,
140
144
  created: formatLocalDate(),
145
+ ...(skipsSpecs ? { skip_specs: true } : {}),
141
146
  ...options.metadata,
142
147
  }, projectRoot);
143
148
  return { schema: schemaName, changeDir };
@@ -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
@@ -0,0 +1,61 @@
1
+ import { formatPatch, OMIT_HEADERS, structuredPatch } from 'diff';
2
+ import { extractRequirementsSection, foldRequirementName, normalizeRequirementName, } from '../core/parsers/requirement-blocks.js';
3
+ /**
4
+ * Find the raw markdown block for a requirement by name in a spec file.
5
+ *
6
+ * Delegates to extractRequirementsSection() which already handles code fences,
7
+ * section boundaries, and requirement header parsing. We just look up by name:
8
+ * exactly first, then falling back to the shared case/whitespace fold so a
9
+ * header that differs only in spelling still shows its diff, flagged inexact.
10
+ *
11
+ * Returns null if no requirement header matches either way.
12
+ */
13
+ export function extractRequirementBlock(specContent, requirementName) {
14
+ const parts = extractRequirementsSection(specContent);
15
+ const targetName = normalizeRequirementName(requirementName);
16
+ const foldedTarget = foldRequirementName(targetName);
17
+ let folded = null;
18
+ for (const block of parts.bodyBlocks) {
19
+ const name = normalizeRequirementName(block.name);
20
+ if (name === targetName) {
21
+ return { raw: block.raw, name, exact: true };
22
+ }
23
+ if (!folded && foldRequirementName(name) === foldedTarget) {
24
+ folded = { raw: block.raw, name, exact: false };
25
+ }
26
+ }
27
+ return folded;
28
+ }
29
+ /**
30
+ * Compute a unified diff between a main-spec requirement block and the delta
31
+ * block that replaces it. A null main block (new capability) diffs against the
32
+ * empty string, so every line reads as an addition.
33
+ *
34
+ * Uses structuredPatch to retain unified-diff hunk ranges while omitting the
35
+ * synthetic file headers, since the caller provides its own labeling.
36
+ */
37
+ export function diffRequirementBlock(baseBlock, deltaBlock, label) {
38
+ const base = ensureTrailingNewline(baseBlock ?? '');
39
+ const delta = ensureTrailingNewline(deltaBlock);
40
+ const patch = structuredPatch(label, label, base, delta);
41
+ return formatPatch(patch, OMIT_HEADERS).trimEnd();
42
+ }
43
+ function ensureTrailingNewline(s) {
44
+ return s.endsWith('\n') ? s : s + '\n';
45
+ }
46
+ /**
47
+ * Build a map from folded RENAMED TO name to the original main-spec name.
48
+ * Renames apply in source order, so a chain such as A -> B -> C maps C back to
49
+ * A. That is the block a later MODIFIED C replaces.
50
+ */
51
+ export function buildRenameMap(renames) {
52
+ const map = new Map();
53
+ for (const r of renames) {
54
+ const from = foldRequirementName(r.from);
55
+ const original = map.get(from) ?? normalizeRequirementName(r.from);
56
+ map.delete(from);
57
+ map.set(foldRequirementName(r.to), original);
58
+ }
59
+ return map;
60
+ }
61
+ //# sourceMappingURL=requirement-diff.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fission-ai/openspec",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "openspec",
@@ -33,7 +33,6 @@
33
33
  "dist",
34
34
  "bin",
35
35
  "schemas",
36
- "scripts/postinstall.js",
37
36
  "!dist/**/*.test.js",
38
37
  "!dist/**/__tests__",
39
38
  "!dist/**/*.map"
@@ -53,10 +52,11 @@
53
52
  "vitest": "^3.2.6"
54
53
  },
55
54
  "dependencies": {
56
- "@inquirer/core": "^10.3.2",
57
- "@inquirer/prompts": "^7.10.1",
55
+ "@inquirer/core": "^11.2.1",
56
+ "@inquirer/prompts": "^8.5.2",
58
57
  "chalk": "^5.6.2",
59
58
  "commander": "^14.0.0",
59
+ "diff": "^9.0.0",
60
60
  "cross-spawn": "7.0.6",
61
61
  "fast-glob": "^3.3.3",
62
62
  "ora": "^9.4.1",
@@ -74,8 +74,6 @@
74
74
  "test:watch": "vitest",
75
75
  "test:ui": "vitest --ui",
76
76
  "test:coverage": "vitest --coverage",
77
- "test:postinstall": "node scripts/postinstall.js",
78
- "postinstall": "node scripts/postinstall.js",
79
77
  "check:pack-version": "node scripts/pack-version-check.mjs",
80
78
  "release": "pnpm run release:ci",
81
79
  "release:ci": "pnpm run check:pack-version && pnpm exec changeset publish",