@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
@@ -13,12 +13,12 @@ import { FileSystemUtils } from '../utils/file-system.js';
13
13
  import { getSkillReferenceTransformer, getTransformerForTool, transformToSkillReferences } from '../utils/command-references.js';
14
14
  import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';
15
15
  import { generateCommands, CommandAdapterRegistry, } from './command-generation/index.js';
16
- import { getToolVersionStatus, getSkillTemplates, getCommandContents, generateSkillContent, getToolsWithSkillsDir, hasGlobalSkillTarget, resolveToolSkillsDir, toolSupportsSkills, formatIdeRestart, } from './shared/index.js';
16
+ import { getToolSkillStatus, getToolVersionStatus, getSkillTemplates, getCommandContents, generateSkillContent, getToolsWithSkillsDir, hasGlobalSkillTarget, resolveToolSkillsDir, toolSupportsSkills, formatIdeRestart, } from './shared/index.js';
17
17
  import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatDeferredGlobalPromptSummary, formatCleanupSummary, formatDetectionSummary, getLegacyGlobalPromptMatches, getLegacyWorkflowIdsForTool, getToolsFromLegacyArtifacts, omitGlobalLegacyPromptFiles, omitToolLegacyArtifacts, pickGlobalLegacyPromptFiles, } from './legacy-cleanup.js';
18
18
  import { isInteractive } from '../utils/interactive.js';
19
19
  import { getGlobalConfig } from './global-config.js';
20
20
  import { getProfileWorkflows, ALL_WORKFLOWS, CORE_WORKFLOWS } from './profiles.js';
21
- import { getOnboardingCommands } from './onboarding-commands.js';
21
+ import { formatOptionalWorkflowsNote, getOnboardingCommands } from './onboarding-commands.js';
22
22
  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';
