@fission-ai/openspec 1.12.0 → 1.13.1

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 (105) hide show
  1. package/README.md +3 -15
  2. package/dist/commands/change.js +12 -1
  3. package/dist/commands/config.d.ts +9 -0
  4. package/dist/commands/config.js +161 -22
  5. package/dist/commands/feedback.js +6 -4
  6. package/dist/commands/schema.js +30 -9
  7. package/dist/commands/store.js +5 -2
  8. package/dist/commands/validate.d.ts +7 -0
  9. package/dist/commands/validate.js +63 -1
  10. package/dist/commands/workflow/instructions.js +170 -16
  11. package/dist/commands/workflow/new-change.js +20 -0
  12. package/dist/commands/workflow/shared.d.ts +8 -0
  13. package/dist/commands/workflow/shared.js +8 -0
  14. package/dist/commands/workflow/status.d.ts +5 -1
  15. package/dist/commands/workflow/status.js +42 -5
  16. package/dist/core/archive.js +17 -1
  17. package/dist/core/artifact-graph/schema.d.ts +14 -0
  18. package/dist/core/artifact-graph/schema.js +52 -0
  19. package/dist/core/artifact-graph/types.js +12 -1
  20. package/dist/core/change-status-policy.d.ts +14 -0
  21. package/dist/core/change-status-policy.js +14 -6
  22. package/dist/core/command-generation/generator.js +2 -0
  23. package/dist/core/completion-tip.js +7 -7
  24. package/dist/core/completions/installers/bash-installer.js +15 -7
  25. package/dist/core/completions/installers/shell-quote.d.ts +11 -0
  26. package/dist/core/completions/installers/shell-quote.js +13 -0
  27. package/dist/core/completions/installers/zsh-installer.js +3 -2
  28. package/dist/core/config-schema.d.ts +1 -1
  29. package/dist/core/config.d.ts +16 -0
  30. package/dist/core/config.js +27 -1
  31. package/dist/core/global-config.d.ts +30 -2
  32. package/dist/core/global-config.js +57 -3
  33. package/dist/core/init.js +33 -3
  34. package/dist/core/legacy-cleanup.d.ts +4 -0
  35. package/dist/core/legacy-cleanup.js +180 -12
  36. package/dist/core/list.js +55 -11
  37. package/dist/core/migration.js +6 -1
  38. package/dist/core/onboarding-commands.d.ts +11 -1
  39. package/dist/core/onboarding-commands.js +26 -0
  40. package/dist/core/parsers/change-parser.d.ts +12 -1
  41. package/dist/core/parsers/change-parser.js +85 -87
  42. package/dist/core/parsers/markdown-parser.js +4 -3
  43. package/dist/core/parsers/requirement-blocks.d.ts +44 -3
  44. package/dist/core/parsers/requirement-blocks.js +164 -43
  45. package/dist/core/parsers/requirement-text.d.ts +13 -2
  46. package/dist/core/parsers/requirement-text.js +34 -7
  47. package/dist/core/parsers/spec-structure.js +4 -1
  48. package/dist/core/project-config.d.ts +9 -1
  49. package/dist/core/project-config.js +11 -2
  50. package/dist/core/references.d.ts +20 -0
  51. package/dist/core/references.js +56 -0
  52. package/dist/core/shared/skill-content-equivalence.js +5 -1
  53. package/dist/core/shared/skill-generation.js +36 -8
  54. package/dist/core/shared/tool-detection.js +37 -4
  55. package/dist/core/specs-apply.js +260 -18
  56. package/dist/core/store/git.d.ts +33 -0
  57. package/dist/core/store/git.js +60 -6
  58. package/dist/core/store/operations.d.ts +1 -1
  59. package/dist/core/store/operations.js +32 -10
  60. package/dist/core/store/registry.d.ts +2 -1
  61. package/dist/core/store/registry.js +5 -1
  62. package/dist/core/templates/optional-workflow.d.ts +69 -0
  63. package/dist/core/templates/optional-workflow.js +140 -0
  64. package/dist/core/templates/workflows/apply-change.js +18 -3
  65. package/dist/core/templates/workflows/archive-change.js +50 -15
  66. package/dist/core/templates/workflows/bulk-archive-change.js +63 -13
  67. package/dist/core/templates/workflows/continue-change.js +14 -2
  68. package/dist/core/templates/workflows/explore.js +48 -14
  69. package/dist/core/templates/workflows/ff-change.js +19 -3
  70. package/dist/core/templates/workflows/new-change.js +15 -3
  71. package/dist/core/templates/workflows/onboard.js +67 -37
  72. package/dist/core/templates/workflows/project-root.d.ts +29 -0
  73. package/dist/core/templates/workflows/project-root.js +38 -0
  74. package/dist/core/templates/workflows/propose.js +52 -15
  75. package/dist/core/templates/workflows/sync-specs.js +40 -1
  76. package/dist/core/templates/workflows/update-change.js +52 -25
  77. package/dist/core/templates/workflows/verify-change.js +12 -3
  78. package/dist/core/update.d.ts +34 -0
  79. package/dist/core/update.js +109 -11
  80. package/dist/core/validation/task-checkboxes.d.ts +34 -0
  81. package/dist/core/validation/task-checkboxes.js +181 -0
  82. package/dist/core/validation/validator.d.ts +53 -1
  83. package/dist/core/validation/validator.js +154 -40
  84. package/dist/core/version-check.d.ts +14 -7
  85. package/dist/core/version-check.js +63 -17
  86. package/dist/core/worksets.js +6 -4
  87. package/dist/prompts/searchable-multi-select.d.ts +7 -0
  88. package/dist/prompts/searchable-multi-select.js +35 -6
  89. package/dist/telemetry/config.js +14 -2
  90. package/dist/telemetry/index.d.ts +4 -3
  91. package/dist/telemetry/index.js +22 -10
  92. package/dist/telemetry/opt-out.d.ts +17 -0
  93. package/dist/telemetry/opt-out.js +27 -0
  94. package/dist/utils/nested-change.d.ts +35 -0
  95. package/dist/utils/nested-change.js +186 -0
  96. package/dist/utils/spec-discovery.d.ts +20 -0
  97. package/dist/utils/spec-discovery.js +59 -0
  98. package/dist/utils/task-progress.d.ts +1 -1
  99. package/dist/utils/task-progress.js +38 -6
  100. package/package.json +4 -4
  101. package/schemas/spec-driven/schema.yaml +24 -5
  102. package/schemas/spec-driven/templates/design.md +2 -0
  103. package/schemas/spec-driven/templates/proposal.md +2 -0
  104. package/schemas/spec-driven/templates/spec.md +2 -0
  105. package/schemas/spec-driven/templates/tasks.md +2 -0
