@seanmars/tospec 0.23.0 → 0.26.0-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +281 -0
- package/README.md +27 -9
- package/assets/claude-mod/tospec-dashboard/.claude-plugin/plugin.json +7 -0
- package/assets/claude-mod/tospec-dashboard/hooks/hooks.json +1 -0
- package/assets/claude-mod/tospec-dashboard/hooks/register.ts +695 -0
- package/assets/claude-mod/tospec-dashboard/tests/register.test.ts +448 -0
- package/assets/claude-mod/tospec-dashboard/types/index.d.ts +8 -0
- package/assets/dashboard/app.js +218 -1180
- package/assets/dashboard/index.html +31 -22
- package/assets/dashboard/lib/charts.js +355 -0
- package/assets/dashboard/lib/derive.js +375 -0
- package/assets/dashboard/lib/dom.js +121 -0
- package/assets/dashboard/lib/routes.js +52 -0
- package/assets/dashboard/lib/store.js +326 -0
- package/assets/dashboard/style.css +3103 -664
- package/assets/dashboard/views/alerts.js +170 -0
- package/assets/dashboard/views/change.js +234 -0
- package/assets/dashboard/views/docs.js +214 -0
- package/assets/dashboard/views/home.js +357 -0
- package/assets/dashboard/views/project.js +168 -0
- package/assets/dashboard/views/session.js +270 -0
- package/assets/dashboard/views/sidebar.js +131 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +116 -102
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +213 -100
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/dashboard-relay.d.ts +11 -0
- package/dist/commands/dashboard-relay.d.ts.map +1 -0
- package/dist/commands/dashboard-relay.js +88 -0
- package/dist/commands/dashboard-relay.js.map +1 -0
- package/dist/commands/dashboard.d.ts +69 -100
- package/dist/commands/dashboard.d.ts.map +1 -1
- package/dist/commands/dashboard.js +420 -598
- package/dist/commands/dashboard.js.map +1 -1
- package/dist/commands/decision.d.ts +38 -1
- package/dist/commands/decision.d.ts.map +1 -1
- package/dist/commands/decision.js +303 -27
- package/dist/commands/decision.js.map +1 -1
- package/dist/commands/metrics.d.ts +3 -4
- package/dist/commands/metrics.d.ts.map +1 -1
- package/dist/commands/metrics.js +3 -5
- package/dist/commands/metrics.js.map +1 -1
- package/dist/commands/shared-output.d.ts +24 -0
- package/dist/commands/shared-output.d.ts.map +1 -1
- package/dist/commands/shared-output.js +21 -2
- package/dist/commands/shared-output.js.map +1 -1
- package/dist/commands/show.d.ts.map +1 -1
- package/dist/commands/show.js +12 -9
- package/dist/commands/show.js.map +1 -1
- package/dist/commands/validate.d.ts +19 -0
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +243 -64
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/workflow/instructions.d.ts +7 -1
- package/dist/commands/workflow/instructions.d.ts.map +1 -1
- package/dist/commands/workflow/instructions.js +299 -81
- package/dist/commands/workflow/instructions.js.map +1 -1
- package/dist/commands/workflow/new-change.d.ts.map +1 -1
- package/dist/commands/workflow/new-change.js +26 -17
- package/dist/commands/workflow/new-change.js.map +1 -1
- package/dist/commands/workflow/prompt-escape.d.ts +25 -0
- package/dist/commands/workflow/prompt-escape.d.ts.map +1 -0
- package/dist/commands/workflow/prompt-escape.js +62 -0
- package/dist/commands/workflow/prompt-escape.js.map +1 -0
- package/dist/commands/workflow/schemas.d.ts.map +1 -1
- package/dist/commands/workflow/schemas.js +34 -16
- package/dist/commands/workflow/schemas.js.map +1 -1
- package/dist/commands/workflow/shared.d.ts +46 -10
- package/dist/commands/workflow/shared.d.ts.map +1 -1
- package/dist/commands/workflow/shared.js +66 -30
- package/dist/commands/workflow/shared.js.map +1 -1
- package/dist/commands/workflow/status.d.ts +5 -2
- package/dist/commands/workflow/status.d.ts.map +1 -1
- package/dist/commands/workflow/status.js +62 -28
- package/dist/commands/workflow/status.js.map +1 -1
- package/dist/core/archive.d.ts +38 -0
- package/dist/core/archive.d.ts.map +1 -1
- package/dist/core/archive.js +492 -75
- package/dist/core/archive.js.map +1 -1
- package/dist/core/artifact-graph/index.d.ts +2 -2
- package/dist/core/artifact-graph/index.d.ts.map +1 -1
- package/dist/core/artifact-graph/index.js +2 -2
- package/dist/core/artifact-graph/index.js.map +1 -1
- package/dist/core/artifact-graph/instruction-loader.d.ts +8 -0
- package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
- package/dist/core/artifact-graph/instruction-loader.js +58 -36
- package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
- package/dist/core/artifact-graph/resolver.d.ts +39 -12
- package/dist/core/artifact-graph/resolver.d.ts.map +1 -1
- package/dist/core/artifact-graph/resolver.js +82 -36
- package/dist/core/artifact-graph/resolver.js.map +1 -1
- package/dist/core/artifact-graph/schema.d.ts +24 -0
- package/dist/core/artifact-graph/schema.d.ts.map +1 -1
- package/dist/core/artifact-graph/schema.js +32 -1
- package/dist/core/artifact-graph/schema.js.map +1 -1
- package/dist/core/artifact-graph/stub-detection.d.ts +10 -2
- package/dist/core/artifact-graph/stub-detection.d.ts.map +1 -1
- package/dist/core/artifact-graph/stub-detection.js +18 -11
- package/dist/core/artifact-graph/stub-detection.js.map +1 -1
- package/dist/core/artifact-graph/types.d.ts +2 -2
- package/dist/core/artifact-graph/types.d.ts.map +1 -1
- package/dist/core/artifact-graph/types.js +10 -1
- package/dist/core/artifact-graph/types.js.map +1 -1
- package/dist/core/change-metadata/schema.d.ts.map +1 -1
- package/dist/core/change-metadata/schema.js +18 -4
- package/dist/core/change-metadata/schema.js.map +1 -1
- package/dist/core/change-presenter.d.ts.map +1 -1
- package/dist/core/change-presenter.js +13 -8
- package/dist/core/change-presenter.js.map +1 -1
- package/dist/core/change-status-policy.d.ts.map +1 -1
- package/dist/core/change-status-policy.js +3 -1
- package/dist/core/change-status-policy.js.map +1 -1
- package/dist/core/codex-metrics.d.ts +1 -1
- package/dist/core/codex-metrics.js +1 -1
- package/dist/core/config-schema.d.ts +8 -0
- package/dist/core/config-schema.d.ts.map +1 -1
- package/dist/core/config-schema.js +10 -0
- package/dist/core/config-schema.js.map +1 -1
- package/dist/core/dashboard-activity.d.ts +4 -4
- package/dist/core/dashboard-activity.js +4 -4
- package/dist/core/dashboard-data.d.ts +11 -5
- package/dist/core/dashboard-data.d.ts.map +1 -1
- package/dist/core/dashboard-data.js +54 -28
- package/dist/core/dashboard-data.js.map +1 -1
- package/dist/core/dashboard-hub.d.ts +258 -0
- package/dist/core/dashboard-hub.d.ts.map +1 -0
- package/dist/core/dashboard-hub.js +655 -0
- package/dist/core/dashboard-hub.js.map +1 -0
- package/dist/core/dashboard-routes.d.ts +57 -0
- package/dist/core/dashboard-routes.d.ts.map +1 -0
- package/dist/core/dashboard-routes.js +398 -0
- package/dist/core/dashboard-routes.js.map +1 -0
- package/dist/core/global-config.d.ts +71 -2
- package/dist/core/global-config.d.ts.map +1 -1
- package/dist/core/global-config.js +149 -4
- package/dist/core/global-config.js.map +1 -1
- package/dist/core/id.d.ts +9 -0
- package/dist/core/id.d.ts.map +1 -1
- package/dist/core/id.js +37 -0
- package/dist/core/id.js.map +1 -1
- package/dist/core/init.d.ts +9 -2
- package/dist/core/init.d.ts.map +1 -1
- package/dist/core/init.js +119 -33
- package/dist/core/init.js.map +1 -1
- package/dist/core/list.d.ts.map +1 -1
- package/dist/core/list.js +69 -45
- package/dist/core/list.js.map +1 -1
- package/dist/core/local-server.d.ts +10 -3
- package/dist/core/local-server.d.ts.map +1 -1
- package/dist/core/local-server.js +25 -5
- package/dist/core/local-server.js.map +1 -1
- package/dist/core/markdown-render.d.ts.map +1 -1
- package/dist/core/markdown-render.js +7 -2
- package/dist/core/markdown-render.js.map +1 -1
- package/dist/core/migrate.d.ts +24 -2
- package/dist/core/migrate.d.ts.map +1 -1
- package/dist/core/migrate.js +335 -59
- package/dist/core/migrate.js.map +1 -1
- package/dist/core/parsers/change-parser.d.ts +20 -1
- package/dist/core/parsers/change-parser.d.ts.map +1 -1
- package/dist/core/parsers/change-parser.js +76 -73
- package/dist/core/parsers/change-parser.js.map +1 -1
- package/dist/core/parsers/markdown-parser.d.ts +24 -0
- package/dist/core/parsers/markdown-parser.d.ts.map +1 -1
- package/dist/core/parsers/markdown-parser.js +65 -10
- package/dist/core/parsers/markdown-parser.js.map +1 -1
- package/dist/core/parsers/requirement-blocks.d.ts +127 -6
- package/dist/core/parsers/requirement-blocks.d.ts.map +1 -1
- package/dist/core/parsers/requirement-blocks.js +349 -56
- package/dist/core/parsers/requirement-blocks.js.map +1 -1
- package/dist/core/parsers/requirement-text.d.ts +25 -1
- package/dist/core/parsers/requirement-text.d.ts.map +1 -1
- package/dist/core/parsers/requirement-text.js +62 -6
- package/dist/core/parsers/requirement-text.js.map +1 -1
- package/dist/core/parsers/spec-structure.d.ts.map +1 -1
- package/dist/core/parsers/spec-structure.js +36 -24
- package/dist/core/parsers/spec-structure.js.map +1 -1
- package/dist/core/project-config.d.ts +9 -2
- package/dist/core/project-config.d.ts.map +1 -1
- package/dist/core/project-config.js +30 -7
- package/dist/core/project-config.js.map +1 -1
- package/dist/core/project-layout.d.ts +26 -0
- package/dist/core/project-layout.d.ts.map +1 -1
- package/dist/core/project-layout.js +51 -1
- package/dist/core/project-layout.js.map +1 -1
- package/dist/core/schema-names.d.ts +17 -0
- package/dist/core/schema-names.d.ts.map +1 -1
- package/dist/core/schema-names.js +24 -0
- package/dist/core/schema-names.js.map +1 -1
- package/dist/core/shared/dashboard-mod.d.ts +24 -0
- package/dist/core/shared/dashboard-mod.d.ts.map +1 -0
- package/dist/core/shared/dashboard-mod.js +44 -0
- package/dist/core/shared/dashboard-mod.js.map +1 -0
- package/dist/core/shared/index.d.ts +2 -1
- package/dist/core/shared/index.d.ts.map +1 -1
- package/dist/core/shared/index.js +2 -1
- package/dist/core/shared/index.js.map +1 -1
- package/dist/core/shared/rules-generation.d.ts +12 -0
- package/dist/core/shared/rules-generation.d.ts.map +1 -1
- package/dist/core/shared/rules-generation.js +26 -2
- package/dist/core/shared/rules-generation.js.map +1 -1
- package/dist/core/shared/skill-generation.d.ts +7 -0
- package/dist/core/shared/skill-generation.d.ts.map +1 -1
- package/dist/core/shared/skill-generation.js +19 -0
- package/dist/core/shared/skill-generation.js.map +1 -1
- package/dist/core/shared/tool-detection.d.ts +10 -1
- package/dist/core/shared/tool-detection.d.ts.map +1 -1
- package/dist/core/shared/tool-detection.js +37 -6
- package/dist/core/shared/tool-detection.js.map +1 -1
- package/dist/core/spec-presenter.d.ts.map +1 -1
- package/dist/core/spec-presenter.js +23 -1
- package/dist/core/spec-presenter.js.map +1 -1
- package/dist/core/specs-apply.d.ts +98 -1
- package/dist/core/specs-apply.d.ts.map +1 -1
- package/dist/core/specs-apply.js +426 -63
- package/dist/core/specs-apply.js.map +1 -1
- package/dist/core/templates/fragments/change-diff.d.ts +37 -0
- package/dist/core/templates/fragments/change-diff.d.ts.map +1 -0
- package/dist/core/templates/fragments/change-diff.js +37 -0
- package/dist/core/templates/fragments/change-diff.js.map +1 -0
- package/dist/core/templates/fragments/sync-fail.d.ts +8 -0
- package/dist/core/templates/fragments/sync-fail.d.ts.map +1 -0
- package/dist/core/templates/fragments/sync-fail.js +8 -0
- package/dist/core/templates/fragments/sync-fail.js.map +1 -0
- package/dist/core/templates/fragments/validate.d.ts +14 -1
- package/dist/core/templates/fragments/validate.d.ts.map +1 -1
- package/dist/core/templates/fragments/validate.js +14 -1
- package/dist/core/templates/fragments/validate.js.map +1 -1
- package/dist/core/templates/fragments/verify.d.ts +2 -2
- package/dist/core/templates/fragments/verify.d.ts.map +1 -1
- package/dist/core/templates/fragments/verify.js +8 -5
- package/dist/core/templates/fragments/verify.js.map +1 -1
- package/dist/core/templates/workflows/apply.d.ts +1 -1
- package/dist/core/templates/workflows/apply.d.ts.map +1 -1
- package/dist/core/templates/workflows/apply.js +29 -10
- package/dist/core/templates/workflows/apply.js.map +1 -1
- package/dist/core/templates/workflows/archive.d.ts.map +1 -1
- package/dist/core/templates/workflows/archive.js +27 -9
- package/dist/core/templates/workflows/archive.js.map +1 -1
- package/dist/core/templates/workflows/decision.d.ts.map +1 -1
- package/dist/core/templates/workflows/decision.js +10 -4
- package/dist/core/templates/workflows/decision.js.map +1 -1
- package/dist/core/templates/workflows/explore.d.ts +2 -2
- package/dist/core/templates/workflows/explore.d.ts.map +1 -1
- package/dist/core/templates/workflows/explore.js +7 -3
- package/dist/core/templates/workflows/explore.js.map +1 -1
- package/dist/core/templates/workflows/grill.d.ts +2 -2
- package/dist/core/templates/workflows/grill.d.ts.map +1 -1
- package/dist/core/templates/workflows/grill.js +5 -4
- package/dist/core/templates/workflows/grill.js.map +1 -1
- package/dist/core/templates/workflows/issue.d.ts.map +1 -1
- package/dist/core/templates/workflows/issue.js +9 -3
- package/dist/core/templates/workflows/issue.js.map +1 -1
- package/dist/core/templates/workflows/propose.d.ts.map +1 -1
- package/dist/core/templates/workflows/propose.js +4 -1
- package/dist/core/templates/workflows/propose.js.map +1 -1
- package/dist/core/templates/workflows/sync.d.ts.map +1 -1
- package/dist/core/templates/workflows/sync.js +25 -11
- package/dist/core/templates/workflows/sync.js.map +1 -1
- package/dist/core/templates/workflows/update.d.ts.map +1 -1
- package/dist/core/templates/workflows/update.js +17 -10
- package/dist/core/templates/workflows/update.js.map +1 -1
- package/dist/core/update.d.ts +5 -0
- package/dist/core/update.d.ts.map +1 -1
- package/dist/core/update.js +69 -29
- package/dist/core/update.js.map +1 -1
- package/dist/core/validation/constants.d.ts +7 -1
- package/dist/core/validation/constants.d.ts.map +1 -1
- package/dist/core/validation/constants.js +16 -2
- package/dist/core/validation/constants.js.map +1 -1
- package/dist/core/validation/purpose-placeholder.d.ts.map +1 -1
- package/dist/core/validation/purpose-placeholder.js +35 -11
- package/dist/core/validation/purpose-placeholder.js.map +1 -1
- package/dist/core/validation/section-validator.d.ts +7 -0
- package/dist/core/validation/section-validator.d.ts.map +1 -1
- package/dist/core/validation/section-validator.js +22 -2
- package/dist/core/validation/section-validator.js.map +1 -1
- package/dist/core/validation/sister-changes.d.ts +93 -0
- package/dist/core/validation/sister-changes.d.ts.map +1 -0
- package/dist/core/validation/sister-changes.js +180 -0
- package/dist/core/validation/sister-changes.js.map +1 -0
- package/dist/core/validation/task-markers.d.ts +9 -0
- package/dist/core/validation/task-markers.d.ts.map +1 -0
- package/dist/core/validation/task-markers.js +71 -0
- package/dist/core/validation/task-markers.js.map +1 -0
- package/dist/core/validation/types.d.ts +58 -0
- package/dist/core/validation/types.d.ts.map +1 -1
- package/dist/core/validation/types.js +22 -0
- package/dist/core/validation/types.js.map +1 -1
- package/dist/core/validation/validator.d.ts +79 -2
- package/dist/core/validation/validator.d.ts.map +1 -1
- package/dist/core/validation/validator.js +565 -84
- package/dist/core/validation/validator.js.map +1 -1
- package/dist/utils/change-metadata.d.ts +62 -5
- package/dist/utils/change-metadata.d.ts.map +1 -1
- package/dist/utils/change-metadata.js +193 -31
- package/dist/utils/change-metadata.js.map +1 -1
- package/dist/utils/change-utils.d.ts +21 -0
- package/dist/utils/change-utils.d.ts.map +1 -1
- package/dist/utils/change-utils.js +168 -53
- package/dist/utils/change-utils.js.map +1 -1
- package/dist/utils/command-line.d.ts +11 -0
- package/dist/utils/command-line.d.ts.map +1 -0
- package/dist/utils/command-line.js +43 -0
- package/dist/utils/command-line.js.map +1 -0
- package/dist/utils/display-width.d.ts +10 -0
- package/dist/utils/display-width.d.ts.map +1 -0
- package/dist/utils/display-width.js +15 -0
- package/dist/utils/display-width.js.map +1 -0
- package/dist/utils/file-system.d.ts +16 -0
- package/dist/utils/file-system.d.ts.map +1 -1
- package/dist/utils/file-system.js +49 -2
- package/dist/utils/file-system.js.map +1 -1
- package/dist/utils/frontmatter.d.ts.map +1 -1
- package/dist/utils/frontmatter.js +11 -5
- package/dist/utils/frontmatter.js.map +1 -1
- package/dist/utils/interactive.d.ts.map +1 -1
- package/dist/utils/interactive.js +3 -1
- package/dist/utils/interactive.js.map +1 -1
- package/dist/utils/item-discovery.d.ts +3 -2
- package/dist/utils/item-discovery.d.ts.map +1 -1
- package/dist/utils/item-discovery.js +34 -16
- package/dist/utils/item-discovery.js.map +1 -1
- package/dist/utils/link.d.ts +0 -1
- package/dist/utils/link.d.ts.map +1 -1
- package/dist/utils/link.js +0 -1
- package/dist/utils/link.js.map +1 -1
- package/dist/utils/match.d.ts.map +1 -1
- package/dist/utils/match.js +10 -1
- package/dist/utils/match.js.map +1 -1
- package/dist/utils/option-flags.d.ts +9 -0
- package/dist/utils/option-flags.d.ts.map +1 -0
- package/dist/utils/option-flags.js +12 -0
- package/dist/utils/option-flags.js.map +1 -0
- package/dist/utils/sentence.d.ts +9 -0
- package/dist/utils/sentence.d.ts.map +1 -0
- package/dist/utils/sentence.js +12 -0
- package/dist/utils/sentence.js.map +1 -0
- package/dist/utils/spec-files.d.ts +84 -1
- package/dist/utils/spec-files.d.ts.map +1 -1
- package/dist/utils/spec-files.js +192 -3
- package/dist/utils/spec-files.js.map +1 -1
- package/dist/utils/task-progress.d.ts.map +1 -1
- package/dist/utils/task-progress.js +32 -9
- package/dist/utils/task-progress.js.map +1 -1
- package/dist/utils/timestamp.d.ts +27 -0
- package/dist/utils/timestamp.d.ts.map +1 -1
- package/dist/utils/timestamp.js +37 -0
- package/dist/utils/timestamp.js.map +1 -1
- package/package.json +6 -2
- package/schemas/decision/templates/decision.md +2 -2
- package/schemas/issue/schema.yaml +12 -6
- package/schemas/issue/templates/task.md +1 -1
- package/schemas/sdd/schema.yaml +21 -10
- package/schemas/sdd/templates/proposal.md +1 -1
- package/assets/dashboard/chart.umd.js +0 -14
package/dist/core/migrate.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { promises as fs } from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
4
|
-
import { moveDirectory, moveFile } from './archive.js';
|
|
4
|
+
import { formatArchiveStamp, moveDirectory, moveFile } from './archive.js';
|
|
5
5
|
import { serializeConfig } from './config-prompts.js';
|
|
6
|
-
import { formatLocalDate } from '../utils/timestamp.js';
|
|
7
|
-
import { buildCodeFenceMask, normalizeDocument } from './parsers/requirement-text.js';
|
|
8
|
-
import {
|
|
6
|
+
import { CREATED_PATTERN, formatLocalDate, parseStampedName } from '../utils/timestamp.js';
|
|
7
|
+
import { buildCodeFenceMask, normalizeDocument, stripClosingHashes } from './parsers/requirement-text.js';
|
|
8
|
+
import { MarkdownParser, findSection } from './parsers/markdown-parser.js';
|
|
9
|
+
import { isDeltaSectionHeader, matchRequirementHeader, parseDeltaSpec, parseRequirementBlocks, sectionHeaderKey, } from './parsers/requirement-blocks.js';
|
|
9
10
|
import { emitSuccess } from '../commands/shared-output.js';
|
|
10
|
-
import { isMissingPathError } from '../utils/file-system.js';
|
|
11
|
+
import { isDirectoryEntry, isMissingPathError } from '../utils/file-system.js';
|
|
12
|
+
import { validateNewChangeName } from '../utils/change-utils.js';
|
|
11
13
|
/**
|
|
12
14
|
* `tospec migrate [openspec-dir]` — one-shot conversion of an OpenSpec project
|
|
13
15
|
* layout into the tospec layout, written to `<cwd>/tospec`.
|
|
@@ -17,13 +19,17 @@ import { isMissingPathError } from '../utils/file-system.js';
|
|
|
17
19
|
* metadata filename `.openspec.yaml` → `.tospec.yaml`.
|
|
18
20
|
* - archive dir names: `YYYY-MM-DD-<name>` → `YYYYMMDD_000000-<name>`
|
|
19
21
|
* (tospec splits stamp/name on the first hyphen, so the OpenSpec date
|
|
20
|
-
* prefix would parse wrong if left as-is).
|
|
22
|
+
* prefix would parse wrong if left as-is). A dir with no date prefix is
|
|
23
|
+
* stamped from its `created` date, else today, and reported.
|
|
21
24
|
* - main specs OpenSpec left in delta form (`## ADDED Requirements` at top
|
|
22
25
|
* level) or headerless (bare `### Requirement:` list) are rebuilt into the
|
|
23
26
|
* `# <name> Specification / ## Purpose / ## Requirements` shape tospec
|
|
24
27
|
* validates. MODIFIED blocks are kept (they carry the full updated text),
|
|
25
28
|
* REMOVED requirements are dropped and RENAMED ones are renamed, both
|
|
26
|
-
* through the project's own delta parser and both reported.
|
|
29
|
+
* through the project's own delta parser and both reported. Any other `##`
|
|
30
|
+
* section is kept, after the requirements, and the text above the first `##`
|
|
31
|
+
* stays above Purpose, under the source's own title when it has one. A `#`
|
|
32
|
+
* heading that ends Purpose stays below it, with the text under it.
|
|
27
33
|
*
|
|
28
34
|
* The source directory is never modified.
|
|
29
35
|
*/
|
|
@@ -31,10 +37,16 @@ const METADATA_SOURCE = '.openspec.yaml';
|
|
|
31
37
|
const METADATA_TARGET = '.tospec.yaml';
|
|
32
38
|
const TARGET_SCHEMA = 'sdd';
|
|
33
39
|
const PURPOSE_STUB = 'TBD - migrated from OpenSpec; refine this purpose statement.';
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
/**
|
|
41
|
+
* A `#` or `##` heading: what ends the `## Purpose` body, as it ends the
|
|
42
|
+
* section for MarkdownParser. A `###` subheading under Purpose is part of it.
|
|
43
|
+
*/
|
|
44
|
+
const PURPOSE_END_HEADER = /^#{1,2}\s/;
|
|
45
|
+
/**
|
|
46
|
+
* A level-1 heading: the title a source spec may carry above its sections, or
|
|
47
|
+
* the heading that ends Purpose and opens text no `##` section holds.
|
|
48
|
+
*/
|
|
49
|
+
const TITLE_HEADER = /^#\s/;
|
|
38
50
|
const ARCHIVE_DATE_PREFIX = /^(\d{4})-(\d{2})-(\d{2})-(.+)$/;
|
|
39
51
|
/** The `--json` null-shape; mirrors the success payload with `migrate` nulled. */
|
|
40
52
|
export const MIGRATE_FAILURE_PAYLOAD = { migrate: null, root: null };
|
|
@@ -55,18 +67,39 @@ export class MigrateError extends Error {
|
|
|
55
67
|
};
|
|
56
68
|
}
|
|
57
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* The archive stamp of local midnight on a `YYYY-MM-DD` day, or null when the
|
|
72
|
+
* text names no real day (`2026-13-45` would roll over into another one).
|
|
73
|
+
*
|
|
74
|
+
* A `created` written as `YYYY-MM-DDTHH:mm:ss` gives its day only: every other
|
|
75
|
+
* stamp this migration assigns is a midnight, and one change stamped with its
|
|
76
|
+
* hour would sort apart from the others archived on that day.
|
|
77
|
+
*/
|
|
78
|
+
function dayStamp(day) {
|
|
79
|
+
const match = CREATED_PATTERN.exec(day);
|
|
80
|
+
if (!match)
|
|
81
|
+
return null;
|
|
82
|
+
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
|
83
|
+
return formatLocalDate(date) === day.slice(0, 10) ? formatArchiveStamp(date) : null;
|
|
84
|
+
}
|
|
58
85
|
/**
|
|
59
86
|
* Converts an OpenSpec archive dir name (`YYYY-MM-DD-<name>`) to the tospec
|
|
60
87
|
* stamp form (`YYYYMMDD_000000-<name>`). OpenSpec only recorded the date, so the
|
|
61
|
-
* time part is zero-filled.
|
|
88
|
+
* time part is zero-filled. A name already stamped passes through.
|
|
89
|
+
*
|
|
90
|
+
* A name with no date (moved into the archive by hand) is stamped from
|
|
91
|
+
* `created`, else today, and `stamped` says so: left bare, the dashboard read
|
|
92
|
+
* `no-date-change` as stamp `no`, name `date-change`.
|
|
62
93
|
*/
|
|
63
|
-
export function convertArchiveDirName(dirName) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
94
|
+
export function convertArchiveDirName(dirName, created = formatLocalDate()) {
|
|
95
|
+
if (parseStampedName(dirName))
|
|
96
|
+
return { name: dirName, stamped: false };
|
|
97
|
+
const prefixed = dirName.match(ARCHIVE_DATE_PREFIX);
|
|
98
|
+
const prefixStamp = prefixed ? dayStamp(`${prefixed[1]}-${prefixed[2]}-${prefixed[3]}`) : null;
|
|
99
|
+
if (prefixed && prefixStamp)
|
|
100
|
+
return { name: `${prefixStamp}-${prefixed[4]}`, stamped: false };
|
|
101
|
+
const stamp = dayStamp(created) ?? dayStamp(formatLocalDate());
|
|
102
|
+
return { name: `${stamp}-${dirName}`, stamped: true };
|
|
70
103
|
}
|
|
71
104
|
/**
|
|
72
105
|
* Rebuilds a main spec that is in delta form or headerless into the shape
|
|
@@ -82,9 +115,16 @@ export function normalizeMainSpec(content, capabilityName) {
|
|
|
82
115
|
for (let i = 0; i < lines.length; i++) {
|
|
83
116
|
if (fenceMask[i])
|
|
84
117
|
continue;
|
|
85
|
-
|
|
118
|
+
// The delta parser's own test, as the rebuild below reads with it. A local
|
|
119
|
+
// regex that took only `## ADDED Requirements` copied a spec headed
|
|
120
|
+
// `## Added Requirements` unconverted when it had a Purpose, so its REMOVED
|
|
121
|
+
// entries stayed and validate then refused the spec.
|
|
122
|
+
if (isDeltaSectionHeader(lines[i]))
|
|
86
123
|
hasDelta = true;
|
|
87
|
-
|
|
124
|
+
// Same reading for the Purpose: a regex taking only `## Purpose` called a
|
|
125
|
+
// well-formed spec headed `## purpose` headerless and rebuilt it, with the
|
|
126
|
+
// TBD stub in place of its Purpose.
|
|
127
|
+
if (sectionHeaderKey(lines[i]) === 'purpose')
|
|
88
128
|
hasPurpose = true;
|
|
89
129
|
}
|
|
90
130
|
if (!hasDelta && hasPurpose) {
|
|
@@ -136,16 +176,33 @@ export function normalizeMainSpec(content, capabilityName) {
|
|
|
136
176
|
// with an empty shell.
|
|
137
177
|
return null;
|
|
138
178
|
}
|
|
139
|
-
const
|
|
179
|
+
const lead = extractLead(lines, fenceMask);
|
|
180
|
+
const purpose = extractPurpose(lines, fenceMask);
|
|
181
|
+
const { requirementsPreamble, sections } = collectUnslottedSections(lines, fenceMask);
|
|
182
|
+
// The lead stays above Purpose, where it stood. When it carries its own `#`
|
|
183
|
+
// title it replaces the generated one: writing both put two titles on one
|
|
184
|
+
// spec, and dropping the source's lost a title somebody chose.
|
|
185
|
+
const head = lead.hasTitle
|
|
186
|
+
? [lead.text]
|
|
187
|
+
: [`# ${capabilityName} Specification`, ...(lead.text ? ['', lead.text] : [])];
|
|
188
|
+
// Kept sections go after the requirements, in source order. A `##` ends the
|
|
189
|
+
// section before it, so they change neither the Purpose nor the requirements
|
|
190
|
+
// the main-spec readers see.
|
|
191
|
+
// The Purpose tail stays below Purpose, where it stood, not with the kept
|
|
192
|
+
// sections. Above `## Requirements`, every reader ends Purpose at the `#`, and
|
|
193
|
+
// `## Requirements` ends the tail.
|
|
140
194
|
const rebuilt = [
|
|
141
|
-
|
|
195
|
+
...head,
|
|
142
196
|
'',
|
|
143
197
|
'## Purpose',
|
|
144
|
-
|
|
198
|
+
purpose.text || PURPOSE_STUB,
|
|
199
|
+
...(purpose.tail ? ['', purpose.tail] : []),
|
|
145
200
|
'',
|
|
146
201
|
'## Requirements',
|
|
147
202
|
'',
|
|
203
|
+
...(requirementsPreamble ? [requirementsPreamble, ''] : []),
|
|
148
204
|
[...blocks.values()].join('\n\n'),
|
|
205
|
+
...sections.flatMap((section) => ['', section]),
|
|
149
206
|
'',
|
|
150
207
|
].join('\n');
|
|
151
208
|
return {
|
|
@@ -157,25 +214,137 @@ export function normalizeMainSpec(content, capabilityName) {
|
|
|
157
214
|
};
|
|
158
215
|
}
|
|
159
216
|
/**
|
|
160
|
-
* The text under `## Purpose`, up to the next
|
|
161
|
-
*
|
|
217
|
+
* The text under `## Purpose`, up to the next `#` or `##` heading or the first
|
|
218
|
+
* requirement header, whose block is already collected. Ending at a heading of
|
|
219
|
+
* any level dropped a `###` subheading and the text under it.
|
|
220
|
+
*
|
|
221
|
+
* `tail` is the `#` heading that ends Purpose and the text under it, up to the
|
|
222
|
+
* next `##` section or requirement header, where `collectUnslottedSections`
|
|
223
|
+
* starts or cuts a section. It sits inside the `## Purpose` section that
|
|
224
|
+
* collector skips, so nothing else keeps it.
|
|
162
225
|
*/
|
|
163
226
|
function extractPurpose(lines, fenceMask) {
|
|
164
|
-
const
|
|
165
|
-
|
|
227
|
+
const purpose = [];
|
|
228
|
+
const tail = [];
|
|
229
|
+
let state = 'before';
|
|
166
230
|
for (let i = 0; i < lines.length; i++) {
|
|
167
231
|
if (!fenceMask[i]) {
|
|
168
|
-
if (
|
|
169
|
-
|
|
232
|
+
if (state !== 'tail' && sectionHeaderKey(lines[i]) === 'purpose') {
|
|
233
|
+
state = 'purpose';
|
|
170
234
|
continue;
|
|
171
235
|
}
|
|
172
|
-
|
|
236
|
+
const isRequirement = matchRequirementHeader(lines[i]) !== null;
|
|
237
|
+
if (state === 'purpose' && TITLE_HEADER.test(lines[i]))
|
|
238
|
+
state = 'tail';
|
|
239
|
+
else if (state === 'purpose' && (PURPOSE_END_HEADER.test(lines[i]) || isRequirement))
|
|
240
|
+
break;
|
|
241
|
+
else if (state === 'tail' && (sectionHeaderKey(lines[i]) !== null || isRequirement))
|
|
173
242
|
break;
|
|
174
243
|
}
|
|
175
|
-
if (
|
|
176
|
-
|
|
244
|
+
if (state === 'purpose')
|
|
245
|
+
purpose.push(lines[i]);
|
|
246
|
+
else if (state === 'tail')
|
|
247
|
+
tail.push(lines[i]);
|
|
248
|
+
}
|
|
249
|
+
return { text: purpose.join('\n').trim(), tail: tail.join('\n').trim() };
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The text above the first `##` section, which the rebuild otherwise dropped
|
|
253
|
+
* along with the source title. Cut at the first requirement header, as a
|
|
254
|
+
* section is: a headerless spec's blocks start there. `hasTitle` says whether
|
|
255
|
+
* it holds a `#` heading, so the caller does not write a second title over it.
|
|
256
|
+
*/
|
|
257
|
+
function extractLead(lines, fenceMask) {
|
|
258
|
+
const collected = [];
|
|
259
|
+
let hasTitle = false;
|
|
260
|
+
for (let i = 0; i < lines.length; i++) {
|
|
261
|
+
if (!fenceMask[i]) {
|
|
262
|
+
if (sectionHeaderKey(lines[i]) !== null || matchRequirementHeader(lines[i]) !== null)
|
|
263
|
+
break;
|
|
264
|
+
// A `# Purpose` names a section, not the spec: taken as the title, it
|
|
265
|
+
// became the spec's H1 in place of the generated one.
|
|
266
|
+
if (TITLE_HEADER.test(lines[i]) && !isPurposeHeading(lines[i]))
|
|
267
|
+
hasTitle = true;
|
|
268
|
+
}
|
|
269
|
+
collected.push(lines[i]);
|
|
270
|
+
}
|
|
271
|
+
return { text: collected.join('\n').trim(), hasTitle };
|
|
272
|
+
}
|
|
273
|
+
/** A `#` heading titled Purpose, compared as `sectionHeaderKey` compares a `##` one. */
|
|
274
|
+
function isPurposeHeading(line) {
|
|
275
|
+
return stripClosingHashes(line.replace(TITLE_HEADER, '')).trim().toLowerCase() === 'purpose';
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Sections whose text, up to the first requirement header, goes under the
|
|
279
|
+
* rebuilt `## Requirements`: the source one, and the delta sections holding
|
|
280
|
+
* whole blocks. REMOVED and RENAMED hold entries the rebuild has applied, not
|
|
281
|
+
* text for the spec.
|
|
282
|
+
*/
|
|
283
|
+
const PREAMBLE_SECTION_KEYS = new Set([
|
|
284
|
+
'requirements',
|
|
285
|
+
'added requirements',
|
|
286
|
+
'modified requirements',
|
|
287
|
+
]);
|
|
288
|
+
/**
|
|
289
|
+
* The text of every `##` section the rebuild has no slot for (neither Purpose
|
|
290
|
+
* nor a delta section), which it otherwise dropped without a word. Each is cut
|
|
291
|
+
* at its first requirement header: every line from there to the next `#`
|
|
292
|
+
* heading belongs to a block already collected. The text a source
|
|
293
|
+
* `## Requirements`, ADDED or MODIFIED section holds before its first block is
|
|
294
|
+
* returned on its own, to go under the rebuilt header rather than into a
|
|
295
|
+
* second one.
|
|
296
|
+
*/
|
|
297
|
+
function collectUnslottedSections(lines, fenceMask) {
|
|
298
|
+
const preambles = [];
|
|
299
|
+
const sections = [];
|
|
300
|
+
// `bareTitle` marks a `#` heading. Its key is '' as a `##` titled blanks
|
|
301
|
+
// alone keys '', so the key could not tell them apart: an empty `## ` was
|
|
302
|
+
// kept as if it were a heading.
|
|
303
|
+
let current = null;
|
|
304
|
+
let cut = false;
|
|
305
|
+
const flush = () => {
|
|
306
|
+
if (current === null || current.key === 'purpose')
|
|
307
|
+
return;
|
|
308
|
+
// An empty `##` section is dropped, but a bare `#` heading is the author's
|
|
309
|
+
// text in its own right: the requirement after it is in a block already, so
|
|
310
|
+
// dropping the chunk dropped the heading without a word.
|
|
311
|
+
if (!current.bareTitle && !current.body.some((line) => line.trim() !== ''))
|
|
312
|
+
return;
|
|
313
|
+
if (PREAMBLE_SECTION_KEYS.has(current.key))
|
|
314
|
+
preambles.push(current.body.join('\n').trim());
|
|
315
|
+
else if (!isDeltaSectionHeader(current.header))
|
|
316
|
+
sections.push([current.header, ...current.body].join('\n').trimEnd());
|
|
317
|
+
};
|
|
318
|
+
for (let i = 0; i < lines.length; i++) {
|
|
319
|
+
if (!fenceMask[i]) {
|
|
320
|
+
const key = sectionHeaderKey(lines[i]);
|
|
321
|
+
if (key !== null) {
|
|
322
|
+
flush();
|
|
323
|
+
current = { header: lines[i], key, body: [], bareTitle: false };
|
|
324
|
+
cut = false;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
// A `#` heading ends the block before it, as the block parser ends it, so
|
|
328
|
+
// it and the text under it are in no block. Kept on their own, after the
|
|
329
|
+
// requirements: below a cut nothing else collects them. In a section that
|
|
330
|
+
// goes under `## Requirements` it starts its own text too, even before a
|
|
331
|
+
// block: written into the preamble, it ended the rebuilt section above
|
|
332
|
+
// every requirement, which no reader then saw.
|
|
333
|
+
const endsPreamble = current !== null && (PREAMBLE_SECTION_KEYS.has(current.key) || isDeltaSectionHeader(current.header));
|
|
334
|
+
if ((cut || endsPreamble) && TITLE_HEADER.test(lines[i])) {
|
|
335
|
+
flush();
|
|
336
|
+
current = { header: lines[i], key: '', body: [], bareTitle: true };
|
|
337
|
+
cut = false;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (matchRequirementHeader(lines[i]) !== null)
|
|
341
|
+
cut = true;
|
|
342
|
+
}
|
|
343
|
+
if (current !== null && !cut)
|
|
344
|
+
current.body.push(lines[i]);
|
|
177
345
|
}
|
|
178
|
-
|
|
346
|
+
flush();
|
|
347
|
+
return { requirementsPreamble: preambles.join('\n\n'), sections };
|
|
179
348
|
}
|
|
180
349
|
/**
|
|
181
350
|
* Retitles a requirement block. The parser guarantees the block's first line is
|
|
@@ -203,10 +372,15 @@ function convertMetadata(raw, defaults) {
|
|
|
203
372
|
return stringifyYaml(parsed);
|
|
204
373
|
}
|
|
205
374
|
/** Appends prose as a YAML literal block so its Markdown characters stay verbatim. */
|
|
206
|
-
function appendContext(config, context) {
|
|
375
|
+
export function appendContext(config, context) {
|
|
207
376
|
const prefix = config.endsWith('\n') ? config : `${config}\n`;
|
|
208
377
|
const normalized = normalizeDocument(context);
|
|
209
|
-
|
|
378
|
+
// Counted by a scan: unanchored, `/\n*$/` retried from every newline of an
|
|
379
|
+
// inner run, and a project.md with 80k blank lines in its middle took 2.5 s.
|
|
380
|
+
let end = normalized.length;
|
|
381
|
+
while (end > 0 && normalized[end - 1] === '\n')
|
|
382
|
+
end--;
|
|
383
|
+
const trailingNewlines = normalized.length - end;
|
|
210
384
|
// YAML block chomping: strip, clip, or keep the trailing newlines.
|
|
211
385
|
let chomping;
|
|
212
386
|
if (trailingNewlines === 0)
|
|
@@ -256,6 +430,47 @@ export class MigrateCommand {
|
|
|
256
430
|
const activeChanges = await listDirs(path.join(source, 'changes'), 'archive');
|
|
257
431
|
const archivedChanges = await listDirs(path.join(source, 'changes', 'archive'), 'archive');
|
|
258
432
|
const specDirs = await listDirs(path.join(source, 'specs'));
|
|
433
|
+
// Refused rather than copied: status, instructions and archive all reject a
|
|
434
|
+
// change whose name fails validateChangeName, so a migrated `Bad_Name` was
|
|
435
|
+
// listed but could not be worked on. Held to the create-time rule, since
|
|
436
|
+
// migrate writes these directories: a `nul` copy cannot be checked out on
|
|
437
|
+
// Windows. Renaming it here was weighed and
|
|
438
|
+
// dropped: two source names can map to one kebab id, and the source stays
|
|
439
|
+
// read-only, so the reader renames it there and reruns.
|
|
440
|
+
const invalidNames = activeChanges.flatMap((name) => {
|
|
441
|
+
const validation = validateNewChangeName(name);
|
|
442
|
+
return validation.valid ? [] : [`${name} (${validation.error})`];
|
|
443
|
+
});
|
|
444
|
+
if (invalidNames.length > 0) {
|
|
445
|
+
const changesDir = path.join(source, 'changes');
|
|
446
|
+
throw new MigrateError(`Invalid change names in ${changesDir}: ${invalidNames.join('; ')}`, {
|
|
447
|
+
code: 'migrate_change_name_invalid',
|
|
448
|
+
fix: `Rename each listed directory under ${changesDir} to a kebab-case name Windows also accepts (e.g. add-auth, not nul or con), then rerun tospec migrate.`,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
const archiveDir = path.join(source, 'changes', 'archive');
|
|
452
|
+
const archivePlan = await Promise.all(archivedChanges.map(async (dir) => ({
|
|
453
|
+
dir,
|
|
454
|
+
...convertArchiveDirName(dir, await readCreatedDate(path.join(archiveDir, dir))),
|
|
455
|
+
})));
|
|
456
|
+
// Refused before anything is written: `2025-03-04-x` and an undated `x`
|
|
457
|
+
// created that day both become `20250304_000000-x`, and the second copy
|
|
458
|
+
// would merge into the first. Compared without case, as a case-insensitive
|
|
459
|
+
// file system (Windows, default macOS) merges names that differ only in it.
|
|
460
|
+
const byTarget = new Map();
|
|
461
|
+
for (const { dir, name } of archivePlan) {
|
|
462
|
+
const key = name.toLowerCase();
|
|
463
|
+
byTarget.set(key, [...(byTarget.get(key) ?? []), dir]);
|
|
464
|
+
}
|
|
465
|
+
const collisions = archivePlan
|
|
466
|
+
.filter(({ name }) => byTarget.get(name.toLowerCase()).length > 1)
|
|
467
|
+
.map(({ dir, name }) => `${dir} -> ${name}`);
|
|
468
|
+
if (collisions.length > 0) {
|
|
469
|
+
throw new MigrateError(`Archive dirs in ${archiveDir} map to the same tospec name: ${collisions.join('; ')}`, {
|
|
470
|
+
code: 'migrate_archive_name_collision',
|
|
471
|
+
fix: `Rename each colliding directory under ${archiveDir} to a distinct name, then rerun tospec migrate.`,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
259
474
|
// JSON mode is non-interactive, so the caller has to say --force up front.
|
|
260
475
|
if (options?.json && !options?.force) {
|
|
261
476
|
throw new MigrateError('migrate --json is non-interactive and needs confirmation up front.', {
|
|
@@ -282,6 +497,11 @@ export class MigrateCommand {
|
|
|
282
497
|
removedRequirements: [],
|
|
283
498
|
renamedRequirements: [],
|
|
284
499
|
renamedKept: [],
|
|
500
|
+
skipped: await listUnmigratedEntries(source),
|
|
501
|
+
stampedArchives: archivePlan
|
|
502
|
+
.filter(({ stamped }) => stamped)
|
|
503
|
+
.map(({ dir, name }) => `${dir} -> ${name}`)
|
|
504
|
+
.sort(),
|
|
285
505
|
};
|
|
286
506
|
// Two-phase: build the whole tree in a sibling staging dir, then promote it.
|
|
287
507
|
// A mid-migration failure cleans staging and leaves the target untouched, so
|
|
@@ -297,9 +517,12 @@ export class MigrateCommand {
|
|
|
297
517
|
const specFile = path.join(source, 'specs', dir, 'spec.md');
|
|
298
518
|
if (!(await exists(specFile))) {
|
|
299
519
|
summary.specs.skippedEmpty++;
|
|
520
|
+
// The count alone hid that a README.md or other file beside the
|
|
521
|
+
// missing spec.md was left behind with the directory.
|
|
522
|
+
summary.skipped.push(`specs/${dir}/`);
|
|
300
523
|
continue;
|
|
301
524
|
}
|
|
302
|
-
await
|
|
525
|
+
await copyContent(path.join(source, 'specs', dir), path.join(staging, 'specs', dir));
|
|
303
526
|
const content = await fs.readFile(specFile, 'utf-8');
|
|
304
527
|
const normalized = normalizeMainSpec(content, dir);
|
|
305
528
|
if (normalized) {
|
|
@@ -314,16 +537,17 @@ export class MigrateCommand {
|
|
|
314
537
|
}
|
|
315
538
|
summary.specs.copied++;
|
|
316
539
|
}
|
|
540
|
+
summary.skipped.sort();
|
|
317
541
|
for (const dir of activeChanges) {
|
|
318
542
|
const dest = path.join(staging, 'changes', dir);
|
|
319
|
-
await
|
|
543
|
+
await copyContent(path.join(source, 'changes', dir), dest);
|
|
320
544
|
if (await this.migrateMetadata(dest))
|
|
321
545
|
summary.changes.metadataCreated++;
|
|
322
546
|
summary.changes.active++;
|
|
323
547
|
}
|
|
324
|
-
for (const dir of
|
|
325
|
-
const dest = path.join(staging, 'changes', 'archive',
|
|
326
|
-
await
|
|
548
|
+
for (const { dir, name } of archivePlan) {
|
|
549
|
+
const dest = path.join(staging, 'changes', 'archive', name);
|
|
550
|
+
await copyContent(path.join(source, 'changes', 'archive', dir), dest);
|
|
327
551
|
const created = dir.match(ARCHIVE_DATE_PREFIX);
|
|
328
552
|
const createdDate = created ? `${created[1]}-${created[2]}-${created[3]}` : undefined;
|
|
329
553
|
if (await this.migrateMetadata(dest, createdDate))
|
|
@@ -339,7 +563,6 @@ export class MigrateCommand {
|
|
|
339
563
|
if (options?.json) {
|
|
340
564
|
// A migrated project has no skills until `tospec init` runs, and the
|
|
341
565
|
// deferred ticket stubs are a deliberate gap a caller must know about.
|
|
342
|
-
// tospec/decisions/20260916_121552-migrated-changes-defer-ticket-stubs.md
|
|
343
566
|
const migratedChangeCount = summary.changes.active + summary.changes.archived;
|
|
344
567
|
emitSuccess({
|
|
345
568
|
migrate: {
|
|
@@ -365,6 +588,12 @@ export class MigrateCommand {
|
|
|
365
588
|
if (summary.removedRequirements.length > 0) {
|
|
366
589
|
console.log(` REMOVED requirements dropped during normalization: ${summary.removedRequirements.join(', ')}`);
|
|
367
590
|
}
|
|
591
|
+
if (summary.skipped.length > 0) {
|
|
592
|
+
console.log(` Warning: not migrated, copy by hand if still needed: ${summary.skipped.join(', ')}`);
|
|
593
|
+
}
|
|
594
|
+
if (summary.stampedArchives.length > 0) {
|
|
595
|
+
console.log(` Warning: undated archive dirs stamped from created (else today), not their archive date: ${summary.stampedArchives.join(', ')}`);
|
|
596
|
+
}
|
|
368
597
|
switch (summary.context) {
|
|
369
598
|
case 'carried':
|
|
370
599
|
console.log(` Context: project.md -> config.yaml context:`);
|
|
@@ -460,7 +689,12 @@ export class MigrateCommand {
|
|
|
460
689
|
await fs.rm(staging, { recursive: true, force: true });
|
|
461
690
|
}
|
|
462
691
|
}
|
|
463
|
-
/**
|
|
692
|
+
/**
|
|
693
|
+
* First non-empty line of proposal.md's Why section, else a marker. The section
|
|
694
|
+
* validate reads as Why (`requiredSections`, through `findSection`): a regex of
|
|
695
|
+
* its own took only `## Why`, so `## why`, `## Why ##` and `### Why` passed
|
|
696
|
+
* validate and lost their goal here, and a fenced example supplied one.
|
|
697
|
+
*/
|
|
464
698
|
async function extractGoal(changeDir) {
|
|
465
699
|
const fallback = 'Migrated from OpenSpec';
|
|
466
700
|
let content;
|
|
@@ -470,20 +704,9 @@ async function extractGoal(changeDir) {
|
|
|
470
704
|
catch {
|
|
471
705
|
return fallback;
|
|
472
706
|
}
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
if (/^## Why\s*$/.test(line)) {
|
|
477
|
-
inWhy = true;
|
|
478
|
-
continue;
|
|
479
|
-
}
|
|
480
|
-
if (inWhy && /^#{1,2} /.test(line))
|
|
481
|
-
break;
|
|
482
|
-
if (inWhy && line.trim().length > 0) {
|
|
483
|
-
return line.trim();
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
return fallback;
|
|
707
|
+
const why = findSection(new MarkdownParser(content).getSections(), 'Why');
|
|
708
|
+
const first = why?.content.split(/\r?\n/).find((line) => line.trim().length > 0);
|
|
709
|
+
return first?.trim() || fallback;
|
|
487
710
|
}
|
|
488
711
|
/**
|
|
489
712
|
* False only for a path that is not there. Every caller treats `false` as
|
|
@@ -501,16 +724,69 @@ async function exists(p) {
|
|
|
501
724
|
throw error;
|
|
502
725
|
}
|
|
503
726
|
}
|
|
727
|
+
/**
|
|
728
|
+
* The `created` day of a source change's `.openspec.yaml`, or undefined when
|
|
729
|
+
* there is none to read. A file that does not parse is left to
|
|
730
|
+
* `migrateMetadata`, which reports it when it converts the file.
|
|
731
|
+
*/
|
|
732
|
+
async function readCreatedDate(changeDir) {
|
|
733
|
+
try {
|
|
734
|
+
const parsed = parseYaml(await fs.readFile(path.join(changeDir, METADATA_SOURCE), 'utf-8'));
|
|
735
|
+
const created = parsed?.created;
|
|
736
|
+
return typeof created === 'string' ? created : undefined;
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
return undefined;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Copies a source change or spec dir by its content. `fs.cp` on a link copies
|
|
744
|
+
* the link, so a linked source would come out as a link in tospec/ still
|
|
745
|
+
* pointing outside it, and the migrated content would never be the project's.
|
|
746
|
+
* Links inside the dir are copied as before.
|
|
747
|
+
*/
|
|
748
|
+
async function copyContent(from, to) {
|
|
749
|
+
await fs.cp(await fs.realpath(from), to, { recursive: true });
|
|
750
|
+
}
|
|
504
751
|
/** Names of the `.md` files directly in `dir`. Unreadable dir → empty list. */
|
|
505
752
|
async function listMarkdownFiles(dir) {
|
|
506
753
|
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
507
754
|
return entries.filter((entry) => entry.isFile() && entry.name.endsWith('.md')).map((entry) => entry.name);
|
|
508
755
|
}
|
|
509
|
-
/**
|
|
756
|
+
/** Top-level source entries migrate reads; every other one is left behind. */
|
|
757
|
+
const MIGRATED_TOP_LEVEL = new Set(['config.yaml', 'project.md', 'specs', 'changes']);
|
|
758
|
+
/**
|
|
759
|
+
* The source entries no step reads: top-level items outside
|
|
760
|
+
* `MIGRATED_TOP_LEVEL`, and whatever `listDirs` drops (files, dangling links)
|
|
761
|
+
* from `specs/`, `changes/` and `changes/archive/`. Sorted, `/`-separated.
|
|
762
|
+
*/
|
|
763
|
+
async function listUnmigratedEntries(source) {
|
|
764
|
+
const skipped = [];
|
|
765
|
+
const collect = async (relative, isMigrated) => {
|
|
766
|
+
const dir = path.join(source, relative);
|
|
767
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
768
|
+
for (const entry of entries) {
|
|
769
|
+
const isDir = isDirectoryEntry(dir, entry);
|
|
770
|
+
if (!isMigrated(entry, isDir))
|
|
771
|
+
skipped.push(`${relative}${entry.name}${isDir ? '/' : ''}`);
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
await collect('', (entry) => MIGRATED_TOP_LEVEL.has(entry.name));
|
|
775
|
+
await collect('specs/', (_, isDir) => isDir);
|
|
776
|
+
// `archive` by name, as `listDirs` excludes it: it is read on its own below.
|
|
777
|
+
await collect('changes/', (entry, isDir) => isDir || entry.name === 'archive');
|
|
778
|
+
await collect('changes/archive/', (_, isDir) => isDir);
|
|
779
|
+
return skipped.sort();
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Sorted subdirectory names, linked ones included, minus `exclude`. Unreadable
|
|
783
|
+
* dir → empty list. Linked ones count because every command reading the
|
|
784
|
+
* migrated project follows them; left out, they were listed as not migrated.
|
|
785
|
+
*/
|
|
510
786
|
async function listDirs(dir, exclude) {
|
|
511
787
|
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
512
788
|
return entries
|
|
513
|
-
.filter((entry) => entry
|
|
789
|
+
.filter((entry) => isDirectoryEntry(dir, entry) && entry.name !== exclude)
|
|
514
790
|
.map((entry) => entry.name)
|
|
515
791
|
.sort();
|
|
516
792
|
}
|