@@ -27,6 +27,14 @@ import { resolveSharedSkillWriters, writeSharedSkillTarget, } from './shared-ski
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');
30
+ /**
31
+ * Checkout artifacts that are not real content drift: a UTF-8 BOM and the CRLF
32
+ * line endings a Windows clone with `core.autocrlf` reintroduces on every
33
+ * checkout of committed generated files.
34
+ */
35
+ function normalizeGeneratedFile(content) {
36
+ return content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
37
+ }
30
38
  /**
31
39
  * Scans installed workflow artifacts (skills and managed commands) across all configured tools.
32
40
  * Returns the union of detected workflow IDs that match ALL_WORKFLOWS.
@@ -120,9 +128,11 @@ export class UpdateCommand {
120
128
  })
121
129
  .map((s) => s.toolId);
122
130
  const toolsNeedingConfigSync = getToolsNeedingProfileSync(resolvedProjectPath, desiredWorkflows, delivery, configuredTools);
131
+ const toolsWithDriftedSkills = this.findToolsWithDriftedSkills(resolvedProjectPath, configuredTools, delivery, (toolId) => legacyWorkflowOverrides[toolId] ?? desiredWorkflows);
123
132
  const toolsToUpdateSet = new Set([
124
133
  ...toolsNeedingVersionUpdate,
125
134
  ...toolsNeedingConfigSync,
135
+ ...toolsWithDriftedSkills,
126
136
  ]);
127
137
  const toolsUpToDate = toolStatuses.filter((s) => !toolsToUpdateSet.has(s.toolId));
128
138
  if (!this.force && toolsToUpdateSet.size === 0 && newlyConfiguredTools.length === 0) {
@@ -134,8 +144,7 @@ export class UpdateCommand {
134
144
  await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools);
135
145
  // Still check for new tool directories and extra workflows
136
146
  this.detectNewTools(resolvedProjectPath, configuredTools);
137
- this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows);
138
- this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows);
147
+ this.displayProfileNotes(resolvedProjectPath, configuredTools, desiredWorkflows, profile, delivery);
139
148
  this.displaySetupNotes(configuredTools);
140
149
  return;
141
150
  }
@@ -147,7 +156,7 @@ export class UpdateCommand {
147
156
  console.log('No additional refresh needed after legacy migration.');
148
157
  }
149
158
  else {
150
- this.displayUpdatePlan([...toolsToUpdateSet], statusByTool, toolsUpToDate);
159
+ this.displayUpdatePlan([...toolsToUpdateSet], statusByTool, toolsUpToDate, new Set(toolsWithDriftedSkills));
151
160
  }
152
161
  console.log();
153
162
  // 9. Determine what to generate based on delivery
@@ -316,9 +325,8 @@ export class UpdateCommand {
316
325
  await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools);
317
326
  // 13. Detect new tool directories not currently configured
318
327
  this.detectNewTools(resolvedProjectPath, configuredAndNewTools);
319
- // 14. Display note about extra workflows not in profile
320
- this.displayExtraWorkflowsNote(resolvedProjectPath, configuredAndNewTools, desiredWorkflows);
321
- this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows);
328
+ // 14. Display the profile notes
329
+ this.displayProfileNotes(resolvedProjectPath, configuredAndNewTools, desiredWorkflows, profile, delivery);
322
330
  this.displaySetupNotes(configuredAndNewTools);
323
331
  // 15. List affected tools
324
332
  if (updatedTools.length > 0) {
@@ -377,6 +385,45 @@ export class UpdateCommand {
377
385
  console.warn(`Warning: failed to sync Copilot cloud agent files: ${message}`);
378
386
  }
379
387
  }
388
+ /**
389
+ * Tools whose SKILL.md bodies no longer match what this CLI generates.
390
+ *
391
+ * Skill freshness was decided solely by the `generatedBy:` line, so a body
392
+ * edited after generation — a "helpful" PR touching `.claude/skills/**`, a
393
+ * dotfile sync, another agent — left `update` reporting the install healthy.
394
+ * Command files never had that gap: `areCommandFilesUpToDate` content-
395
+ * compares them, and the same comparison belongs on the higher-authority
396
+ * surface. A missing skill file is left to the profile-sync check, which
397
+ * already knows what a partial install means.
398
+ */
399
+ findToolsWithDriftedSkills(projectPath, toolIds, delivery, workflowsForTool) {
400
+ return toolIds.filter((toolId) => {
401
+ const tool = AI_TOOLS.find((t) => t.value === toolId);
402
+ if (!tool || !toolSupportsSkills(tool))
403
+ return false;
404
+ if (!shouldGenerateSkillsForTool(tool.value, delivery))
405
+ return false;
406
+ // A shared skills root is generated with its owner's transformer, so
407
+ // only the owner may compare it. getToolSkillStatus settles ownership.
408
+ if (!getToolSkillStatus(projectPath, tool.value).configured)
409
+ return false;
410
+ const skillsDir = resolveToolSkillsDir(projectPath, tool);
411
+ const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value), resolveCommandInvocation(tool.value));
412
+ return getSkillTemplates(workflowsForTool(toolId)).some(({ template, dirName }) => {
413
+ const skillFile = path.join(skillsDir, dirName, 'SKILL.md');
414
+ if (!fs.existsSync(skillFile))
415
+ return false;
416
+ try {
417
+ const existing = fs.readFileSync(skillFile, 'utf-8');
418
+ const generated = generateSkillContent(template, OPENSPEC_VERSION, transformer);
419
+ return normalizeGeneratedFile(existing) !== normalizeGeneratedFile(generated);
420
+ }
421
+ catch {
422
+ return true;
423
+ }
424
+ });
425
+ });
426
+ }
380
427
  /**
381
428
  * Display message when all tools are up to date.
382
429
  */