@@ -22,7 +22,7 @@ function isLexicallyWithin(allowedDirectory, targetPath) {
22
22
  !relative.startsWith(`..${path.sep}`) &&
23
23
  !path.isAbsolute(relative)));
24
24
  }
25
- function resolveTrustedSpecPath(specsRoot, specPath) {
25
+ function resolveTrustedSpecPath(specsRoot, specPath, projectRoot) {
26
26
  if (!isLexicallyWithin(specsRoot, specPath)) {
27
27
  throw new Error(`Path is outside the allowed directory: ${specPath}`);
28
28
  }
@@ -42,6 +42,14 @@ function resolveTrustedSpecPath(specsRoot, specPath) {
42
42
  // Freeze their canonical location as the trust root so later swaps are
43
43
  // rejected while a nested spec.md link still cannot escape.
44
44
  const root = FileSystemUtils.canonicalizeExistingPath(path.dirname(specPath));
45
+ // An external capability link is deliberate and supported (see
46
+ // assertDiscoveredSpecPath), so it is not refused here. What was wrong is
47
+ // that the write was silent: the CLI reported the in-project path while
48
+ // writing somewhere else entirely, so a link swapped underneath a repo
49
+ // left nothing on screen to notice. Name the real destination instead.
50
+ if (projectRoot && !isLexicallyWithin(FileSystemUtils.canonicalizeExistingPath(projectRoot), root)) {
51
+ process.emitWarning(`Capability '${path.basename(path.dirname(specPath))}' links outside the project; writing to ${root}`, 'OpenSpecExternalSpecWrite');
52
+ }
45
53
  const file = path.join(root, path.basename(specPath));
46
54
  FileSystemUtils.assertPathWithin(root, file);
47
55
  return { root, file };
@@ -69,7 +77,10 @@ export async function findSpecUpdates(changeDir, mainSpecsDir) {
69
77
  for (const { id, specFile } of discovered) {
70
78
  const targetFile = path.join(mainSpecsDir, ...id.split('/'), 'spec.md');
71
79
  const source = resolveTrustedSpecPath(changeSpecsDir, specFile);
72
- const target = resolveTrustedSpecPath(mainSpecsDir, targetFile);
80
+ // Main specs always live at `<project root>/openspec/specs`, so the
81
+ // project root is the grandparent - a linked capability directory may not
82
+ // leave it.
83
+ const target = resolveTrustedSpecPath(mainSpecsDir, targetFile, path.dirname(path.dirname(mainSpecsDir)));
73
84
  // Check if target exists
74
85
  let exists = false;
75
86
  try {
@@ -110,6 +121,29 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
110
121
  // Parse deltas from the change spec file
111
122
  const plan = parseDeltaSpec(changeContent);
112
123
  const specName = update.id;
124
+ // A FROM:/TO: line that never formed a pair means the RENAMED section does not
125
+ // say what the author meant. Refuse rather than apply the pairing the reader
126
+ // happened to form: with interleaved lines that pairing renames a requirement
127
+ // the delta never named, under a name written for a different one.
128
+ if (plan.unpairedRenames.length > 0) {
129
+ const first = plan.unpairedRenames[0];
130
+ const missing = first.side === 'FROM' ? 'TO' : 'FROM';
131
+ throw new Error(`${specName} validation failed - RENAMED entry on line ${first.line} has no matching ${missing}: ` +
132
+ `for header "### Requirement: ${first.name}". ` +
133
+ `Write each rename as a FROM: line followed immediately by its TO: line.`);
134
+ }
135
+ // A well-formed requirement written outside every delta section is not
136
+ // applied. Say so here as well as in validate: archive is the last point at
137
+ // which the author can still notice, and the block reads exactly like one
138
+ // that would have applied.
139
+ for (const orphan of plan.orphanedRequirements) {
140
+ const where = orphan.section
141
+ ? `under "## ${orphan.section}"`
142
+ : 'above the first "## " section';
143
+ warn(`${specName} - requirement "${orphan.name}" (line ${orphan.line}) is ${where}, ` +
144
+ `which is not a delta section, so it was not applied. ` +
145
+ `Move it under ADDED/MODIFIED/REMOVED/RENAMED Requirements.`);
146
+ }
113
147
  // Pre-validate duplicates within sections
114
148
  const addedNames = new Set();
115
149
  for (const add of plan.added) {
@@ -287,6 +321,13 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
287
321
  if (nameToBlock.has(to)) {
288
322
  throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - target already exists`);
289
323
  }
324
+ // A target that differs from another requirement only in case or interior
325
+ // whitespace would leave two copies of one requirement. The source itself
326
+ // is exempt, so a case-only rename of a requirement stays allowed.
327
+ const targetNearMiss = [...nameToBlock.keys()].find((k) => k !== from && foldRequirementName(k) === foldRequirementName(to));
328
+ if (targetNearMiss !== undefined) {
329
+ throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - "### Requirement: ${nameToBlock.get(targetNearMiss).name}" already exists and differs only in case or spacing; choose a distinct name`);
330
+ }
290
331
  const block = nameToBlock.get(from);
291
332
  const newHeader = `### Requirement: ${to}`;
292
333
  const rawLines = block.raw.split('\n');
@@ -369,6 +410,15 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
369
410
  }
370
411
  throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`);
371
412
  }
413
+ // A name that differs from an existing requirement only in case or
414
+ // interior whitespace is that requirement written again: adding it would
415
+ // leave two contradicting copies in the spec. Like the exact check above,
416
+ // this compares against the spec as it stands after the earlier operations,
417
+ // so a variant of a requirement this delta removed or renamed away is fine.
418
+ const nearMiss = [...nameToBlock.keys()].find((k) => foldRequirementName(k) === foldRequirementName(key));
419
+ if (nearMiss !== undefined) {
420
+ throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - "### Requirement: ${nameToBlock.get(nearMiss).name}" already exists and differs only in case or spacing; use MODIFIED with that exact header to change it, or choose a distinct name`);
421
+ }
372
422
  nameToBlock.set(key, add);
373
423
  addedApplied++;
374
424
  }
@@ -422,11 +472,9 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
422
472
  // glued the heading to the Purpose paragraph and the first requirement, so
423
473
  // every archive rewrote a well-formatted spec into that shape. Separate
424
474
  // non-empty slices with one blank line instead.
425
- const rebuilt = [parts.before.trimEnd(), parts.headerLine, reqBody, parts.after.trim()]
475
+ const rebuilt = collapseBlankRunsOutsideFences([parts.before.trimEnd(), parts.headerLine, reqBody, parts.after.trim()]
426
476
  .filter((s) => s !== '')
427
- .join('\n\n')
428
- .replace(/\n{3,}/g, '\n\n')
429
- .trimEnd() + '\n';
477
+ .join('\n\n')).trimEnd() + '\n';
430
478
  return {
431
479
  rebuilt,
432
480
  counts: {
@@ -473,6 +521,76 @@ function firstForeignTail(raw) {
473
521
  }
474
522
  return undefined;
475
523
  }
524
+ /**
525
+ * The column a line's content starts at, tabs expanded to a four-column stop.
526
+ * `prefix` is the text that precedes the content: a line's indentation, or a
527
+ * list item's indentation together with its marker.
528
+ */
529
+ function contentColumn(prefix) {
530
+ let column = 0;
531
+ for (const char of prefix)
532
+ column += char === '\t' ? 4 - (column % 4) : 1;
533
+ return column;
534
+ }
535
+ /**
536
+ * A line that opens a block of its own: a blockquote, a thematic break, a list
537
+ * item, a table row, or raw HTML. CommonMark lets each of these interrupt a
538
+ * paragraph, so one written flush against a bullet starts something new rather
539
+ * than continuing it - and the audit has to name it rather than let it be
540
+ * deleted with the file. Headings interrupt too and are checked separately,
541
+ * since they are refused however they are indented.
542
+ */
543
+ const INTERRUPTS_PARAGRAPH = /^ {0,3}(?:>|(?:[-*_][ \t]*){3,}$|(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)|[<|])/;
544
+ /**
545
+ * A list item, spelled the way CommonMark spells one, with its marker and the
546
+ * space after it captured so a caller can measure the item's content column.
547
+ *
548
+ * Every marker, and only those. `+` is a list marker like `-` and `*`: a spec
549
+ * bulleted that way validates like any other, and naming only two of the three
550
+ * made every one of its scenario bullets unaccounted content, so such a
551
+ * capability could not be retired at all.
552
+ *
553
+ * The nine-digit cap is the other half of "only those": CommonMark stops an
554
+ * ordered marker at nine digits, so `1234567890.` opens a paragraph, not a
555
+ * list. It changes no verdict here, because a line this pattern rejects is
556
+ * weighed by the same rules either way; it is here so the audit and
557
+ * INTERRUPTS_PARAGRAPH cannot disagree about what a marker is. A line one of
558
+ * them calls a bullet and the other does not is read as both at once, and that
559
+ * disagreement is what a shared definition removes.
560
+ *
561
+ * Content after the marker is not required, so an empty `- ` still reads as
562
+ * the bullet it is rather than falling through to the leftovers. The captured
563
+ * group is the indent plus the marker plus its trailing space, which is the
564
+ * item's content column.
565
+ */
566
+ const LIST_ITEM = /^(\s*(?:[-*+]|\d{1,9}[.)])\s+)/;
567
+ /**
568
+ * Drop up to `columns` visual columns of leading whitespace, so a line inside a
569
+ * list item is classified by what it is *within* that item. A `## Retention`
570
+ * indented under `100. Step` is a heading; measured against the file's left
571
+ * margin instead, it reads as five spaces of nothing and was absorbed as
572
+ * continuation. A tab straddling the boundary is consumed whole, which can only
573
+ * make a line look more like a construct - the direction that refuses.
574
+ */
575
+ function dropIndent(line, columns) {
576
+ let column = 0;
577
+ let index = 0;
578
+ while (index < line.length && column < columns) {
579
+ const char = line[index];
580
+ if (char === ' ')
581
+ column += 1;
582
+ else if (char === '\t')
583
+ column += 4 - (column % 4);
584
+ else
585
+ break;
586
+ index++;
587
+ }
588
+ return line.slice(index);
589
+ }
590
+ /** A heading in any form a spec can write one, ATX or raw HTML. */
591
+ function isHeadingLine(line) {
592
+ return /^ {0,3}#{1,6}(?:[ \t]|$)/.test(line) || /^\s*<h[1-6]\b/i.test(line);
593
+ }
476
594
  /**
477
595
  * The non-blank lines of a spec that are not part of what a retirement is able
478
596
  * to name: the title, the `## Purpose` section, the `## Requirements` header,
@@ -560,6 +678,18 @@ function contentTheMergeCannotName(parts) {
560
678
  // operational note below the last scenario be deleted unmentioned.
561
679
  let inScenarioBullets = false;
562
680
  let bulletsSeen = false;
681
+ // The content column of the list item the previous line opened or
682
+ // continued, or null when the last line was not part of one. A line
683
+ // indented to that column continues the item it sits under (#1780) - a
684
+ // repository that wraps its prose at a column limit writes most scenario
685
+ // bullets over two lines, and counting the second line as loose content
686
+ // made every such capability unretirable. Reset by a blank line, so an
687
+ // indented note written below the scenarios is still the author's own.
688
+ let listContentIndent = null;
689
+ // Whether the bullet's paragraph is still open, so a line that does not
690
+ // indent can still be continuing it. Closed by anything that ends a
691
+ // paragraph: a blank line, a fence, a heading, or a block of its own.
692
+ let paragraphOpen = false;
563
693
  for (let index = 0; index < lines.length; index++) {
564
694
  const line = lines[index];
565
695
  if (!line.trim()) {
@@ -567,29 +697,82 @@ function contentTheMergeCannotName(parts) {
567
697
  // between a scenario header and its first bullet is not a boundary.
568
698
  if (bulletsSeen)
569
699
  inScenarioBullets = false;
700
+ listContentIndent = null;
701
+ paragraphOpen = false;
570
702
  continue;
571
703
  }
572
704
  if (index === 0)
573
705
  continue; // the `### Requirement:` header itself
706
+ const indent = contentColumn(/^[ \t]*/.exec(line)[0]);
707
+ // Indented to the item's content column: inside the item, whatever it
708
+ // holds - a nested list, a table, an indented quote.
709
+ const insideItem = listContentIndent !== null && indent >= listContentIndent;
710
+ // Every syntax test below reads the line as the item sees it. A wide
711
+ // marker (`100. `) pushes its content past the three columns Markdown
712
+ // constructs are allowed, so measuring from the file's left margin missed
713
+ // headings and block starts written inside such an item.
714
+ const withinItem = insideItem ? dropIndent(line, listContentIndent) : line;
715
+ // Not indented at all, but continuing the bullet's own paragraph inside a
716
+ // scenario's unbroken bullet run - how a hand-wrapped bullet is usually
717
+ // written. Absorbing it widens nothing: a sibling bullet in that same
718
+ // position is already read as the scenario's own, and a lazy line is part
719
+ // of the bullet above it where a sibling is merely next to it. Outside
720
+ // the run the indent is required, so a note bulleted below the scenarios
721
+ // and its own wrapped lines stay the author's.
722
+ const lazilyContinuesBullet = paragraphOpen && inScenarioBullets && !INTERRUPTS_PARAGRAPH.test(withinItem);
723
+ // A heading is a heading wherever it sits, so neither form absorbs one:
724
+ // `firstForeignTail` names the ATX spelling and the `before` pass names
725
+ // the raw HTML, and indenting a section under a bullet must not smuggle
726
+ // it past the audit.
727
+ const continuesListItem = (insideItem || lazilyContinuesBullet) && !isHeadingLine(withinItem);
574
728
  // Fenced lines render as a code block inside the requirement, so they are
575
729
  // its own content however they are spelled - a `### Requirement:` in an
576
730
  // example is not a heading to any reader. Flagging them made a spec that
577
731
  // merely documents a command unretirable.
578
- if (mask[index])
732
+ if (mask[index]) {
733
+ // A fence that starts left of the item's content column has ended it,
734
+ // and a fence ends the paragraph wherever it sits - so what follows is
735
+ // not a lazy continuation of anything.
736
+ if (!insideItem)
737
+ listContentIndent = null;
738
+ paragraphOpen = false;
579
739
  continue;
740
+ }
741
+ // Checked ahead of the continuation branch: a setext underline turns the
742
+ // line above it into a heading, and indenting the pair under a bullet
743
+ // must not absorb them any more than an indented `#` line is absorbed.
580
744
  if (index > 1 &&
581
- /^ {0,3}(?:=+|-+)\s*$/.test(line) &&
745
+ /^ {0,3}(?:=+|-+)\s*$/.test(withinItem) &&
582
746
  lines[index - 1].trim()) {
583
747
  leftovers.push(lines[index - 1].trim());
748
+ listContentIndent = null;
749
+ paragraphOpen = false;
584
750
  continue;
585
751
  }
752
+ // A continuation of the list item above: indented to its content column
753
+ // with no blank line between. Whatever the item is, this line is part of
754
+ // it - accounted for when the item was, and already reported when it was
755
+ // not, so nothing is deleted unmentioned either way.
756
+ if (continuesListItem) {
757
+ // An indented nested list or quote is still inside the item, but it
758
+ // ended the bullet's paragraph - so a later unindented line is not
759
+ // continuing that paragraph either.
760
+ paragraphOpen = !INTERRUPTS_PARAGRAPH.test(withinItem);
761
+ continue;
762
+ }
763
+ // Any other line closes the item; a bullet opens the next one. The
764
+ // content column is the marker's own indent plus the marker itself, so a
765
+ // nested list and its own wrapped lines stay inside the item too.
766
+ const bullet = line.match(LIST_ITEM);
767
+ listContentIndent = bullet ? contentColumn(bullet[1]) : null;
768
+ paragraphOpen = bullet !== null;
586
769
  if (/^ {0,3}####\s+Scenario:/i.test(line)) {
587
770
  seenScenario = true;
588
771
  inScenarioBullets = true;
589
772
  bulletsSeen = false;
590
773
  continue;
591
774
  }
592
- if (/^\s*(?:[-*]|\d+[.)])\s/.test(line)) {
775
+ if (bullet) {
593
776
  if (inScenarioBullets) {
594
777
  bulletsSeen = true;
595
778
  continue;
@@ -609,6 +792,44 @@ function contentTheMergeCannotName(parts) {
609
792
  }
610
793
  return [...new Set(leftovers)];
611
794
  }
795
+ /**
796
+ * Collapse runs of blank lines to a single blank line - everywhere except
797
+ * inside a fenced code block.
798
+ *
799
+ * The normalisation exists to tidy the seams between the slices this function
800
+ * rejoins. Applying it to the whole document also rewrote the inside of fenced
801
+ * code blocks, so a requirement documenting a sample with two consecutive blank
802
+ * lines had that sample silently edited on every archive. That matters for
803
+ * whitespace-significant content, and every other structural pass in this
804
+ * module is already fence-aware via `buildCodeFenceMask`.
805
+ *
806
+ * Only a truly empty line counts as blank, exactly as the `/\n{3,}/` it
807
+ * replaces did: a line of spaces was never collapsed and still is not.
808
+ */
809
+ function collapseBlankRunsOutsideFences(content) {
810
+ const lines = content.split('\n');
811
+ const mask = buildCodeFenceMask(lines);
812
+ const kept = [];
813
+ let blankRun = 0;
814
+ for (let index = 0; index < lines.length; index++) {
815
+ const line = lines[index];
816
+ if (mask[index]) {
817
+ blankRun = 0;
818
+ kept.push(line);
819
+ continue;
820
+ }
821
+ if (line === '') {
822
+ blankRun++;
823
+ if (blankRun > 1)
824
+ continue;
825
+ kept.push(line);
826
+ continue;
827
+ }
828
+ blankRun = 0;
829
+ kept.push(line);
830
+ }
831
+ return kept.join('\n');
832
+ }
612
833
  function normalizeBlockRaw(raw) {
613
834
  return raw.replace(/\r\n?/g, '\n').trim();
614
835
  }
@@ -840,15 +1061,36 @@ export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
840
1061
  /** Blank out `<!-- ... -->` spans, preserving line count so indices stay aligned. */
841
1062
  function maskHtmlComments(content) {
842
1063
  const blank = (text) => text.replace(/[^\n]/g, ' ');
843
- // `--!>` is a comment terminator as well as `-->`.
844
- const masked = content.replace(/<!--[\s\S]*?--!?>/g, blank);
845
- // A comment that is never closed runs to end of file, so everything after it
846
- // is commented out too. Without this an unterminated `<!--` above a
847
- // `## Purpose` left the commented-out header looking real (#1413).
848
- const unterminated = masked.indexOf('<!--');
849
- if (unterminated === -1)
850
- return masked;
851
- return masked.slice(0, unterminated) + blank(masked.slice(unterminated));
1064
+ // Linear scan: every character is visited once. A `/<!--[\s\S]*?--!?>/g`
1065
+ // replace re-scans to end of file from every `<!--`, which is quadratic on a
1066
+ // spec dense in comment openers.
1067
+ let out = '';
1068
+ let index = 0;
1069
+ for (;;) {
1070
+ const open = content.indexOf('<!--', index);
1071
+ if (open === -1)
1072
+ return out + content.slice(index);
1073
+ out += content.slice(index, open);
1074
+ // `--!>` is a comment terminator as well as `-->`.
1075
+ let close = -1;
1076
+ for (let i = open + 4; i < content.length; i++) {
1077
+ if (content.startsWith('-->', i)) {
1078
+ close = i + 3;
1079
+ break;
1080
+ }
1081
+ if (content.startsWith('--!>', i)) {
1082
+ close = i + 4;
1083
+ break;
1084
+ }
1085
+ }
1086
+ // A comment that is never closed runs to end of file, so everything after
1087
+ // it is commented out too. Without this an unterminated `<!--` above a
1088
+ // `## Purpose` left the commented-out header looking real (#1413).
1089
+ if (close === -1)
1090
+ return out + blank(content.slice(open));
1091
+ out += blank(content.slice(open, close));
1092
+ index = close;
1093
+ }
852
1094
  }
853
1095
  /**
854
1096
  * Read the body of a `## Purpose` section, ignoring markdown that only appears
@@ -1,3 +1,29 @@
1
+ /**
2
+ * Bounds every read-only git probe. Without a timeout a wedged network mount, an
3
+ * fsmonitor daemon, or a credential/GPG prompt hangs the CLI forever; without a
4
+ * raised maxBuffer a very large dirty tree makes `git status --porcelain` throw
5
+ * ENOBUFS, which the probes below would otherwise report as "no git facts".
6
+ * A probe writes nothing, so a hard kill is safe.
7
+ */
8
+ export declare const GIT_EXEC_OPTIONS: {
9
+ readonly encoding: "utf8";
10
+ readonly timeout: 15000;
11
+ readonly killSignal: "SIGKILL";
12
+ readonly maxBuffer: number;
13
+ };
14
+ /**
15
+ * Writes get their own bounds, and deliberately NOT SIGKILL: git traps SIGTERM
16
+ * to remove `.git/index.lock` on its way out, and a signal it cannot catch
17
+ * leaves that lock behind - every later git command in the user's store then
18
+ * fails with "Another git process seems to be running", including the
19
+ * best-effort unstage below. The timeout is also far longer, because a signed
20
+ * commit can legitimately sit waiting on pinentry or a hardware key.
21
+ */
22
+ export declare const GIT_WRITE_EXEC_OPTIONS: {
23
+ readonly encoding: "utf8";
24
+ readonly timeout: 120000;
25
+ readonly maxBuffer: number;
26
+ };
1
27
  export declare function isGitRepositoryAtRoot(storeRoot: string): Promise<boolean>;
2
28
  export declare function initGitRepository(storeRoot: string): Promise<boolean>;
3
29
  /**
@@ -11,6 +37,13 @@ export declare function assertGitCommitIdentity(probeCwd: string): Promise<void>
11
37
  * Pathspecs may be files or directories.
12
38
  */
13
39
  export declare function commitStoreFiles(storeRoot: string, id: string, pathspecs: string[]): Promise<boolean>;
40
+ /**
41
+ * A probe that hit a resource limit rather than an ordinary Git answer: the
42
+ * command was killed by the timeout above, or its output exceeded maxBuffer.
43
+ * Both produce the same `null` as "not a repository", so without this the CLI
44
+ * would quietly stop reporting facts it is capable of reporting.
45
+ */
46
+ export declare function isProbeResourceFailure(error: unknown): boolean;
14
47
  export declare function gitHasCommits(storeRoot: string): Promise<boolean | null>;
15
48
  export declare function gitHasUncommittedChanges(storeRoot: string): Promise<boolean | null>;
16
49
  export declare function gitHasRemote(storeRoot: string): Promise<boolean | null>;
@@ -4,7 +4,40 @@ import * as path from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  import { StoreError } from './errors.js';
6
6
  const fs = nodeFs.promises;
7
- const execFileAsync = promisify(execFile);
7
+ const rawExecFileAsync = promisify(execFile);
8
+ /**
9
+ * Bounds every read-only git probe. Without a timeout a wedged network mount, an
10
+ * fsmonitor daemon, or a credential/GPG prompt hangs the CLI forever; without a
11
+ * raised maxBuffer a very large dirty tree makes `git status --porcelain` throw
12
+ * ENOBUFS, which the probes below would otherwise report as "no git facts".
13
+ * A probe writes nothing, so a hard kill is safe.
14
+ */
15
+ export const GIT_EXEC_OPTIONS = {
16
+ encoding: 'utf8',
17
+ timeout: 15_000,
18
+ killSignal: 'SIGKILL',
19
+ maxBuffer: 16 * 1024 * 1024,
20
+ };
21
+ /**
22
+ * Writes get their own bounds, and deliberately NOT SIGKILL: git traps SIGTERM
23
+ * to remove `.git/index.lock` on its way out, and a signal it cannot catch
24
+ * leaves that lock behind - every later git command in the user's store then
25
+ * fails with "Another git process seems to be running", including the
26
+ * best-effort unstage below. The timeout is also far longer, because a signed
27
+ * commit can legitimately sit waiting on pinentry or a hardware key.
28
+ */
29
+ export const GIT_WRITE_EXEC_OPTIONS = {
30
+ encoding: 'utf8',
31
+ timeout: 120_000,
32
+ maxBuffer: 16 * 1024 * 1024,
33
+ };
34
+ function execFileAsync(file, args, options = {}) {
35
+ return rawExecFileAsync(file, args, { ...GIT_EXEC_OPTIONS, ...options });
36
+ }
37
+ /** Same as execFileAsync, for commands that modify the user's repository. */
38
+ function execGitWrite(args, options = {}) {
39
+ return rawExecFileAsync('git', args, { ...GIT_WRITE_EXEC_OPTIONS, ...options });
40
+ }
8
41
  /**
9
42
  * Git mechanics for stores: repository detection, setup-time init and
10
43
  * commit, and the read-only facts doctor reports. Nothing here clones, pulls,
@@ -31,7 +64,7 @@ export async function initGitRepository(storeRoot) {
31
64
  return false;
32
65
  }
33
66
  try {
34
- await execFileAsync('git', ['init'], { cwd: storeRoot });
67
+ await execGitWrite(['init'], { cwd: storeRoot });
35
68
  }
36
69
  catch (error) {
37
70
  throw new StoreError(`Failed to initialize Git repository: ${error instanceof Error ? error.message : String(error)}`, 'store_git_init_failed', {
@@ -74,13 +107,13 @@ export async function commitStoreFiles(storeRoot, id, pathspecs) {
74
107
  return false;
75
108
  }
76
109
  try {
77
- await execFileAsync('git', ['add', '--', ...pathspecs], { cwd: storeRoot });
78
- await execFileAsync('git', ['commit', '-m', `Initialize OpenSpec store ${id}`, '--', ...pathspecs], { cwd: storeRoot });
110
+ await execGitWrite(['add', '--', ...pathspecs], { cwd: storeRoot });
111
+ await execGitWrite(['commit', '-m', `Initialize OpenSpec store ${id}`, '--', ...pathspecs], { cwd: storeRoot });
79
112
  }
80
113
  catch (error) {
81
114
  // Best-effort unstage so a failed commit (gpg signing, hooks) does not
82
115
  // leave setup's files in the user's index after rollback deletes them.
83
- await execFileAsync('git', ['rm', '--cached', '-r', '-f', '-q', '--', ...pathspecs], {
116
+ await execGitWrite(['rm', '--cached', '-r', '-f', '-q', '--', ...pathspecs], {
84
117
  cwd: storeRoot,
85
118
  }).catch(() => undefined);
86
119
  throw new StoreError(`Failed to create the initial store commit: ${error instanceof Error ? error.message : String(error)}`, 'store_git_commit_failed', {
@@ -90,12 +123,33 @@ export async function commitStoreFiles(storeRoot, id, pathspecs) {
90
123
  }
91
124
  return true;
92
125
  }
126
+ /**
127
+ * A probe that hit a resource limit rather than an ordinary Git answer: the
128
+ * command was killed by the timeout above, or its output exceeded maxBuffer.
129
+ * Both produce the same `null` as "not a repository", so without this the CLI
130
+ * would quietly stop reporting facts it is capable of reporting.
131
+ */
132
+ export function isProbeResourceFailure(error) {
133
+ if (typeof error !== 'object' || error === null)
134
+ return false;
135
+ const { code, killed, signal } = error;
136
+ return (code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' ||
137
+ code === 'ETIMEDOUT' ||
138
+ (killed === true && signal === 'SIGKILL'));
139
+ }
93
140
  async function gitProbe(storeRoot, args) {
94
141
  try {
95
142
  const { stdout } = await execFileAsync('git', ['-C', storeRoot, ...args]);
96
143
  return stdout;
97
144
  }
98
- catch {
145
+ catch (error) {
146
+ // "git is absent" and "this is not a repository" are expected answers and
147
+ // stay silent; a probe that timed out or overflowed its buffer is a
148
+ // degraded result the user should know about, since callers cannot tell
149
+ // the two apart from the null alone.
150
+ if (isProbeResourceFailure(error)) {
151
+ process.emitWarning(`git ${args.join(' ')} did not complete in ${storeRoot}; store Git facts are unavailable for this run.`, 'OpenSpecGitProbeWarning');
152
+ }
99
153
  return null;
100
154
  }
101
155
  }
@@ -100,7 +100,7 @@ export declare function expandUserPath(inputPath: string): string;
100
100
  * no-ops), and always honoring an explicit --init-git/--no-init-git.
101
101
  */
102
102
  export declare function resolveSetupGitEnabled(prepared: PreparedStoreSetup, initGit?: boolean): boolean;
103
- export declare function prepareStoreSetup(input: Pick<SetupStoreInput, 'id' | 'path' | 'allowInsideGitRepository' | 'remote'>): Promise<PreparedStoreSetup>;
103
+ export declare function prepareStoreSetup(input: Pick<SetupStoreInput, 'id' | 'path' | 'initGit' | 'allowInsideGitRepository' | 'remote'>): Promise<PreparedStoreSetup>;
104
104
  export declare function setupPreparedStore(prepared: PreparedStoreSetup, input?: Pick<SetupStoreInput, 'initGit'>): Promise<StoreMutationResult>;
105
105
  export declare function setupStore(input: SetupStoreInput): Promise<StoreMutationResult>;
106
106
  export declare function registerExistingStore(input: RegisterExistingStoreInput): Promise<StoreMutationResult>;
@@ -8,7 +8,7 @@ import { classifyOpenSpecDir, storePointerProblem, } from '../project-config.js'
8
8
  import { ANCHORED_OPENSPEC_DIRS, DIRECTORY_ANCHOR_FILE_NAME, OPENSPEC_ROOT_DIR, ensureOpenSpecRoot, inspectOpenSpecRoot, rollbackCreatedPaths, } from '../openspec-root.js';
9
9
  import { STORE_METADATA_DIR_NAME, getStoreMetadataDir, getStoreMetadataPath, getStoreRegistryPath, listStoreRegistryEntries, readStoreRegistryState, readOptionalStoreMetadataState, resolveGitStoreBackendConfig, validateStoreId, writeStoreMetadataState, } from './foundation.js';
10
10
  import { StoreError, makeStoreDiagnostic } from './errors.js';
11
- import { assertGitCommitIdentity, commitStoreFiles, gitDirectoryHasTrackedFiles, gitHasCommits, gitHasRemote, gitHasUncommittedChanges, gitOriginUrl, initGitRepository, isGitRepositoryAtRoot, } from './git.js';
11
+ import { GIT_EXEC_OPTIONS, assertGitCommitIdentity, commitStoreFiles, gitDirectoryHasTrackedFiles, gitHasCommits, gitHasRemote, gitHasUncommittedChanges, gitOriginUrl, initGitRepository, isGitRepositoryAtRoot, } from './git.js';
12
12
  import { getStoreRootForBackend, assertNoRegisteredStoreConflict, commitStoreRegistration, getRegisteredStore, listRegisteredStores, unregisterStoreRegistration, } from './registry.js';
13
13
  const fs = nodeFs.promises;
14
14
  const execFileAsync = promisify(execFile);
@@ -106,12 +106,7 @@ async function findContainingGitRepositoryRoot(storeRoot) {
106
106
  : null;
107
107
  };
108
108
  try {
109
- const { stdout } = await execFileAsync('git', [
110
- '-C',
111
- nearestParent,
112
- 'rev-parse',
113
- '--show-toplevel',
114
- ]);
109
+ const { stdout } = await execFileAsync('git', ['-C', nearestParent, 'rev-parse', '--show-toplevel'], GIT_EXEC_OPTIONS);
115
110
  return gitRootContainsStore(stdout.trim());
116
111
  }
117
112
  catch {
@@ -244,9 +239,10 @@ async function prepareSetupPlan(input) {
244
239
  });
245
240
  }
246
241
  // Stores may be Git-backed, but creating one inside an implementation
247
- // repo is almost always an accidental nested-repo setup.
242
+ // repo is almost always an accidental nested-repo setup. --no-init-git
243
+ // creates no repository, so there is nothing to nest.
248
244
  await assertSetupPathIsNotNestedInGitRepo(storeRoot, {
249
- allowInsideGitRepository: input.allowInsideGitRepository,
245
+ allowInsideGitRepository: input.allowInsideGitRepository || input.initGit === false,
250
246
  });
251
247
  let metadata = null;
252
248
  let backend;
@@ -594,6 +590,29 @@ async function assertSafeToDeleteStoreRoot(storeRoot, id) {
594
590
  }
595
591
  return { exists: true };
596
592
  }
