freeharness 0.4.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.
- package/LICENSE +21 -0
- package/NOTICE +108 -0
- package/README.md +134 -0
- package/canonical/HARVEST.yaml +858 -0
- package/canonical/PROVENANCE.yaml +740 -0
- package/canonical/agents/README.md +95 -0
- package/canonical/agents/agentic-architect.md +18 -0
- package/canonical/agents/database-reviewer.md +41 -0
- package/canonical/agents/explorer-ai-engineer-analyst.md +65 -0
- package/canonical/agents/pr-reviewer.md +96 -0
- package/canonical/agents/python-reviewer.md +55 -0
- package/canonical/agents/report-formatter.md +19 -0
- package/canonical/agents/security-reviewer.md +53 -0
- package/canonical/agents/test-writer.md +34 -0
- package/canonical/commands/fh-context.md +37 -0
- package/canonical/evals/promptfoo.redteam.yaml +108 -0
- package/canonical/hooks/cost-prices.json +14 -0
- package/canonical/hooks/freeharness-auto-memory.mjs +158 -0
- package/canonical/hooks/freeharness-config-guard.mjs +162 -0
- package/canonical/hooks/freeharness-context-monitor.mjs +198 -0
- package/canonical/hooks/freeharness-converge.mjs +227 -0
- package/canonical/hooks/freeharness-cost-tracker.mjs +1027 -0
- package/canonical/hooks/freeharness-memory-guard.mjs +57 -0
- package/canonical/hooks/freeharness-output-pruner.mjs +333 -0
- package/canonical/hooks/freeharness-precompact.mjs +323 -0
- package/canonical/hooks/freeharness-repeat-tool.mjs +208 -0
- package/canonical/hooks/freeharness-security-guidance.mjs +164 -0
- package/canonical/hooks/freeharness-session-start +583 -0
- package/canonical/hooks/hooks.auto-memory.claude-code.json +16 -0
- package/canonical/hooks/hooks.claude-code.json +17 -0
- package/canonical/hooks/hooks.config-guard.claude-code.json +16 -0
- package/canonical/hooks/hooks.context-monitor.claude-code.json +15 -0
- package/canonical/hooks/hooks.converge.claude-code.json +15 -0
- package/canonical/hooks/hooks.cost-tracker.claude-code.json +37 -0
- package/canonical/hooks/hooks.memory-guard.claude-code.json +16 -0
- package/canonical/hooks/hooks.output-pruner.claude-code.json +16 -0
- package/canonical/hooks/hooks.precompact.claude-code.json +15 -0
- package/canonical/hooks/hooks.repeat-tool.claude-code.json +25 -0
- package/canonical/hooks/hooks.security-guidance.claude-code.json +16 -0
- package/canonical/mcp/servers.json +20 -0
- package/canonical/openspec/config.base.yaml +89 -0
- package/canonical/openspec/schema.spec-driven.yaml +277 -0
- package/canonical/openspec/templates/data-contract.md +36 -0
- package/canonical/openspec/templates/design.md +84 -0
- package/canonical/openspec/templates/eval-plan.md +33 -0
- package/canonical/openspec/templates/follow-ups.md +2 -0
- package/canonical/openspec/templates/proposal.md +45 -0
- package/canonical/openspec/templates/spec.md +42 -0
- package/canonical/openspec/templates/tasks.md +38 -0
- package/canonical/profiles/local-llamacpp.LOCAL-MODELS.md +47 -0
- package/canonical/profiles/local-llamacpp.json +27 -0
- package/canonical/profiles/local-ollama.json +20 -0
- package/canonical/profiles/mcp.json +91 -0
- package/canonical/rules/CLAUDE.md +78 -0
- package/canonical/rules/project-CLAUDE.template.md +62 -0
- package/canonical/rules/routing.md +20 -0
- package/canonical/rules/safety.md +41 -0
- package/canonical/settings/settings.cache-hygiene.claude-code.json +5 -0
- package/canonical/skills/agent-evaluation/SKILL.md +102 -0
- package/canonical/skills/agent-evaluation/assets/evaluation_report_template.md +206 -0
- package/canonical/skills/agent-evaluation/evals/cases.yaml +106 -0
- package/canonical/skills/agent-evaluation/references/dataset-preparation.md +351 -0
- package/canonical/skills/agent-evaluation/references/scorers-constraints.md +165 -0
- package/canonical/skills/agent-evaluation/references/scorers.md +431 -0
- package/canonical/skills/agent-evaluation/references/setup-guide.md +252 -0
- package/canonical/skills/agent-evaluation/references/tracing-integration.md +164 -0
- package/canonical/skills/agent-evaluation/references/troubleshooting.md +514 -0
- package/canonical/skills/agent-evaluation/scripts/analyze_results.py +462 -0
- package/canonical/skills/agent-evaluation/scripts/create_dataset_template.py +382 -0
- package/canonical/skills/agent-evaluation/scripts/list_datasets.py +273 -0
- package/canonical/skills/agent-evaluation/scripts/run_evaluation_template.py +309 -0
- package/canonical/skills/agent-evaluation/scripts/setup_mlflow.py +353 -0
- package/canonical/skills/agent-evaluation/scripts/utils/__init__.py +27 -0
- package/canonical/skills/agent-evaluation/scripts/utils/env_validation.py +123 -0
- package/canonical/skills/agent-evaluation/scripts/utils/tracing_utils.py +108 -0
- package/canonical/skills/agent-evaluation/scripts/validate_agent_tracing.py +148 -0
- package/canonical/skills/agent-evaluation/scripts/validate_auth.py +240 -0
- package/canonical/skills/agent-evaluation/scripts/validate_environment.py +185 -0
- package/canonical/skills/agent-evaluation/scripts/validate_tracing_runtime.py +318 -0
- package/canonical/skills/agentic-architecture/SKILL.md +41 -0
- package/canonical/skills/agentic-architecture/evals/cases.yaml +94 -0
- package/canonical/skills/agentic-architecture/reference/context-engineering.md +75 -0
- package/canonical/skills/agentic-architecture/reference/patterns.md +76 -0
- package/canonical/skills/agentic-architecture/reference/reliability-observability.md +94 -0
- package/canonical/skills/agentic-architecture/reference/review-framework.md +59 -0
- package/canonical/skills/code-review/SKILL.md +52 -0
- package/canonical/skills/code-review/evals/cases.yaml +197 -0
- package/canonical/skills/git-workflow/SKILL.md +41 -0
- package/canonical/skills/git-workflow/evals/cases.yaml +92 -0
- package/canonical/skills/local-code/SKILL.md +24 -0
- package/canonical/skills/local-code/evals/cases.yaml +85 -0
- package/canonical/skills/local-code/scripts/query_llama.py +68 -0
- package/canonical/skills/report-formatting/SKILL.md +62 -0
- package/canonical/skills/report-formatting/evals/cases.yaml +83 -0
- package/canonical/skills/report-formatting/reference/ARCHITECTURE.md +135 -0
- package/canonical/skills/report-formatting/reference/HELPERS.md +120 -0
- package/canonical/skills/report-formatting/reference/PPTX.md +136 -0
- package/canonical/skills/report-formatting/reference/RELEASE.md +99 -0
- package/canonical/skills/report-formatting/reference/TEMPLATES.md +48 -0
- package/canonical/skills/report-formatting/reference/authoring.md +66 -0
- package/canonical/skills/report-formatting/scripts/architecture-template.md +271 -0
- package/canonical/skills/report-formatting/scripts/assets/claro-logo.png +0 -0
- package/canonical/skills/report-formatting/scripts/assets/claro-wordmark.svg +1 -0
- package/canonical/skills/report-formatting/scripts/build-architecture.js +57 -0
- package/canonical/skills/report-formatting/scripts/build-md-claro.js +224 -0
- package/canonical/skills/report-formatting/scripts/build-release.js +51 -0
- package/canonical/skills/report-formatting/scripts/check-env.js +58 -0
- package/canonical/skills/report-formatting/scripts/docmodel.js +283 -0
- package/canonical/skills/report-formatting/scripts/example-build.js +119 -0
- package/canonical/skills/report-formatting/scripts/example_build_ArchitectureReview_docx.js +195 -0
- package/canonical/skills/report-formatting/scripts/example_build_ExecutiveReport_docx.js +114 -0
- package/canonical/skills/report-formatting/scripts/package.json +17 -0
- package/canonical/skills/report-formatting/scripts/pptx/example_build_StatusReport_pptx.py +223 -0
- package/canonical/skills/report-formatting/scripts/pptx/example_build_WeeklyStatus_pptx.py +245 -0
- package/canonical/skills/report-formatting/scripts/pptx/pptx_office.py +144 -0
- package/canonical/skills/report-formatting/scripts/pptx/requirements.txt +7 -0
- package/canonical/skills/report-formatting/scripts/pptx/status_report_blocks.py +193 -0
- package/canonical/skills/report-formatting/scripts/pptx/theme_claro_pptx.py +473 -0
- package/canonical/skills/report-formatting/scripts/release-template.md +80 -0
- package/canonical/skills/report-formatting/scripts/render-mermaid.js +105 -0
- package/canonical/skills/report-formatting/scripts/theme-claro.js +403 -0
- package/canonical/skills/subagent-driven-development/SKILL.md +60 -0
- package/canonical/skills/subagent-driven-development/evals/cases.yaml +208 -0
- package/canonical/skills/subagent-driven-development/reference/brief.md +91 -0
- package/canonical/skills/subagent-driven-development/reference/handoff.md +118 -0
- package/canonical/skills/subagent-driven-development/scripts/validate-handoff.mjs +850 -0
- package/canonical/skills/systematic-debugging/SKILL.md +44 -0
- package/canonical/skills/systematic-debugging/evals/cases.yaml +90 -0
- package/canonical/skills/test-driven-development/SKILL.md +42 -0
- package/canonical/skills/test-driven-development/evals/cases.yaml +86 -0
- package/canonical/skills/test-driven-development/reference/writing-good-tests.md +52 -0
- package/canonical/skills/verification-before-completion/SKILL.md +53 -0
- package/canonical/skills/verification-before-completion/evals/cases.yaml +107 -0
- package/dist/adapters/content/language-lint.d.ts +46 -0
- package/dist/adapters/content/language-lint.js +191 -0
- package/dist/adapters/harness/claude-code.d.ts +57 -0
- package/dist/adapters/harness/claude-code.js +385 -0
- package/dist/adapters/harness/index.d.ts +3 -0
- package/dist/adapters/harness/index.js +17 -0
- package/dist/adapters/harness/opencode.d.ts +7 -0
- package/dist/adapters/harness/opencode.js +166 -0
- package/dist/adapters/harness/plugin.d.ts +22 -0
- package/dist/adapters/harness/plugin.js +257 -0
- package/dist/adapters/memory/doctor.d.ts +480 -0
- package/dist/adapters/memory/doctor.js +1217 -0
- package/dist/adapters/memory/vendored.d.ts +150 -0
- package/dist/adapters/memory/vendored.js +487 -0
- package/dist/adapters/package/node.d.ts +9 -0
- package/dist/adapters/package/node.js +20 -0
- package/dist/adapters/prompt/readline.d.ts +19 -0
- package/dist/adapters/prompt/readline.js +53 -0
- package/dist/adapters/sync/diff.d.ts +43 -0
- package/dist/adapters/sync/diff.js +380 -0
- package/dist/cli/cleanup.d.ts +86 -0
- package/dist/cli/cleanup.js +382 -0
- package/dist/cli/cost.d.ts +304 -0
- package/dist/cli/cost.js +501 -0
- package/dist/cli/doctor.d.ts +205 -0
- package/dist/cli/doctor.js +654 -0
- package/dist/cli/executor.d.ts +5 -0
- package/dist/cli/executor.js +48 -0
- package/dist/cli/plugin-validate.d.ts +37 -0
- package/dist/cli/plugin-validate.js +256 -0
- package/dist/cli/prefix.d.ts +68 -0
- package/dist/cli/prefix.js +218 -0
- package/dist/cli/profile.d.ts +170 -0
- package/dist/cli/profile.js +285 -0
- package/dist/cli/runner.d.ts +10 -0
- package/dist/cli/runner.js +27 -0
- package/dist/cli/uninstall.d.ts +65 -0
- package/dist/cli/uninstall.js +147 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +989 -0
- package/dist/infra/dogfood.d.ts +31 -0
- package/dist/infra/dogfood.js +51 -0
- package/dist/infra/fsutil.d.ts +40 -0
- package/dist/infra/fsutil.js +143 -0
- package/dist/infra/manifest.d.ts +76 -0
- package/dist/infra/manifest.js +92 -0
- package/dist/infra/paths.d.ts +19 -0
- package/dist/infra/paths.js +74 -0
- package/dist/infra/routing.d.ts +4 -0
- package/dist/infra/routing.js +7 -0
- package/node_modules/@freeharness/core/dist/context.d.ts +30 -0
- package/node_modules/@freeharness/core/dist/context.js +1 -0
- package/node_modules/@freeharness/core/dist/index.d.ts +5 -0
- package/node_modules/@freeharness/core/dist/index.js +6 -0
- package/node_modules/@freeharness/core/dist/planner.d.ts +13 -0
- package/node_modules/@freeharness/core/dist/planner.js +18 -0
- package/node_modules/@freeharness/core/dist/ports.d.ts +90 -0
- package/node_modules/@freeharness/core/dist/ports.js +1 -0
- package/node_modules/@freeharness/core/dist/registry.d.ts +41 -0
- package/node_modules/@freeharness/core/dist/registry.js +124 -0
- package/node_modules/@freeharness/core/dist/tiers.d.ts +30 -0
- package/node_modules/@freeharness/core/dist/tiers.js +99 -0
- package/node_modules/@freeharness/core/package.json +34 -0
- package/node_modules/@freeharness/sdd/PROVENANCE.yaml +175 -0
- package/node_modules/@freeharness/sdd/dist/src/bin/openspec.d.ts +2 -0
- package/node_modules/@freeharness/sdd/dist/src/bin/openspec.js +166 -0
- package/node_modules/@freeharness/sdd/dist/src/index.d.ts +14 -0
- package/node_modules/@freeharness/sdd/dist/src/index.js +11 -0
- package/node_modules/@freeharness/sdd/dist/src/init.d.ts +39 -0
- package/node_modules/@freeharness/sdd/dist/src/init.js +190 -0
- package/node_modules/@freeharness/sdd/dist/src/root-resolver.d.ts +8 -0
- package/node_modules/@freeharness/sdd/dist/src/root-resolver.js +32 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/archive.d.ts +81 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/archive.js +483 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/context.d.ts +4 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/context.js +48 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.d.ts +57 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.js +279 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/instructions.d.ts +67 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/instructions.js +460 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/list.d.ts +5 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/list.js +95 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/new-change.d.ts +6 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/new-change.js +150 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/schemas.d.ts +4 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/schemas.js +23 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/shared.d.ts +56 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/shared.js +140 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/status.d.ts +8 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/status.js +85 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/validate.d.ts +6 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/validate.js +229 -0
- package/node_modules/@freeharness/sdd/dist/src/validate-change.d.ts +40 -0
- package/node_modules/@freeharness/sdd/dist/src/validate-change.js +114 -0
- package/node_modules/@freeharness/sdd/dist/src/validate.d.ts +24 -0
- package/node_modules/@freeharness/sdd/dist/src/validate.js +72 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/specs-apply.d.ts +130 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/specs-apply.js +981 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-metadata.d.ts +46 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-metadata.js +128 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.d.ts +26 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.js +32 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/file-system.d.ts +50 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/file-system.js +394 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/global-config.d.ts +15 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/global-config.js +49 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/graph.d.ts +71 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/graph.js +164 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.d.ts +11 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.js +21 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/instruction-loader.d.ts +102 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/instruction-loader.js +230 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.d.ts +18 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.js +138 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/resolver.d.ts +82 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/resolver.js +333 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/schema.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/schema.js +110 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/state.d.ts +11 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/state.js +32 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/task-progress.d.ts +33 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/task-progress.js +149 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/types.d.ts +39 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/types.js +49 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/adapters/claude.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/adapters/claude.js +31 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/adapters/index.d.ts +10 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/adapters/index.js +10 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/adapters/opencode.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/adapters/opencode.js +26 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/generator.d.ts +28 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/generator.js +41 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/index.d.ts +23 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/index.js +25 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/invocation.d.ts +72 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/invocation.js +76 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/registry.d.ts +39 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/registry.js +53 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/types.d.ts +62 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/types.js +7 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/yaml.d.ts +35 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/yaml.js +47 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-references.d.ts +33 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-references.js +55 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/shared/allowed-tools.d.ts +11 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/shared/allowed-tools.js +11 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/shared/skill-generation.d.ts +48 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/shared/skill-generation.js +99 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/skill-templates.d.ts +19 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/skill-templates.js +18 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/types.d.ts +18 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/types.js +4 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/apply-change.d.ts +19 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/apply-change.js +207 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/archive-change.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/archive-change.js +409 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/bulk-archive-change.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/bulk-archive-change.js +673 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/continue-change.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/continue-change.js +230 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/explore.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/explore.js +550 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/feedback.d.ts +8 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/feedback.js +107 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/ff-change.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/ff-change.js +228 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/new-change.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/new-change.js +147 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/onboard.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/onboard.js +571 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/propose.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/propose.js +322 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/store-selection.d.ts +7 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/store-selection.js +7 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/sync-specs.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/sync-specs.js +518 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/update-change.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/update-change.js +176 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/verify-change.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/verify-change.js +344 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.d.ts +39 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.js +54 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/delta-resolution.d.ts +35 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/delta-resolution.js +171 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.d.ts +27 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.js +210 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/code-fence.d.ts +14 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/code-fence.js +48 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/markdown-parser.d.ts +22 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/markdown-parser.js +186 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.d.ts +65 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.js +409 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.d.ts +55 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.js +98 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/spec-structure.d.ts +8 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/spec-structure.js +77 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/purpose-placeholder.d.ts +44 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/purpose-placeholder.js +116 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/base.schema.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/base.schema.js +17 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/change.schema.d.ts +72 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/change.schema.js +30 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/index.d.ts +3 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/index.js +3 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/spec.schema.d.ts +17 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/spec.schema.js +14 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.d.ts +39 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.js +114 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.d.ts +45 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.js +105 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-numbering.d.ts +15 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-numbering.js +56 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/types.d.ts +17 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/types.js +1 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.d.ts +94 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.js +756 -0
- package/node_modules/@freeharness/sdd/package.json +57 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/LICENSE.upstream +22 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/schema.upstream.yaml +232 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/README.md +19 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-apply-change/SKILL.md +187 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-archive-change/SKILL.md +181 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-bulk-archive-change/SKILL.md +338 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-continue-change/SKILL.md +117 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-explore/SKILL.md +342 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-ff-change/SKILL.md +116 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-new-change/SKILL.md +76 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-onboard/SKILL.md +560 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-propose/SKILL.md +162 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-sync-specs/SKILL.md +261 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-update-change/SKILL.md +90 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-verify-change/SKILL.md +174 -0
- package/package.json +70 -0
- package/provenance/canonical.PROVENANCE.yaml +740 -0
- package/provenance/claude-mem.LICENSE.upstream +202 -0
- package/provenance/memory.PROVENANCE.yaml +399 -0
- package/provenance/sdd.PROVENANCE.yaml +175 -0
- package/vendor-licenses/caveman.LICENSE +27 -0
- package/vendor-licenses/deepseek-harness.LICENSE +21 -0
- package/vendor-licenses/ecc.LICENSE +21 -0
- package/vendor-licenses/pi.LICENSE +21 -0
- package/vendor-licenses/superpowers.LICENSE +21 -0
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec Application Logic
|
|
3
|
+
*
|
|
4
|
+
* Extracted from ArchiveCommand to enable standalone spec application.
|
|
5
|
+
* Applies delta specs from a change to main specs without archiving.
|
|
6
|
+
*/
|
|
7
|
+
import { promises as fs } from 'fs';
|
|
8
|
+
import { randomUUID } from 'crypto';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { extractRequirementsSection, findMissingCurrentScenarios, foldRequirementName, parseDeltaSpec, normalizeRequirementName, } from '../validation/parsers/requirement-blocks.js';
|
|
11
|
+
import { findMainSpecStructureIssues } from '../validation/parsers/spec-structure.js';
|
|
12
|
+
import { buildCodeFenceMask } from '../validation/parsers/code-fence.js';
|
|
13
|
+
import { MarkdownParser } from '../validation/parsers/markdown-parser.js';
|
|
14
|
+
import { MIN_PURPOSE_LENGTH, PURPOSE_PLACEHOLDER_PREFIX, PURPOSE_PLACEHOLDER_SUFFIX, } from '../validation/constants.js';
|
|
15
|
+
import { discoverSpecFiles } from '../validation/spec-discovery.js';
|
|
16
|
+
import { FileSystemUtils } from '../engine/file-system.js';
|
|
17
|
+
function isLexicallyWithin(allowedDirectory, targetPath) {
|
|
18
|
+
const relative = path.relative(path.resolve(allowedDirectory), path.resolve(targetPath));
|
|
19
|
+
return (relative === '' ||
|
|
20
|
+
(relative !== '..' &&
|
|
21
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
22
|
+
!path.isAbsolute(relative)));
|
|
23
|
+
}
|
|
24
|
+
function resolveTrustedSpecPath(specsRoot, specPath) {
|
|
25
|
+
if (!isLexicallyWithin(specsRoot, specPath)) {
|
|
26
|
+
throw new Error(`Path is outside the allowed directory: ${specPath}`);
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
// Preserve spec.md links that remain inside the overall specs tree.
|
|
30
|
+
FileSystemUtils.assertPathWithin(specsRoot, specPath);
|
|
31
|
+
const root = FileSystemUtils.canonicalizeExistingPath(specsRoot);
|
|
32
|
+
return {
|
|
33
|
+
root,
|
|
34
|
+
// Rebase onto the canonical root so missing targets also work when the
|
|
35
|
+
// project is reached through an OS path alias (for example /var on macOS).
|
|
36
|
+
file: path.join(root, path.relative(path.resolve(specsRoot), path.resolve(specPath))),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Direct capability directories may intentionally be monorepo symlinks.
|
|
41
|
+
// Freeze their canonical location as the trust root so later swaps are
|
|
42
|
+
// rejected while a nested spec.md link still cannot escape.
|
|
43
|
+
const root = FileSystemUtils.canonicalizeExistingPath(path.dirname(specPath));
|
|
44
|
+
const file = path.join(root, path.basename(specPath));
|
|
45
|
+
FileSystemUtils.assertPathWithin(root, file);
|
|
46
|
+
return { root, file };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function assertTrustedSpecPath(root, specPath) {
|
|
50
|
+
if (FileSystemUtils.canonicalizeExistingPath(root) !== path.resolve(root)) {
|
|
51
|
+
throw new Error(`Path is outside the allowed directory: ${specPath}`);
|
|
52
|
+
}
|
|
53
|
+
FileSystemUtils.assertPathWithin(root, specPath);
|
|
54
|
+
}
|
|
55
|
+
// -----------------------------------------------------------------------------
|
|
56
|
+
// Public API
|
|
57
|
+
// -----------------------------------------------------------------------------
|
|
58
|
+
/**
|
|
59
|
+
* Find all delta spec files that need to be applied from a change.
|
|
60
|
+
*/
|
|
61
|
+
export async function findSpecUpdates(changeDir, mainSpecsDir) {
|
|
62
|
+
const updates = [];
|
|
63
|
+
const changeSpecsDir = path.join(changeDir, 'specs');
|
|
64
|
+
// Discover delta specs recursively so nested layouts like
|
|
65
|
+
// specs/<area>/<capability>/spec.md merge into the same relative path
|
|
66
|
+
// under the main specs directory (#1353)
|
|
67
|
+
const discovered = await discoverSpecFiles(changeSpecsDir);
|
|
68
|
+
for (const { id, specFile } of discovered) {
|
|
69
|
+
const targetFile = path.join(mainSpecsDir, ...id.split('/'), 'spec.md');
|
|
70
|
+
const source = resolveTrustedSpecPath(changeSpecsDir, specFile);
|
|
71
|
+
const target = resolveTrustedSpecPath(mainSpecsDir, targetFile);
|
|
72
|
+
// Check if target exists
|
|
73
|
+
let exists = false;
|
|
74
|
+
try {
|
|
75
|
+
await fs.access(target.file);
|
|
76
|
+
exists = true;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
exists = false;
|
|
80
|
+
}
|
|
81
|
+
updates.push({
|
|
82
|
+
id,
|
|
83
|
+
sourceRoot: source.root,
|
|
84
|
+
source: source.file,
|
|
85
|
+
targetRoot: target.root,
|
|
86
|
+
target: target.file,
|
|
87
|
+
exists,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return updates;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Build an updated spec by applying delta operations.
|
|
94
|
+
* Returns the rebuilt content and counts of operations.
|
|
95
|
+
*/
|
|
96
|
+
export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
97
|
+
// Collected so silent (JSON) callers can surface them; printed live for
|
|
98
|
+
// human callers at the point they occur.
|
|
99
|
+
const warnings = [];
|
|
100
|
+
const warn = (message) => {
|
|
101
|
+
warnings.push(message);
|
|
102
|
+
if (!options.silent) {
|
|
103
|
+
// Colored console output dropped in the vendored slice (no chalk dep).
|
|
104
|
+
console.log(`Warning: ${message}`);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
// Read change spec content (delta-format expected)
|
|
108
|
+
assertTrustedSpecPath(update.sourceRoot, update.source);
|
|
109
|
+
const changeContent = await fs.readFile(update.source, 'utf-8');
|
|
110
|
+
// Parse deltas from the change spec file
|
|
111
|
+
const plan = parseDeltaSpec(changeContent);
|
|
112
|
+
const specName = update.id;
|
|
113
|
+
// Pre-validate duplicates within sections
|
|
114
|
+
const addedNames = new Set();
|
|
115
|
+
for (const add of plan.added) {
|
|
116
|
+
const name = normalizeRequirementName(add.name);
|
|
117
|
+
if (addedNames.has(name)) {
|
|
118
|
+
throw new Error(`${specName} validation failed - duplicate requirement in ADDED for header "### Requirement: ${add.name}"`);
|
|
119
|
+
}
|
|
120
|
+
addedNames.add(name);
|
|
121
|
+
}
|
|
122
|
+
const modifiedNames = new Set();
|
|
123
|
+
for (const mod of plan.modified) {
|
|
124
|
+
const name = normalizeRequirementName(mod.name);
|
|
125
|
+
if (modifiedNames.has(name)) {
|
|
126
|
+
throw new Error(`${specName} validation failed - duplicate requirement in MODIFIED for header "### Requirement: ${mod.name}"`);
|
|
127
|
+
}
|
|
128
|
+
modifiedNames.add(name);
|
|
129
|
+
}
|
|
130
|
+
const removedNamesSet = new Set();
|
|
131
|
+
for (const rem of plan.removed) {
|
|
132
|
+
const name = normalizeRequirementName(rem);
|
|
133
|
+
if (removedNamesSet.has(name)) {
|
|
134
|
+
throw new Error(`${specName} validation failed - duplicate requirement in REMOVED for header "### Requirement: ${rem}"`);
|
|
135
|
+
}
|
|
136
|
+
removedNamesSet.add(name);
|
|
137
|
+
}
|
|
138
|
+
const renamedFromSet = new Set();
|
|
139
|
+
const renamedToSet = new Set();
|
|
140
|
+
for (const { from, to } of plan.renamed) {
|
|
141
|
+
const fromNorm = normalizeRequirementName(from);
|
|
142
|
+
const toNorm = normalizeRequirementName(to);
|
|
143
|
+
if (renamedFromSet.has(fromNorm)) {
|
|
144
|
+
throw new Error(`${specName} validation failed - duplicate FROM in RENAMED for header "### Requirement: ${from}"`);
|
|
145
|
+
}
|
|
146
|
+
if (renamedToSet.has(toNorm)) {
|
|
147
|
+
throw new Error(`${specName} validation failed - duplicate TO in RENAMED for header "### Requirement: ${to}"`);
|
|
148
|
+
}
|
|
149
|
+
renamedFromSet.add(fromNorm);
|
|
150
|
+
renamedToSet.add(toNorm);
|
|
151
|
+
}
|
|
152
|
+
// Pre-validate cross-section conflicts
|
|
153
|
+
const conflicts = [];
|
|
154
|
+
for (const n of modifiedNames) {
|
|
155
|
+
if (removedNamesSet.has(n))
|
|
156
|
+
conflicts.push({ name: n, a: 'MODIFIED', b: 'REMOVED' });
|
|
157
|
+
if (addedNames.has(n))
|
|
158
|
+
conflicts.push({ name: n, a: 'MODIFIED', b: 'ADDED' });
|
|
159
|
+
}
|
|
160
|
+
for (const n of addedNames) {
|
|
161
|
+
if (removedNamesSet.has(n))
|
|
162
|
+
conflicts.push({ name: n, a: 'ADDED', b: 'REMOVED' });
|
|
163
|
+
}
|
|
164
|
+
// Renamed interplay: MODIFIED must reference the NEW header, not FROM
|
|
165
|
+
for (const { from, to } of plan.renamed) {
|
|
166
|
+
const fromNorm = normalizeRequirementName(from);
|
|
167
|
+
const toNorm = normalizeRequirementName(to);
|
|
168
|
+
// A REMOVED naming the FROM side contradicts the rename. This used to
|
|
169
|
+
// fail incidentally at apply time (the rename consumed the old header,
|
|
170
|
+
// so REMOVED hit "not found"); now that a missing REMOVED target is a
|
|
171
|
+
// no-op, the conflict must be rejected explicitly. Compared folded, so
|
|
172
|
+
// a case/whitespace variant cannot slip past the guard and degrade
|
|
173
|
+
// into a warned no-op.
|
|
174
|
+
const removedFoldMatch = [...removedNamesSet].find((r) => foldRequirementName(r) === foldRequirementName(fromNorm));
|
|
175
|
+
if (removedFoldMatch !== undefined) {
|
|
176
|
+
throw new Error(`${specName} validation failed - requirement present in multiple sections (RENAMED and REMOVED) for header "### Requirement: ${from}"` +
|
|
177
|
+
(removedFoldMatch === fromNorm ? '' : ` (REMOVED spells it "${removedFoldMatch}")`));
|
|
178
|
+
}
|
|
179
|
+
if (modifiedNames.has(fromNorm)) {
|
|
180
|
+
throw new Error(`${specName} validation failed - when a rename exists, MODIFIED must reference the NEW header "### Requirement: ${to}"`);
|
|
181
|
+
}
|
|
182
|
+
// Detect ADDED colliding with a RENAMED TO
|
|
183
|
+
if (addedNames.has(toNorm)) {
|
|
184
|
+
throw new Error(`${specName} validation failed - RENAMED TO header collides with ADDED for "### Requirement: ${to}"`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (conflicts.length > 0) {
|
|
188
|
+
const c = conflicts[0];
|
|
189
|
+
throw new Error(`${specName} validation failed - requirement present in multiple sections (${c.a} and ${c.b}) for header "### Requirement: ${c.name}"`);
|
|
190
|
+
}
|
|
191
|
+
const hasAnyDelta = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0;
|
|
192
|
+
if (!hasAnyDelta) {
|
|
193
|
+
throw new Error(`Delta parsing found no operations for ${update.id}. ` +
|
|
194
|
+
`Provide ADDED/MODIFIED/REMOVED/RENAMED sections in change spec.`);
|
|
195
|
+
}
|
|
196
|
+
// Load or create base target content
|
|
197
|
+
const deltaPurpose = extractPurposeSection(changeContent);
|
|
198
|
+
let targetContent;
|
|
199
|
+
let isNewSpec = false;
|
|
200
|
+
assertTrustedSpecPath(update.targetRoot, update.target);
|
|
201
|
+
try {
|
|
202
|
+
targetContent = await fs.readFile(update.target, 'utf-8');
|
|
203
|
+
// A delta Purpose only seeds a spec that does not exist yet. Say so rather
|
|
204
|
+
// than dropping it silently - the specs instruction tells authors to write
|
|
205
|
+
// one for new capabilities, and the delta file looks identical either way.
|
|
206
|
+
// Only when the spec really does have a different Purpose: claiming it
|
|
207
|
+
// "already has one" would be false when it has none, and saying anything at
|
|
208
|
+
// all is noise when the two bodies match.
|
|
209
|
+
if (deltaPurpose) {
|
|
210
|
+
const existingPurpose = extractPurposeSection(targetContent);
|
|
211
|
+
if (existingPurpose && existingPurpose !== deltaPurpose) {
|
|
212
|
+
warn(`${specName} - delta Purpose ignored; ${specName} already has one. ` +
|
|
213
|
+
`Edit ${update.target} directly to change it.`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
// An unreadable target is not a new spec. Preserve the filesystem error
|
|
219
|
+
// for callers, rather than synthesizing a baseline or a missing-target finding.
|
|
220
|
+
const code = error?.code;
|
|
221
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR')
|
|
222
|
+
throw error;
|
|
223
|
+
// Target spec does not exist; MODIFIED and RENAMED are not allowed for new specs
|
|
224
|
+
// REMOVED will be ignored with a warning since there's nothing to remove
|
|
225
|
+
// With skipUnresolved every MODIFIED/RENAMED misses the empty skeleton below
|
|
226
|
+
// and is skipped there, one warning each.
|
|
227
|
+
if ((plan.modified.length > 0 || plan.renamed.length > 0) && !options.skipUnresolved) {
|
|
228
|
+
throw new Error(`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.`);
|
|
229
|
+
}
|
|
230
|
+
// Warn about REMOVED requirements being ignored for new specs
|
|
231
|
+
if (plan.removed.length > 0) {
|
|
232
|
+
warn(`${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).`);
|
|
233
|
+
}
|
|
234
|
+
isNewSpec = true;
|
|
235
|
+
targetContent = buildSpecSkeleton(specName, changeName, deltaPurpose);
|
|
236
|
+
const overview = deltaPurpose ? readableOverview(targetContent, specName) : null;
|
|
237
|
+
if (deltaPurpose && !overview) {
|
|
238
|
+
// Keep the placeholder rather than turning this into a failure: these
|
|
239
|
+
// deltas archived cleanly before the Purpose carry-over existed.
|
|
240
|
+
targetContent = buildSpecSkeleton(specName, changeName);
|
|
241
|
+
warn(`${specName} - delta Purpose ignored (it would leave the new spec unreadable); wrote the placeholder Purpose instead.`);
|
|
242
|
+
}
|
|
243
|
+
else if (overview && overview.length < MIN_PURPOSE_LENGTH) {
|
|
244
|
+
// The placeholder always cleared this threshold, so a carried Purpose is
|
|
245
|
+
// the first way archive can leave a spec that `validate --strict` fails.
|
|
246
|
+
// Measured on the parsed overview, which is what the validator reads.
|
|
247
|
+
warn(`${specName} - carried Purpose is under ${MIN_PURPOSE_LENGTH} characters; ` +
|
|
248
|
+
`openspec validate --strict reports it as too brief.`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const structureIssues = findMainSpecStructureIssues(targetContent);
|
|
252
|
+
if (structureIssues.length > 0) {
|
|
253
|
+
const details = structureIssues
|
|
254
|
+
.map(issue => `line ${issue.line}: ${issue.message}`)
|
|
255
|
+
.join('\n');
|
|
256
|
+
throw new Error(`${specName}: target spec is structurally invalid and cannot be updated until fixed:\n${details}`);
|
|
257
|
+
}
|
|
258
|
+
// Extract requirements section and build name->block map
|
|
259
|
+
const parts = extractRequirementsSection(targetContent);
|
|
260
|
+
const nameToBlock = new Map();
|
|
261
|
+
for (const block of parts.bodyBlocks) {
|
|
262
|
+
nameToBlock.set(normalizeRequirementName(block.name), block);
|
|
263
|
+
}
|
|
264
|
+
// Keep source blocks immutable for loss attribution. This parallel key list
|
|
265
|
+
// carries only positional identity as renames change lookup keys.
|
|
266
|
+
const orderedKeys = parts.bodyBlocks.map((block) => normalizeRequirementName(block.name));
|
|
267
|
+
// Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED
|
|
268
|
+
// RENAMED
|
|
269
|
+
let renamedApplied = 0;
|
|
270
|
+
for (const r of plan.renamed) {
|
|
271
|
+
const from = normalizeRequirementName(r.from);
|
|
272
|
+
const to = normalizeRequirementName(r.to);
|
|
273
|
+
if (!nameToBlock.has(from)) {
|
|
274
|
+
// Source gone but target present means the rename was already synced
|
|
275
|
+
// to the baseline (early-sync pattern) — re-applying it is a no-op,
|
|
276
|
+
// not a failure. Only a missing source AND target is a genuine error.
|
|
277
|
+
if (nameToBlock.has(to)) {
|
|
278
|
+
// Unless a case/whitespace variant of the source still exists (and is
|
|
279
|
+
// not the target itself, as in a case-only rename): that is a typo'd
|
|
280
|
+
// header, not an early-synced rename — same guard REMOVED applies.
|
|
281
|
+
const nearMiss = [...nameToBlock.keys()].find((k) => k !== to && foldRequirementName(k) === foldRequirementName(from));
|
|
282
|
+
if (nearMiss !== undefined) {
|
|
283
|
+
throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found, but "### Requirement: ${nameToBlock.get(nearMiss).name}" exists; fix the header to match it exactly`);
|
|
284
|
+
}
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (options.skipUnresolved) {
|
|
288
|
+
warn(`${specName} - RENAMED "${r.from}" is not in the current spec; skipped (--force).`);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found`);
|
|
292
|
+
}
|
|
293
|
+
if (nameToBlock.has(to)) {
|
|
294
|
+
throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - target already exists`);
|
|
295
|
+
}
|
|
296
|
+
const block = nameToBlock.get(from);
|
|
297
|
+
const newHeader = `### Requirement: ${to}`;
|
|
298
|
+
const rawLines = block.raw.split('\n');
|
|
299
|
+
rawLines[0] = newHeader;
|
|
300
|
+
const renamedBlock = {
|
|
301
|
+
headerLine: newHeader,
|
|
302
|
+
name: to,
|
|
303
|
+
raw: rawLines.join('\n'),
|
|
304
|
+
};
|
|
305
|
+
nameToBlock.delete(from);
|
|
306
|
+
nameToBlock.set(to, renamedBlock);
|
|
307
|
+
// A Map delete+set moves the renamed block to insertion-order tail. Carry
|
|
308
|
+
// its new key in the source slot instead; chained renames update it again.
|
|
309
|
+
const orderIndex = orderedKeys.indexOf(from);
|
|
310
|
+
if (orderIndex >= 0) {
|
|
311
|
+
orderedKeys[orderIndex] = to;
|
|
312
|
+
}
|
|
313
|
+
renamedApplied++;
|
|
314
|
+
}
|
|
315
|
+
// REMOVED
|
|
316
|
+
let removedApplied = 0;
|
|
317
|
+
for (const name of plan.removed) {
|
|
318
|
+
const key = normalizeRequirementName(name);
|
|
319
|
+
if (!nameToBlock.has(key)) {
|
|
320
|
+
// Requirement gone from the baseline means the removal was already
|
|
321
|
+
// synced (early-sync pattern) — re-applying it is a no-op, not a
|
|
322
|
+
// failure. One signal does separate that from a mistyped header: a
|
|
323
|
+
// requirement that differs only in case or interior whitespace still
|
|
324
|
+
// being present. That is a typo, and stays a hard abort.
|
|
325
|
+
// For new specs the skip was already warned about above.
|
|
326
|
+
if (!isNewSpec) {
|
|
327
|
+
const nearMiss = [...nameToBlock.keys()].find((k) => foldRequirementName(k) === foldRequirementName(key));
|
|
328
|
+
if (nearMiss !== undefined) {
|
|
329
|
+
throw new Error(`${specName} REMOVED failed for header "### Requirement: ${name}" - not found, but "### Requirement: ${nameToBlock.get(nearMiss).name}" exists; fix the header to match it exactly`);
|
|
330
|
+
}
|
|
331
|
+
warn(`${specName} - REMOVED requirement "${name}" is not in the current spec; treating it as already removed.`);
|
|
332
|
+
}
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
nameToBlock.delete(key);
|
|
336
|
+
removedApplied++;
|
|
337
|
+
}
|
|
338
|
+
// MODIFIED
|
|
339
|
+
let modifiedApplied = 0;
|
|
340
|
+
for (const mod of plan.modified) {
|
|
341
|
+
const key = normalizeRequirementName(mod.name);
|
|
342
|
+
const currentBlock = nameToBlock.get(key);
|
|
343
|
+
if (!currentBlock) {
|
|
344
|
+
if (options.skipUnresolved) {
|
|
345
|
+
warn(`${specName} - MODIFIED "${mod.name}" is not in the current spec; skipped (--force).`);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found`);
|
|
349
|
+
}
|
|
350
|
+
// Replace block with provided raw (ensure header line matches key)
|
|
351
|
+
const modHeaderMatch = mod.raw.split('\n')[0].match(/^###\s*Requirement:\s*(.+)\s*$/i);
|
|
352
|
+
if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) {
|
|
353
|
+
throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content`);
|
|
354
|
+
}
|
|
355
|
+
const missingScenarios = findMissingCurrentScenarios(currentBlock, mod);
|
|
356
|
+
if (missingScenarios.length > 0) {
|
|
357
|
+
throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${missingScenarios.map(name => `"${name}"`).join(', ')}. Refresh the change spec before archiving to avoid dropping scenarios.`);
|
|
358
|
+
}
|
|
359
|
+
// Identical content means the modification was already synced to the
|
|
360
|
+
// baseline (early-sync pattern) — count only real replacements, so a
|
|
361
|
+
// fully synced change still takes the "already in sync" write skip
|
|
362
|
+
// instead of churning normalization differences into the file.
|
|
363
|
+
if (normalizeBlockRaw(currentBlock.raw) !== normalizeBlockRaw(mod.raw)) {
|
|
364
|
+
modifiedApplied++;
|
|
365
|
+
}
|
|
366
|
+
nameToBlock.set(key, mod);
|
|
367
|
+
}
|
|
368
|
+
// ADDED
|
|
369
|
+
let addedApplied = 0;
|
|
370
|
+
for (const add of plan.added) {
|
|
371
|
+
const key = normalizeRequirementName(add.name);
|
|
372
|
+
const existing = nameToBlock.get(key);
|
|
373
|
+
if (existing) {
|
|
374
|
+
// Identical content means the requirement was already synced to the
|
|
375
|
+
// baseline (early-sync pattern) — re-applying it is a no-op, not a
|
|
376
|
+
// conflict. Only differing content is a genuine collision.
|
|
377
|
+
if (normalizeBlockRaw(existing.raw) === normalizeBlockRaw(add.raw)) {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`);
|
|
381
|
+
}
|
|
382
|
+
nameToBlock.set(key, add);
|
|
383
|
+
addedApplied++;
|
|
384
|
+
}
|
|
385
|
+
// Duplicates within resulting map are implicitly prevented by key uniqueness.
|
|
386
|
+
// Recompose requirements section preserving original ordering where possible
|
|
387
|
+
const keptOrder = [];
|
|
388
|
+
const seen = new Set();
|
|
389
|
+
for (let index = 0; index < parts.bodyBlocks.length; index++) {
|
|
390
|
+
const block = parts.bodyBlocks[index];
|
|
391
|
+
const key = orderedKeys[index];
|
|
392
|
+
const replacement = nameToBlock.get(key);
|
|
393
|
+
if (replacement) {
|
|
394
|
+
keptOrder.push(replacement);
|
|
395
|
+
seen.add(key);
|
|
396
|
+
}
|
|
397
|
+
// A block's raw runs to the next header the parser RECOGNISES, so a note
|
|
398
|
+
// under an unrecognized heading can be absorbed into the requirement.
|
|
399
|
+
// Warn only when the replacement from this same original block drops the
|
|
400
|
+
// full absorbed suffix. RENAMED carries the original raw content under a
|
|
401
|
+
// new map key, and MODIFIED may repeat the suffix deliberately; neither is
|
|
402
|
+
// data loss.
|
|
403
|
+
const replacementFromOriginal = replacement;
|
|
404
|
+
if (replacementFromOriginal !== block) {
|
|
405
|
+
const foreign = firstForeignTail(block.raw);
|
|
406
|
+
const replacementRaw = replacementFromOriginal?.raw;
|
|
407
|
+
const normalizedForeign = foreign ? normalizeBlockRaw(foreign.raw) : '';
|
|
408
|
+
const keepsForeignTail = foreign !== undefined &&
|
|
409
|
+
replacementRaw !== undefined &&
|
|
410
|
+
countOccurrences(normalizeBlockRaw(replacementRaw), normalizedForeign) >=
|
|
411
|
+
countOccurrences(normalizeBlockRaw(block.raw), normalizedForeign);
|
|
412
|
+
if (foreign && !keepsForeignTail) {
|
|
413
|
+
warn(`${specName} - "${foreign.heading}" sits inside requirement "${block.name}" and goes with it. ` +
|
|
414
|
+
'Move it under its own requirement, or above `## Requirements`, to keep it.');
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// Append any newly added that were not in original order
|
|
419
|
+
for (const [key, block] of nameToBlock.entries()) {
|
|
420
|
+
if (!seen.has(key)) {
|
|
421
|
+
keptOrder.push(block);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const reqBody = [parts.preamble && parts.preamble.trim() ? parts.preamble.trimEnd() : '']
|
|
425
|
+
.filter(Boolean)
|
|
426
|
+
.concat(keptOrder.map((b) => b.raw))
|
|
427
|
+
.join('\n\n')
|
|
428
|
+
.trimEnd();
|
|
429
|
+
// The blank lines surrounding `## Requirements` belong to no slice: `before`
|
|
430
|
+
// and `after` carry at most one trailing/leading '\n' by construction, and
|
|
431
|
+
// the body is trimEnd()ed. Joining the slices with a bare '\n' therefore
|
|
432
|
+
// glued the heading to the Purpose paragraph and the first requirement, so
|
|
433
|
+
// every archive rewrote a well-formatted spec into that shape. Separate
|
|
434
|
+
// non-empty slices with one blank line instead.
|
|
435
|
+
const rebuilt = collapseBlankRunsOutsideFences([parts.before.trimEnd(), parts.headerLine, reqBody, parts.after.trim()]
|
|
436
|
+
.filter((s) => s !== '')
|
|
437
|
+
.join('\n\n')).trimEnd() + '\n';
|
|
438
|
+
return {
|
|
439
|
+
rebuilt,
|
|
440
|
+
counts: {
|
|
441
|
+
added: addedApplied,
|
|
442
|
+
modified: modifiedApplied,
|
|
443
|
+
removed: removedApplied,
|
|
444
|
+
renamed: renamedApplied,
|
|
445
|
+
},
|
|
446
|
+
warnings,
|
|
447
|
+
noRequirementBlocks: keptOrder.length === 0,
|
|
448
|
+
// Read off the ORIGINAL requirements section, not the rebuilt one. Anything
|
|
449
|
+
// after the last `### Requirement:` header belongs to that block's raw and
|
|
450
|
+
// is discarded with it, so a rebuilt-body scan only ever sees headings above
|
|
451
|
+
// the first requirement - it would veto `### Notes` written before the
|
|
452
|
+
// requirements and miss the identical heading written after them.
|
|
453
|
+
unaccountedContent: contentTheMergeCannotName(parts),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* The suffix of a requirement block that begins with content the requirement
|
|
458
|
+
* parser did not recognize as a boundary: a `#`, `##`, or `###` heading after
|
|
459
|
+
* the block's own header.
|
|
460
|
+
*
|
|
461
|
+
* `####` is excluded: a requirement's `#### Scenario:` headings are its own.
|
|
462
|
+
* Fenced lines are skipped, so a heading inside an example does not count.
|
|
463
|
+
*
|
|
464
|
+
* Approximate on purpose, and only ever used to WARN. A `#` line inside a
|
|
465
|
+
* scenario looks the same as a note written below the requirement, and no
|
|
466
|
+
* line-based rule separates them; a wrong warning costs a line of output, while
|
|
467
|
+
* acting on a wrong answer would rewrite the spec.
|
|
468
|
+
*/
|
|
469
|
+
function firstForeignTail(raw) {
|
|
470
|
+
const lines = raw.replace(/\r\n?/g, '\n').split('\n');
|
|
471
|
+
const fenceMask = buildCodeFenceMask(lines);
|
|
472
|
+
for (let index = 1; index < lines.length; index++) {
|
|
473
|
+
if (fenceMask[index])
|
|
474
|
+
continue;
|
|
475
|
+
if (/^ {0,3}#{1,3}(?:[ \t]|$)/.test(lines[index])) {
|
|
476
|
+
return {
|
|
477
|
+
heading: lines[index].trim(),
|
|
478
|
+
raw: lines.slice(index).join('\n').trimEnd(),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return undefined;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* The non-blank lines of a spec that are not part of what a retirement is able
|
|
486
|
+
* to name: the title, the `## Purpose` section, the `## Requirements` header,
|
|
487
|
+
* and each requirement block's own header, statement and scenario bullets.
|
|
488
|
+
*
|
|
489
|
+
* Deliberately whole-file. Auditing a subset of the slices is what let authored
|
|
490
|
+
* prose inside a removed block, and content above the requirements section, be
|
|
491
|
+
* deleted unmentioned.
|
|
492
|
+
*/
|
|
493
|
+
function contentTheMergeCannotName(parts) {
|
|
494
|
+
const leftovers = [];
|
|
495
|
+
// Above the requirements section: the title and the Purpose section are
|
|
496
|
+
// expected; anything else is authored content the deletion would take.
|
|
497
|
+
const beforeLines = parts.before.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').split('\n');
|
|
498
|
+
const beforeMask = buildCodeFenceMask(beforeLines);
|
|
499
|
+
let inPurpose = false;
|
|
500
|
+
let titleSeen = false;
|
|
501
|
+
let previousLine = '';
|
|
502
|
+
for (let index = 0; index < beforeLines.length; index++) {
|
|
503
|
+
const line = beforeLines[index];
|
|
504
|
+
if (!line.trim()) {
|
|
505
|
+
previousLine = '';
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
if (!beforeMask[index]) {
|
|
509
|
+
const section = line.match(/^ {0,3}##\s+(.+?)\s*$/);
|
|
510
|
+
if (section) {
|
|
511
|
+
inPurpose = /^purpose$/i.test(section[1].trim());
|
|
512
|
+
if (!inPurpose)
|
|
513
|
+
leftovers.push(line.trim());
|
|
514
|
+
previousLine = line;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
// `##` is not the only way to open a section. A setext underline turns
|
|
518
|
+
// the line above it into a heading, and raw HTML says so outright - a
|
|
519
|
+
// reader sees a sibling of `## Purpose`, not more of its body. Treating
|
|
520
|
+
// everything up to the next ATX `##` as Purpose swallowed those whole and
|
|
521
|
+
// deleted them, reported as nothing but "Purpose".
|
|
522
|
+
const setext = inPurpose && previousLine.trim() && /^ {0,3}(=+|-+)\s*$/.test(line);
|
|
523
|
+
const htmlHeading = /^ {0,3}<h[1-6]\b/i.test(line);
|
|
524
|
+
if (setext || htmlHeading) {
|
|
525
|
+
leftovers.push((setext ? previousLine : line).trim());
|
|
526
|
+
inPurpose = false;
|
|
527
|
+
previousLine = line;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (/^ {0,3}#\s+.+$/.test(line)) {
|
|
531
|
+
if (!titleSeen && !inPurpose) {
|
|
532
|
+
titleSeen = true;
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
leftovers.push(line.trim());
|
|
536
|
+
inPurpose = false;
|
|
537
|
+
}
|
|
538
|
+
previousLine = line;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
previousLine = line;
|
|
543
|
+
if (inPurpose)
|
|
544
|
+
continue;
|
|
545
|
+
leftovers.push(line.trim());
|
|
546
|
+
}
|
|
547
|
+
// Between the header and the first requirement, and past the section's end.
|
|
548
|
+
for (const slice of [parts.preamble, parts.after]) {
|
|
549
|
+
for (const line of slice.split('\n')) {
|
|
550
|
+
if (line.trim())
|
|
551
|
+
leftovers.push(line.trim());
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
// Inside each requirement block, everything the block parser did not treat as
|
|
555
|
+
// a new header rides along in `raw` - tables, fences, comments, prose written
|
|
556
|
+
// below the scenarios. Only a requirement's own parts are expected here.
|
|
557
|
+
for (const block of parts.bodyBlocks) {
|
|
558
|
+
const foreignTail = firstForeignTail(block.raw);
|
|
559
|
+
if (foreignTail)
|
|
560
|
+
leftovers.push(foreignTail.heading);
|
|
561
|
+
const lines = block.raw.replace(/\r\n?/g, '\n').split('\n');
|
|
562
|
+
const mask = buildCodeFenceMask(lines);
|
|
563
|
+
let seenScenario = false;
|
|
564
|
+
// A scenario's bullets run unbroken beneath its header. A blank line after
|
|
565
|
+
// them ends the scenario, so bullets written past that point are a note the
|
|
566
|
+
// author added, not part of the scenario - and deleting the file would take
|
|
567
|
+
// them. Treating every bullet as a scenario's own is what let an
|
|
568
|
+
// operational note below the last scenario be deleted unmentioned.
|
|
569
|
+
let inScenarioBullets = false;
|
|
570
|
+
let bulletsSeen = false;
|
|
571
|
+
for (let index = 0; index < lines.length; index++) {
|
|
572
|
+
const line = lines[index];
|
|
573
|
+
if (!line.trim()) {
|
|
574
|
+
// Only a blank that follows actual bullets closes the run, so a blank
|
|
575
|
+
// between a scenario header and its first bullet is not a boundary.
|
|
576
|
+
if (bulletsSeen)
|
|
577
|
+
inScenarioBullets = false;
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
if (index === 0)
|
|
581
|
+
continue; // the `### Requirement:` header itself
|
|
582
|
+
// Fenced lines render as a code block inside the requirement, so they are
|
|
583
|
+
// its own content however they are spelled - a `### Requirement:` in an
|
|
584
|
+
// example is not a heading to any reader. Flagging them made a spec that
|
|
585
|
+
// merely documents a command unretirable.
|
|
586
|
+
if (mask[index])
|
|
587
|
+
continue;
|
|
588
|
+
if (index > 1 &&
|
|
589
|
+
/^ {0,3}(?:=+|-+)\s*$/.test(line) &&
|
|
590
|
+
lines[index - 1].trim()) {
|
|
591
|
+
leftovers.push(lines[index - 1].trim());
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (/^ {0,3}####\s+Scenario:/i.test(line)) {
|
|
595
|
+
seenScenario = true;
|
|
596
|
+
inScenarioBullets = true;
|
|
597
|
+
bulletsSeen = false;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (/^\s*(?:[-*]|\d+[.)])\s/.test(line)) {
|
|
601
|
+
if (inScenarioBullets) {
|
|
602
|
+
bulletsSeen = true;
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
// A bullet outside a scenario. Before the first scenario it is part of
|
|
606
|
+
// the requirement statement; after one it is the author's own note.
|
|
607
|
+
if (!seenScenario)
|
|
608
|
+
continue;
|
|
609
|
+
leftovers.push(line.trim());
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
// Free prose above the first scenario is the requirement statement.
|
|
613
|
+
if (!seenScenario && !/^\s*[|<]/.test(line))
|
|
614
|
+
continue;
|
|
615
|
+
leftovers.push(line.trim());
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return [...new Set(leftovers)];
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Collapses runs of blank lines to a single blank line everywhere except
|
|
622
|
+
* inside a fenced code block.
|
|
623
|
+
*
|
|
624
|
+
* The rejoin above glues slices back together with blank-line seams that need
|
|
625
|
+
* tidying; running that collapse over the whole document also rewrites the
|
|
626
|
+
* inside of fenced code blocks, so a requirement documenting a sample with two
|
|
627
|
+
* consecutive blank lines has that sample edited on every archive. Every other
|
|
628
|
+
* structural pass in this module is already fence-aware via `buildCodeFenceMask`;
|
|
629
|
+
* this keeps the seam cleanup consistent with them.
|
|
630
|
+
*
|
|
631
|
+
* Only a truly empty line counts as blank, matching the plain `/\n{3,}/`
|
|
632
|
+
* collapse this replaces: a line of spaces is never collapsed.
|
|
633
|
+
*/
|
|
634
|
+
function collapseBlankRunsOutsideFences(content) {
|
|
635
|
+
const lines = content.split('\n');
|
|
636
|
+
const mask = buildCodeFenceMask(lines);
|
|
637
|
+
const kept = [];
|
|
638
|
+
let blankRun = 0;
|
|
639
|
+
for (let index = 0; index < lines.length; index++) {
|
|
640
|
+
const line = lines[index];
|
|
641
|
+
if (mask[index]) {
|
|
642
|
+
blankRun = 0;
|
|
643
|
+
kept.push(line);
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
if (line === '') {
|
|
647
|
+
blankRun++;
|
|
648
|
+
if (blankRun > 1)
|
|
649
|
+
continue;
|
|
650
|
+
kept.push(line);
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
blankRun = 0;
|
|
654
|
+
kept.push(line);
|
|
655
|
+
}
|
|
656
|
+
return kept.join('\n');
|
|
657
|
+
}
|
|
658
|
+
function normalizeBlockRaw(raw) {
|
|
659
|
+
return raw.replace(/\r\n?/g, '\n').trim();
|
|
660
|
+
}
|
|
661
|
+
/** Count non-overlapping copies so one retained duplicate cannot mask another copy's loss. */
|
|
662
|
+
function countOccurrences(haystack, needle) {
|
|
663
|
+
if (!needle)
|
|
664
|
+
return 0;
|
|
665
|
+
let count = 0;
|
|
666
|
+
let start = 0;
|
|
667
|
+
while ((start = haystack.indexOf(needle, start)) !== -1) {
|
|
668
|
+
count++;
|
|
669
|
+
start += needle.length;
|
|
670
|
+
}
|
|
671
|
+
return count;
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Retire a capability whose last requirement a delta removed: delete its main
|
|
675
|
+
* spec and prune any directories the deletion leaves empty. Returns false when
|
|
676
|
+
* there was nothing to delete.
|
|
677
|
+
*
|
|
678
|
+
* Gated by the caller on the change's `retire_capabilities` marker, so the one
|
|
679
|
+
* archive action that removes a file from `openspec/specs/` is always something
|
|
680
|
+
* the author asked for rather than something inferred from a delta's shape. The
|
|
681
|
+
* file is recoverable from git, which the report names; applying REMOVED already
|
|
682
|
+
* deletes requirement content from a main spec, so deleting the spec once
|
|
683
|
+
* nothing is left is the same operation carried to its end rather than a new
|
|
684
|
+
* kind of act.
|
|
685
|
+
*
|
|
686
|
+
* Only the generated `spec.md` is removed - a directory holding anything else (a
|
|
687
|
+
* nested capability, a hand-kept note) is left in place.
|
|
688
|
+
*
|
|
689
|
+
* The target must resolve inside the selected specs root. A capability-directory
|
|
690
|
+
* symlink must not turn a retirement marker into authorization to delete an
|
|
691
|
+
* unrelated external file. A symlinked `spec.md` itself is safe: unlink removes
|
|
692
|
+
* the link and leaves its target alone.
|
|
693
|
+
*
|
|
694
|
+
* Directory pruning IS bounded, by REAL paths rather than string prefixes:
|
|
695
|
+
* `path.resolve` collapses `..` but does not resolve symlinks, and `readdir` and
|
|
696
|
+
* `rmdir` both follow them, so a symlinked capability directory would otherwise
|
|
697
|
+
* let the walk delete directories outside the specs root entirely.
|
|
698
|
+
*/
|
|
699
|
+
export async function retireSpec(update, mainSpecsDir, options = {}) {
|
|
700
|
+
if (options.deferDelete && options.verifyDisplaced === undefined) {
|
|
701
|
+
throw new Error('Deferred retirement requires displaced-file verification.');
|
|
702
|
+
}
|
|
703
|
+
// Resolved before the unlink, while the link still exists, so the report can
|
|
704
|
+
// name the file that actually goes when a symlink points out of the tree.
|
|
705
|
+
// A symlinked `spec.md` is excluded: `realpath` would follow it, but `unlink`
|
|
706
|
+
// removes the link and leaves the target alone, so naming the target would
|
|
707
|
+
// claim a file was deleted that is still there.
|
|
708
|
+
let realSource;
|
|
709
|
+
try {
|
|
710
|
+
const link = await fs.lstat(update.target);
|
|
711
|
+
realSource = link.isSymbolicLink() ? undefined : await fs.realpath(update.target);
|
|
712
|
+
}
|
|
713
|
+
catch (error) {
|
|
714
|
+
if (error.code === 'ENOENT')
|
|
715
|
+
return { retired: false };
|
|
716
|
+
throw new Error(`Could not retire capability '${update.id}': could not verify ${update.target} ` +
|
|
717
|
+
`before deletion (${error instanceof Error ? error.message : String(error)}).`);
|
|
718
|
+
}
|
|
719
|
+
if (realSource !== undefined) {
|
|
720
|
+
let inside;
|
|
721
|
+
try {
|
|
722
|
+
inside = await isInsideRealDir(realSource, mainSpecsDir);
|
|
723
|
+
}
|
|
724
|
+
catch (error) {
|
|
725
|
+
throw new Error(`Could not retire capability '${update.id}': could not verify that ${update.target} ` +
|
|
726
|
+
`is inside ${mainSpecsDir} (${error instanceof Error ? error.message : String(error)}).`);
|
|
727
|
+
}
|
|
728
|
+
if (!inside) {
|
|
729
|
+
throw new Error(`Could not retire capability '${update.id}': ${update.target} resolves outside ` +
|
|
730
|
+
`${mainSpecsDir}. Remove the external file by hand, or replace the symlink and rerun.`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
let displacedPath;
|
|
734
|
+
try {
|
|
735
|
+
await options.beforeMutate?.();
|
|
736
|
+
if (options.verifyDisplaced) {
|
|
737
|
+
const displaced = `${update.target}.openspec-retire-${randomUUID()}`;
|
|
738
|
+
displacedPath = displaced;
|
|
739
|
+
await fs.rename(update.target, displaced);
|
|
740
|
+
try {
|
|
741
|
+
await options.verifyDisplaced(displaced);
|
|
742
|
+
try {
|
|
743
|
+
await fs.lstat(update.target);
|
|
744
|
+
throw new Error(`A concurrent file appeared at ${update.target} while archive was retiring it.`);
|
|
745
|
+
}
|
|
746
|
+
catch (targetError) {
|
|
747
|
+
if (targetError.code !== 'ENOENT')
|
|
748
|
+
throw targetError;
|
|
749
|
+
}
|
|
750
|
+
if (!options.deferDelete)
|
|
751
|
+
await fs.unlink(displaced);
|
|
752
|
+
}
|
|
753
|
+
catch (error) {
|
|
754
|
+
try {
|
|
755
|
+
await fs.lstat(update.target);
|
|
756
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)} ` +
|
|
757
|
+
`A concurrent file now occupies ${update.target}; the displaced spec was retained at ${displaced}.`);
|
|
758
|
+
}
|
|
759
|
+
catch (targetError) {
|
|
760
|
+
if (targetError.code !== 'ENOENT')
|
|
761
|
+
throw targetError;
|
|
762
|
+
}
|
|
763
|
+
await fs.rename(displaced, update.target);
|
|
764
|
+
throw error;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
else {
|
|
768
|
+
await fs.unlink(update.target);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
catch (error) {
|
|
772
|
+
if (error.code === 'ENOENT')
|
|
773
|
+
return { retired: false };
|
|
774
|
+
// A bare errno here reads as an internal failure; say what was being
|
|
775
|
+
// attempted so the message is actionable on its own.
|
|
776
|
+
throw new Error(`Could not retire capability '${update.id}': failed to delete ${update.target} ` +
|
|
777
|
+
`(${error.message}). Remove it by hand, then rerun the archive.`);
|
|
778
|
+
}
|
|
779
|
+
if (!options.deferDelete) {
|
|
780
|
+
await pruneEmptyDirs(path.dirname(update.target), mainSpecsDir);
|
|
781
|
+
}
|
|
782
|
+
const nominal = options.displayPath ?? `openspec/specs/${update.id}/spec.md`;
|
|
783
|
+
if (!options.silent) {
|
|
784
|
+
console.log(`Retiring ${nominal}: all requirements removed.`);
|
|
785
|
+
}
|
|
786
|
+
// `resolvedPath` is always the file that was actually unlinked - callers need
|
|
787
|
+
// it to report a path git will accept, since the nominal one is built from
|
|
788
|
+
// the capability id and can differ in case, or point through a symlink.
|
|
789
|
+
return {
|
|
790
|
+
retired: true,
|
|
791
|
+
...(realSource ? { resolvedPath: realSource } : {}),
|
|
792
|
+
...(options.deferDelete && displacedPath ? { displacedPath } : {}),
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
export async function finalizeRetiredSpec(target, displacedPath, mainSpecsDir) {
|
|
796
|
+
await fs.unlink(displacedPath);
|
|
797
|
+
await pruneEmptyDirs(path.dirname(target), mainSpecsDir);
|
|
798
|
+
}
|
|
799
|
+
/** Whether `realPath` (already canonical) sits under the real `dir`. */
|
|
800
|
+
async function isInsideRealDir(realPath, dir) {
|
|
801
|
+
const realDir = await fs.realpath(dir);
|
|
802
|
+
return realPath.startsWith(realDir + path.sep);
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Remove now-empty directories from `startDir` upward, never leaving the real
|
|
806
|
+
* `boundaryDir` and never removing that directory itself.
|
|
807
|
+
*
|
|
808
|
+
* The boundary is a parameter rather than the specs root directly so the walk's
|
|
809
|
+
* containment is stated at the call site, where the root it must not escape is
|
|
810
|
+
* the thing being reasoned about.
|
|
811
|
+
*
|
|
812
|
+
* The guard re-runs every iteration, so stepping to the LEXICAL parent is safe:
|
|
813
|
+
* a parent that is not the real one is simply re-resolved and rejected. Errors
|
|
814
|
+
* are swallowed and end the walk - ENOTEMPTY and ENOENT are correct outcomes (a
|
|
815
|
+
* file arriving mid-walk must win), and a permissions failure leaves an empty
|
|
816
|
+
* directory behind, which the next successful archive clears.
|
|
817
|
+
*
|
|
818
|
+
* Not race-free: an attacker who can swap an ancestor between the check and the
|
|
819
|
+
* `rmdir` could get an empty directory outside the root removed. Closing that
|
|
820
|
+
* needs fd-relative syscalls Node does not expose, and it requires local write
|
|
821
|
+
* access to `openspec/specs` during an archive.
|
|
822
|
+
*/
|
|
823
|
+
async function pruneEmptyDirs(startDir, boundaryDir) {
|
|
824
|
+
let boundary;
|
|
825
|
+
try {
|
|
826
|
+
boundary = await fs.realpath(boundaryDir);
|
|
827
|
+
}
|
|
828
|
+
catch {
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
let dir = startDir;
|
|
832
|
+
for (;;) {
|
|
833
|
+
let realDir;
|
|
834
|
+
try {
|
|
835
|
+
// lstat first: rmdir on a symlink fails anyway, but resolving one would
|
|
836
|
+
// walk us out of the tree, and the parent we then step to would be wrong.
|
|
837
|
+
const link = await fs.lstat(dir);
|
|
838
|
+
if (link.isSymbolicLink())
|
|
839
|
+
return;
|
|
840
|
+
realDir = await fs.realpath(dir);
|
|
841
|
+
}
|
|
842
|
+
catch {
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
// Strictly inside the real boundary - the boundary itself is never pruned.
|
|
846
|
+
if (realDir === boundary || !realDir.startsWith(boundary + path.sep))
|
|
847
|
+
return;
|
|
848
|
+
try {
|
|
849
|
+
const entries = await fs.readdir(dir);
|
|
850
|
+
if (entries.length > 0)
|
|
851
|
+
return;
|
|
852
|
+
await fs.rmdir(dir);
|
|
853
|
+
}
|
|
854
|
+
catch {
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
dir = path.dirname(dir);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Write an updated spec to disk.
|
|
862
|
+
*/
|
|
863
|
+
export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
|
|
864
|
+
assertTrustedSpecPath(update.targetRoot, update.target);
|
|
865
|
+
// Create target directory if needed
|
|
866
|
+
const targetDir = path.dirname(update.target);
|
|
867
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
868
|
+
await options.beforeMutate?.();
|
|
869
|
+
// Preserve the established in-place write semantics: symlink referents,
|
|
870
|
+
// hard-linked specs, ACLs, extended attributes, and filesystems without hard
|
|
871
|
+
// links must continue to behave as they did before capability retirement.
|
|
872
|
+
await fs.writeFile(update.target, rebuilt);
|
|
873
|
+
if (options.silent)
|
|
874
|
+
return;
|
|
875
|
+
const specName = update.id;
|
|
876
|
+
console.log(`Applying changes to ${options.displayPath ?? `openspec/specs/${specName}/spec.md`}:`);
|
|
877
|
+
if (counts.added)
|
|
878
|
+
console.log(` + ${counts.added} added`);
|
|
879
|
+
if (counts.modified)
|
|
880
|
+
console.log(` ~ ${counts.modified} modified`);
|
|
881
|
+
if (counts.removed)
|
|
882
|
+
console.log(` - ${counts.removed} removed`);
|
|
883
|
+
if (counts.renamed)
|
|
884
|
+
console.log(` → ${counts.renamed} renamed`);
|
|
885
|
+
}
|
|
886
|
+
/** Blank out `<!-- ... -->` spans, preserving line count so indices stay aligned. */
|
|
887
|
+
function maskHtmlComments(content) {
|
|
888
|
+
const blank = (text) => text.replace(/[^\n]/g, ' ');
|
|
889
|
+
// `--!>` is a comment terminator as well as `-->`.
|
|
890
|
+
const masked = content.replace(/<!--[\s\S]*?--!?>/g, blank);
|
|
891
|
+
// A comment that is never closed runs to end of file, so everything after it
|
|
892
|
+
// is commented out too. Without this an unterminated `<!--` above a
|
|
893
|
+
// `## Purpose` left the commented-out header looking real (#1413).
|
|
894
|
+
const unterminated = masked.indexOf('<!--');
|
|
895
|
+
if (unterminated === -1)
|
|
896
|
+
return masked;
|
|
897
|
+
return masked.slice(0, unterminated) + blank(masked.slice(unterminated));
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Read the body of a `## Purpose` section, ignoring markdown that only appears
|
|
901
|
+
* inside fenced code blocks or HTML comments. Returns undefined when the
|
|
902
|
+
* section is absent or its body is empty.
|
|
903
|
+
*/
|
|
904
|
+
function extractPurposeSection(content) {
|
|
905
|
+
const normalized = content.replace(/\r\n?/g, '\n');
|
|
906
|
+
const lines = normalized.split('\n');
|
|
907
|
+
// Structure is read from the masked copy so a commented-out or fenced
|
|
908
|
+
// `## Purpose` is not mistaken for the real one; the body is returned from
|
|
909
|
+
// the original lines so an author's own comments and fences survive intact.
|
|
910
|
+
const masked = maskHtmlComments(normalized).split('\n');
|
|
911
|
+
const fenceMask = buildCodeFenceMask(masked);
|
|
912
|
+
const isStructural = (i) => !fenceMask[i];
|
|
913
|
+
const start = masked.findIndex((line, i) => isStructural(i) && /^##\s+Purpose\s*$/i.test(line));
|
|
914
|
+
if (start === -1)
|
|
915
|
+
return undefined;
|
|
916
|
+
let end = masked.length;
|
|
917
|
+
for (let i = start + 1; i < masked.length; i++) {
|
|
918
|
+
if (isStructural(i) && /^##\s+/.test(masked[i])) {
|
|
919
|
+
end = i;
|
|
920
|
+
break;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
// Emptiness is judged with fenced blocks and HTML comments blanked out, so a
|
|
924
|
+
// Purpose that is only a code sample or only an unfilled template comment
|
|
925
|
+
// counts as absent and falls back to the TBD placeholder.
|
|
926
|
+
const hasProse = masked
|
|
927
|
+
.slice(start + 1, end)
|
|
928
|
+
.filter((_, offset) => isStructural(start + 1 + offset))
|
|
929
|
+
.join('\n')
|
|
930
|
+
.trim();
|
|
931
|
+
if (!hasProse)
|
|
932
|
+
return undefined;
|
|
933
|
+
const body = lines.slice(start + 1, end).join('\n').trim();
|
|
934
|
+
return body || undefined;
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* The Purpose a new main spec would end up with, or null when carrying the
|
|
938
|
+
* delta's body over would leave a spec the readers downstream cannot handle.
|
|
939
|
+
*
|
|
940
|
+
* Returns the parsed overview rather than a boolean so callers measure the same
|
|
941
|
+
* string `validate` measures, not the raw slice out of the delta.
|
|
942
|
+
*/
|
|
943
|
+
function readableOverview(skeleton, specName) {
|
|
944
|
+
// HTML comments are invisible to the spec parsers but not to the file itself:
|
|
945
|
+
// markdown hidden in one is skipped by the boundary scan yet still lands in
|
|
946
|
+
// the spec, where it can hide the headers those parsers depend on and blank
|
|
947
|
+
// the document out in any markdown renderer. Refuse rather than write a spec
|
|
948
|
+
// that reads differently depending on who is reading it (#1413).
|
|
949
|
+
//
|
|
950
|
+
// Only the opener is disqualifying, and only because `maskHtmlComments`
|
|
951
|
+
// covers unterminated comments too: a comment starting above the section
|
|
952
|
+
// header therefore always masks the header, leaving no body to carry, so a
|
|
953
|
+
// body can only hide content behind a `<!--` of its own. A bare `-->` hides
|
|
954
|
+
// nothing and renders as text - rejecting it would throw away a Purpose over
|
|
955
|
+
// prose like "ingest --> transform".
|
|
956
|
+
if (skeleton.includes('<!--'))
|
|
957
|
+
return null;
|
|
958
|
+
if (findMainSpecStructureIssues(skeleton).length > 0)
|
|
959
|
+
return null;
|
|
960
|
+
try {
|
|
961
|
+
// A heading or unterminated fence in the body truncates or swallows the
|
|
962
|
+
// sections around it, so archive would abort or write a spec its own
|
|
963
|
+
// validator rejects.
|
|
964
|
+
return new MarkdownParser(skeleton).parseSpec(specName).overview.trim() || null;
|
|
965
|
+
}
|
|
966
|
+
catch {
|
|
967
|
+
return null;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Build a skeleton spec for new capabilities. When the delta spec authored a
|
|
972
|
+
* `## Purpose`, carry it over instead of the TBD placeholder (#1413) - archive
|
|
973
|
+
* invents the Purpose for a brand-new main spec either way, and the author's
|
|
974
|
+
* own wording beats a placeholder they then have to hand-edit.
|
|
975
|
+
*/
|
|
976
|
+
export function buildSpecSkeleton(specFolderName, changeName, purpose) {
|
|
977
|
+
const titleBase = specFolderName;
|
|
978
|
+
const purposeBody = purpose?.trim() ||
|
|
979
|
+
`${PURPOSE_PLACEHOLDER_PREFIX}${changeName}${PURPOSE_PLACEHOLDER_SUFFIX}`;
|
|
980
|
+
return `# ${titleBase} Specification\n\n## Purpose\n${purposeBody}\n\n## Requirements\n`;
|
|
981
|
+
}
|