@@ -390,13 +437,18 @@ export class UpdateCommand {
390
437
  /**
391
438
  * Display the update plan showing which tools need updating.
392
439
  */
393
- displayUpdatePlan(toolsToUpdate, statusByTool, upToDate) {
440
+ displayUpdatePlan(toolsToUpdate, statusByTool, upToDate, driftedSkills = new Set()) {
394
441
  const updates = toolsToUpdate.map((toolId) => {
395
442
  const status = statusByTool.get(toolId);
396
443
  if (status?.needsUpdate) {
397
444
  const fromVersion = status.generatedByVersion ?? 'unknown';
398
445
  return `${status.toolId} (${fromVersion} → ${OPENSPEC_VERSION})`;
399
446
  }
447
+ // Say why: a user who edited a SKILL.md on purpose is owed the reason
448
+ // their edit is about to be overwritten.
449
+ if (driftedSkills.has(toolId)) {
450
+ return `${toolId} (skill files differ from the generated content)`;
451
+ }
400
452
  return `${toolId} (config sync)`;
401
453
  });
402
454
  console.log(`Updating ${toolsToUpdate.length} tool(s): ${updates.join(', ')}`);
@@ -433,6 +485,18 @@ export class UpdateCommand {
433
485
  console.log(chalk.yellow(`Detected new ${toolNoun}: ${newToolNames.join(', ')}. Run 'openspec init' to add ${pronoun}.`));
434
486
  }
435
487
  }
488
+ /**
489
+ * Prints the profile notes, in order, with one pointer at
490
+ * `openspec config profile` rather than three.
491
+ *
492
+ * Every note is evaluated: reading them as one short-circuited `||` chain
493
+ * would swallow whichever ran second.
494
+ */
495
+ displayProfileNotes(projectPath, configuredTools, desiredWorkflows, profile, delivery) {
496
+ const printedExtraNote = this.displayExtraWorkflowsNote(projectPath, configuredTools, desiredWorkflows ?? []);
497
+ const printedMissingCoreNote = this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows);
498
+ this.displayOptionalWorkflowsNote(configuredTools, desiredWorkflows, delivery, printedExtraNote || printedMissingCoreNote);
499
+ }
436
500
  /**
437
501
  * Displays a note about extra workflows installed that aren't in the current profile.
438
502
  */
@@ -442,7 +506,9 @@ export class UpdateCommand {
442
506
  const extraWorkflows = installedWorkflows.filter((w) => !profileSet.has(w));
443
507
  if (extraWorkflows.length > 0) {
444
508
  console.log(chalk.dim(`Note: ${extraWorkflows.length} extra workflows not in profile (use \`openspec config profile\` to manage)`));
509
+ return true;
445
510
  }
511
+ return false;
446
512
  }
447
513
  /**
448
514
  * Point out core workflows a custom profile is missing, so releases that
@@ -451,17 +517,48 @@ export class UpdateCommand {
451
517
  */
452
518
  displayMissingCoreWorkflowsNote(profile, workflows) {
453
519
  if (profile !== 'custom' || !workflows) {
454
- return;
520
+ return false;
455
521
  }
456
522
  const workflowSet = new Set(workflows);
457
523
  const missing = CORE_WORKFLOWS.filter((workflow) => !workflowSet.has(workflow));
458
524
  if (missing.length === 0) {
459
- return;
525
+ return false;
460
526
  }
461
527
  const label = missing.length === 1 ? 'workflow' : 'workflows';
462
528
  const pronoun = missing.length === 1 ? 'it' : 'them';
463
529
  console.log(chalk.dim(`Note: Your custom profile is missing ${missing.length} core ${label}: ${missing.join(', ')}`));
464
530
  console.log(chalk.dim(`Run \`openspec config profile\` to add ${pronoun}, or \`openspec config profile core\` to use the core set.`));
531
+ return true;
532
+ }
533
+ /**
534
+ * Fallback pointer to the workflows the profile leaves out.
535
+ *
536
+ * `update` already points at `openspec config profile` when files drift from
537
+ * the profile, and when a custom profile is missing core workflows. Neither
538
+ * fires for the default `core` profile, so the user `update` is most likely
539
+ * to be helping — the one who ran it because a command they read about never
540
+ * appeared — learns nothing (#1076). This covers that gap.
541
+ *
542
+ * Silent when another note already pointed at the same command, and when no
543
+ * configured tool can receive a workflow surface under the active delivery:
544
+ * adding workflows would write nothing there.
545
+ */
546
+ displayOptionalWorkflowsNote(configuredTools, workflows, delivery, alreadyPointedAtProfileConfig) {
547
+ if (alreadyPointedAtProfileConfig || !workflows) {
548
+ return;
549
+ }
550
+ const anyToolHasASurface = configuredTools.some((toolId) => shouldGenerateSkillsForTool(toolId, delivery) ||
551
+ shouldGenerateCommandsForTool(toolId, delivery));
552
+ if (!anyToolHasASurface) {
553
+ return;
554
+ }
555
+ const note = formatOptionalWorkflowsNote(workflows);
556
+ if (!note) {
557
+ return;
558
+ }
559
+ for (const line of note) {
560
+ console.log(chalk.dim(line));
561
+ }
465
562
  }
466
563
  /**
467
564
  * Removes skill directories for workflows when delivery changed to commands-only.
@@ -783,6 +880,7 @@ export class UpdateCommand {
783
880
  return {
784
881
  name: tool?.name || toolId,
785
882
  value: toolId,
883
+ searchAliases: tool?.searchAliases,
786
884
  configured: false,
787
885
  preSelected: true, // Pre-select all detected legacy tools
788
886
  };
@@ -0,0 +1,34 @@
1
+ export interface TaskCheckboxDocument {
2
+ path: string;
3
+ content: string;
4
+ }
5
+ export interface TaskCheckboxIssue {
6
+ path: string;
7
+ line: number;
8
+ message: string;
9
+ }
10
+ /**
11
+ * Reports tracked task files that list work as plain bullets or numbered items
12
+ * instead of checkboxes (#354).
13
+ *
14
+ * Progress counts checkboxes and nothing else, so a tasks file written as a
15
+ * bare list is worse than an empty one: `openspec list` prints "No tasks",
16
+ * `openspec status` treats the change as having no work left, and
17
+ * `openspec archive` has no incomplete task to warn about. The file looks
18
+ * finished to the tool and unfinished to the reader.
19
+ *
20
+ * Reported only when the change's *whole* tracked set has zero checkboxes.
21
+ * One nested tasks file of prose beside a real checklist is not the failure
22
+ * this catches, and a change that is mid-authoring keeps its progress the
23
+ * moment a single checkbox exists.
24
+ *
25
+ * The scan for the offending line looks at rendered content only: fenced
26
+ * blocks, HTML comments, YAML front matter and top-level indented code are
27
+ * skipped. Every one of those
28
+ * exclusions can only *silence* a warning, never drop a real task — that is the
29
+ * opposite trade from the task parser, where fence awareness would hide work
30
+ * that `archive` must still refuse, and it is why the parser stays literal
31
+ * while this scan does not.
32
+ */
33
+ export declare function findMissingTaskCheckboxIssues(documents: readonly TaskCheckboxDocument[]): TaskCheckboxIssue[];
34
+ //# sourceMappingURL=task-checkboxes.d.ts.map
@@ -0,0 +1,181 @@
1
+ import { parseTaskLines } from '../../utils/task-progress.js';
2
+ /**
3
+ * A list item that carries text but no checkbox: `- item`, `* item`, `+ item`,
4
+ * `1. item`, `1) item`. Checkbox lines match this too, so callers must rule the
5
+ * document set out on checkbox count first.
6
+ *
7
+ * Matches at any indent; the caller decides how deep is too deep. See
8
+ * `CODE_BLOCK_COLUMN`.
9
+ *
10
+ * A thematic break (`---`, `***`, `- - -`) is not a list item: the run has no
11
+ * text after it, and this pattern requires a non-space character. `* * *` is
12
+ * the one break spelled like a list of `*` items, and it is accepted as a list
13
+ * item rather than special-cased, because a file whose only list-shaped line is
14
+ * a horizontal rule still has zero tasks — the warning stays true, it just
15
+ * points at an odd line.
16
+ *
17
+ * An ordered marker runs at most nine digits, as CommonMark and the scenario
18
+ * bullet pattern in `specs-apply.ts` both require.
19
+ */
20
+ const LIST_ITEM = /^\s*(?:[-*+]|\d{1,9}[.)])\s+\S/;
21
+ /**
22
+ * The indent at which a top-level line stops being content and becomes an
23
+ * indented code block, per CommonMark.
24
+ *
25
+ * The fence logic already treats four spaces as code; the list scan did not,
26
+ * so a sample of terminal output written as ` - example output` was
27
+ * reported as an uncheckboxed task list, and under `--strict` that false
28
+ * positive failed validation on a correct file.
29
+ *
30
+ * Genuine nested lists survive the cut, because this scan reports the *first*
31
+ * list item it finds and a nested item always sits under a shallower parent.
32
+ * That parent is what gets reported, exactly as before. A list-shaped line
33
+ * four columns deep with no shallower item above it is not nested under
34
+ * anything, which is precisely what makes it code.
35
+ */
36
+ const CODE_BLOCK_COLUMN = 4;
37
+ /** Leading whitespace of a line in visual columns, a tab counting as four. */
38
+ function indentColumns(line) {
39
+ let column = 0;
40
+ for (const char of line) {
41
+ if (char === ' ')
42
+ column += 1;
43
+ else if (char === '\t')
44
+ column += CODE_BLOCK_COLUMN - (column % CODE_BLOCK_COLUMN);
45
+ else
46
+ break;
47
+ }
48
+ return column;
49
+ }
50
+ /**
51
+ * A fenced block delimiter: a run of three or more backticks or tildes, plus
52
+ * whatever follows it on the line (an info string on an opener, nothing on a
53
+ * valid closer).
54
+ *
55
+ * Indented by at most three spaces, as CommonMark requires: at four, the line is
56
+ * an indented code block rather than a fence, and treating it as an opener would
57
+ * leave the scan inside a block that never began and hide every list below it.
58
+ *
59
+ * Deliberately unanchored at the end: `.` does not match `\r`, so `(.*)$` would
60
+ * fail on every line of a CRLF file and blind the scan to fences entirely.
61
+ */
62
+ const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)/;
63
+ /**
64
+ * The YAML front-matter delimiter: exactly three dashes. A longer run is a
65
+ * thematic break, so `----` must not open a block that swallows the list under
66
+ * it until the next `---`.
67
+ */
68
+ const FRONT_MATTER = /^-{3}\s*$/;
69
+ const COMMENT_OPEN = '<!--';
70
+ const COMMENT_CLOSE = '-->';
71
+ /**
72
+ * Reports tracked task files that list work as plain bullets or numbered items
73
+ * instead of checkboxes (#354).
74
+ *
75
+ * Progress counts checkboxes and nothing else, so a tasks file written as a
76
+ * bare list is worse than an empty one: `openspec list` prints "No tasks",
77
+ * `openspec status` treats the change as having no work left, and
78
+ * `openspec archive` has no incomplete task to warn about. The file looks
79
+ * finished to the tool and unfinished to the reader.
80
+ *
81
+ * Reported only when the change's *whole* tracked set has zero checkboxes.
82
+ * One nested tasks file of prose beside a real checklist is not the failure
83
+ * this catches, and a change that is mid-authoring keeps its progress the
84
+ * moment a single checkbox exists.
85
+ *
86
+ * The scan for the offending line looks at rendered content only: fenced
87
+ * blocks, HTML comments, YAML front matter and top-level indented code are
88
+ * skipped. Every one of those
89
+ * exclusions can only *silence* a warning, never drop a real task — that is the
90
+ * opposite trade from the task parser, where fence awareness would hide work
91
+ * that `archive` must still refuse, and it is why the parser stays literal
92
+ * while this scan does not.
93
+ */
94
+ export function findMissingTaskCheckboxIssues(documents) {
95
+ if (documents.length === 0)
96
+ return [];
97
+ if (documents.some((document) => parseTaskLines(document.content).length > 0))
98
+ return [];
99
+ const issues = [];
100
+ for (const document of documents) {
101
+ const line = findFirstListItemLine(document.content);
102
+ if (line === undefined)
103
+ continue;
104
+ issues.push({
105
+ path: document.path,
106
+ line,
107
+ message: 'This change counts as 0 tasks: no line in its tracked task files is a checkbox, ' +
108
+ 'so "openspec list" and "openspec status" report no work and "openspec archive" ' +
109
+ 'has nothing to flag as incomplete. Write each task as "- [ ] 1.1 Description".',
110
+ });
111
+ }
112
+ return issues;
113
+ }
114
+ /** 1-based line of the first list item in rendered content, if any. */
115
+ function findFirstListItemLine(content) {
116
+ const lines = content.split('\n');
117
+ let openFence;
118
+ let inComment = false;
119
+ let index = skipFrontMatter(lines);
120
+ for (; index < lines.length; index++) {
121
+ const line = lines[index];
122
+ if (inComment) {
123
+ if (line.includes(COMMENT_CLOSE))
124
+ inComment = false;
125
+ continue;
126
+ }
127
+ const fence = line.match(FENCE);
128
+ if (fence) {
129
+ const marker = fence[1][0];
130
+ const length = fence[1].length;
131
+ if (openFence === undefined) {
132
+ openFence = { marker, length };
133
+ }
134
+ else if (marker === openFence.marker &&
135
+ length >= openFence.length &&
136
+ fence[2].trim() === '') {
137
+ // CommonMark: a closer matches its opener's character, runs at least as
138
+ // long, and carries no info string. A shorter or annotated run inside a
139
+ // block is content, so a ```` ``` ```` sample nested in a ```` ```` ````
140
+ // block does not end the block early and expose its bullets.
141
+ openFence = undefined;
142
+ }
143
+ continue;
144
+ }
145
+ if (openFence !== undefined)
146
+ continue;
147
+ // Only a comment that opens the line hides it. `- [ ] 1.1 do it <!-- note`
148
+ // is a task line first, and the template's own `## 1. <!-- Task Group -->`
149
+ // must not swallow the checklist that follows it.
150
+ if (line.trimStart().startsWith(COMMENT_OPEN)) {
151
+ if (!line.includes(COMMENT_CLOSE, line.indexOf(COMMENT_OPEN) + COMMENT_OPEN.length)) {
152
+ inComment = true;
153
+ }
154
+ continue;
155
+ }
156
+ if (LIST_ITEM.test(line) && indentColumns(line) < CODE_BLOCK_COLUMN)
157
+ return index + 1;
158
+ }
159
+ return undefined;
160
+ }
161
+ /**
162
+ * Index of the first line after a YAML front-matter block, or 0 when the
163
+ * document does not open with one. A list under `tags:` is metadata about the
164
+ * file, never the work it tracks, and pointing a "write checkboxes" warning at
165
+ * it would name the wrong line.
166
+ */
167
+ function skipFrontMatter(lines) {
168
+ // A UTF-8 BOM, prepended by Windows editors and PowerShell redirects, would
169
+ // otherwise hide the opener and expose a `tags:` list to the scan.
170
+ if (lines.length === 0 || !FRONT_MATTER.test(lines[0].replace(/^/, '').trimEnd())) {
171
+ return 0;
172
+ }
173
+ for (let index = 1; index < lines.length; index++) {
174
+ if (FRONT_MATTER.test(lines[index].trimEnd()))
175
+ return index + 1;
176
+ }
177
+ // An unterminated opener is a thematic break, not front matter: rewinding to
178
+ // the top keeps every list in the document visible to the scan.
179
+ return 0;
180
+ }
181
+ //# sourceMappingURL=task-checkboxes.js.map
@@ -29,7 +29,53 @@ export declare class Validator {
29
29
  mainSpecsDir?: string;
30
30
  projectRoot?: string;
31
31
  }): Promise<ValidationReport>;