593
+ /**
594
+ * Deleting a store root takes everything under it, including any other
595
+ * store registered inside it (a shared store vendored as a submodule, for
596
+ * example). `store remove <id>` never asked for that store to go.
597
+ */
598
+ function assertNoRegisteredStoreInside(storeRoot, id, others) {
599
+ const root = normalizeRegistryPathForComparison(storeRoot);
600
+ const nested = others.filter((other) => {
601
+ const relative = path.relative(root, normalizeRegistryPathForComparison(other.storeRoot));
602
+ return (relative.length > 0 &&
603
+ relative !== '..' &&
604
+ !relative.startsWith(`..${path.sep}`) &&
605
+ !path.isAbsolute(relative));
606
+ });
607
+ if (nested.length === 0)
608
+ return;
609
+ const listed = nested.map((other) => `'${other.id}' (${other.storeRoot})`).join(', ');
610
+ const unregister = nested.map((other) => `openspec store unregister ${other.id}`).join(', then ');
611
+ throw new StoreError(`Store remove refuses to delete ${storeRoot}: it contains ${nested.length === 1 ? 'another registered store' : 'other registered stores'}: ${listed}.`, 'store_remove_contains_registered_store', {
612
+ target: 'store.root',
613
+ fix: `Unregister or remove ${nested.length === 1 ? 'that store' : 'those stores'} first (${unregister}), or run "openspec store unregister ${id}" to forget '${id}' without deleting files.`,
614
+ });
615
+ }
597
616
  export async function removeStore(target) {
598
617
  const id = validateStoreId(target.id);
599
618
  const diagnostics = [];
@@ -606,9 +625,12 @@ export async function removeStore(target) {
606
625
  id,
607
626
  expectedBackend: target.backend,
608
627
  globalDataDir: target.globalDataDir,
609
- beforeCommit: async (entry) => {
628
+ beforeCommit: async (entry, remaining) => {
610
629
  const safeTarget = await assertSafeToDeleteStoreRoot(entry.storeRoot, id);
611
630
  rootMissing = !safeTarget.exists;
631
+ if (safeTarget.exists) {
632
+ assertNoRegisteredStoreInside(entry.storeRoot, id, remaining);
633
+ }
612
634
  },
613
635
  });
614
636
  if (rootMissing) {
@@ -15,7 +15,8 @@ export interface GetRegisteredStoreInput extends ResolveRegisteredStoreInput {
15
15
  export interface UnregisterStoreInput extends StorePathOptions {
16
16
  id: string;
17
17
  expectedBackend?: StoreGitBackendConfig;
18
- beforeCommit?: (entry: RegisteredStoreEntry) => Promise<void>;
18
+ /** Runs under the registry lock, with the registrations that will remain. */
19
+ beforeCommit?: (entry: RegisteredStoreEntry, remaining: RegisteredStoreEntry[]) => Promise<void>;
19
20
  }
20
21
  export type ListRegisteredStoresOptions = StorePathOptions;
21
22
  export interface RegisteredStoreEntry extends StoreRegistryEntry {
@@ -236,7 +236,11 @@ export async function unregisterStoreRegistration(input) {
236
236
  ...result.removed,
237
237
  storeRoot: getStoreRootForBackend(result.removed.backend),
238
238
  };
239
- await input.beforeCommit?.(removedEntry);
239
+ const remaining = listStoreRegistryEntries(result.next).map((entry) => ({
240
+ ...entry,
241
+ storeRoot: getStoreRootForBackend(entry.backend),
242
+ }));
243
+ await input.beforeCommit?.(removedEntry, remaining);
240
244
  removed = result.removed;
241
245
  return result.next;
242
246
  }, { globalDataDir: input.globalDataDir });