32
- private collectTaskNumberingIssues;
32
+ /**
33
+ * Lints the change's task files.
34
+ *
35
+ * Two checks with deliberately different reach. Checkbox formatting is read
36
+ * from whatever the change's own schema declares as its tracked task output,
37
+ * because every schema's progress, apply and archive behavior is computed by
38
+ * counting checkboxes in exactly those files. Numbering stays scoped to the
39
+ * built-in `spec-driven` schema, whose template is the one that numbers tasks
40
+ * in the first place.
41
+ *
42
+ * The checkbox check never falls back to a bare top-level `tasks.md`: a file
43
+ * no artifact declares is not a tracked task list, and warning about its
44
+ * formatting would be a guess about a file the tool does not read.
45
+ */
46
+ private collectTaskFileIssues;
47
+ /**
48
+ * Reads task files into change-relative documents, counting the ones that
49
+ * exist but could not be read. A file that is simply absent is not counted:
50
+ * the resolver only returns files it found, so the remaining read failures
51
+ * are permissions and I/O, and a check that reasons over the whole set needs
52
+ * to know its evidence was incomplete.
53
+ */
54
+ private readTaskDocuments;
55
+ /**
56
+ * Names a task file relative to its change, POSIX-separated.
57
+ *
58
+ * Both sides are canonicalized first. `resolveArtifactOutputs` hands back real
59
+ * paths, while `changeDir` carries whatever spelling the caller resolved, and
60
+ * the two can differ without being apart: on Windows a short 8.3 alias
61
+ * (`RUNNER~1`) against its expanded form turned `tasks.md` into a
62
+ * `../../../..`-prefixed absolute path in the report, and a symlinked project
63
+ * directory does the same elsewhere. Canonicalizing recovers the real
64
+ * relationship. The Windows CI job is the regression guard — the mismatch
65
+ * cannot be staged on POSIX, where the spawned CLI's `process.cwd()` is
66
+ * already physical.
67
+ *
68
+ * A path that still escapes would be a resolver bug rather than a spelling
69
+ * difference, but the report must never leak an absolute filesystem path, so
70
+ * the file name stands in.
71
+ */
72
+ private taskDocumentPath;
73
+ /**
74
+ * True when the change resolves to the package's own `spec-driven` schema. A
75
+ * project schema that merely reuses the name is not it, so checks written
76
+ * against the built-in template never fire on someone else's task format.
77
+ */
78
+ private usesBuiltInSpecDrivenSchema;
33
79
  /**
34
80
  * Report MODIFIED requirements whose block omits a scenario the main spec
35
81
  * still carries. Uses the same comparison archive applies, so validate can
@@ -72,6 +118,12 @@ export declare class Validator {
72
118
  */
73
119
  private buildMissingShallOrMustMessage;
74
120
  private countScenarios;
121
+ /**
122
+ * Why a requirement with a visible scenario header still has no scenario:
123
+ * the header has no body, and the spec path archive validates against does
124
+ * not count it. Empty when the block has no bare scenario header.
125
+ */
126
+ private emptyScenarioHint;
75
127
  private formatSectionList;
76
128
  }
77
129
  //# sourceMappingURL=validator.d.ts.map