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,850 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Validator for the subagent-driven-development artifacts (reference/brief.md and
|
|
3
|
+
// reference/handoff.md). Zero dependencies; exit 0 on success, 1 on a rejected artifact or
|
|
4
|
+
// on usage. USAGE lists the subcommands.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
/** Allowed handoff statuses. */
|
|
10
|
+
const STATUSES = new Set(["completed", "partial", "blocked"]);
|
|
11
|
+
/** Allowed per-test results. */
|
|
12
|
+
const TEST_RESULTS = new Set(["pass", "fail", "unavailable"]);
|
|
13
|
+
/** Stop-condition tokens a blocked handoff starts its reason with. */
|
|
14
|
+
const STOP_TOKENS = new Set(["too-big", "needs-confirm", "ambiguous", "regressed", "scope"]);
|
|
15
|
+
/** Tiers a brief may declare. */
|
|
16
|
+
const TIERS = new Set(["strong", "standard", "fast"]);
|
|
17
|
+
/** A `D:<kebab-slug>` or `R:<task>.<n>` digest id; `<task>` is dotted, batch (`1.3b`) or `final`. */
|
|
18
|
+
const DIGEST_ID = /^(D:[a-z0-9]+(?:-[a-z0-9]+)*|R:(?:\d+(?:\.\d+)*b?|final)\.\d+):\s*(.*)$/;
|
|
19
|
+
/** Non-empty-line budget for the `## Why and rulings` section. */
|
|
20
|
+
const DIGEST_BUDGET = 15;
|
|
21
|
+
/** A valid `Ceiling:` header value: a positive integer, no sign, no leading zero. */
|
|
22
|
+
const CEILING_RE = /^[1-9]\d*$/;
|
|
23
|
+
/** Default budget ceiling (cache-read tokens) when a brief or `--ceiling` gives none. */
|
|
24
|
+
const DEFAULT_CEILING = 25000000;
|
|
25
|
+
/** Task id a ledger line may carry: dotted, batch (`1.3b`), or `final`. */
|
|
26
|
+
const LEDGER_TASK_RE = /^(?:\d+(?:\.\d+)*b?|final)$/;
|
|
27
|
+
/** A valid dispatch name: role, underscore-joined task, optional round, optional slug. */
|
|
28
|
+
const DISPATCH_NAME_RE = /^(impl|review|fix)-(\d+(?:_\d+)*b?|final)(?:-r(\d+))?(?:-([a-z0-9-]+))?$/;
|
|
29
|
+
/** Charset a dispatch name may use before correction (letters of either case, digits, `_.-`). */
|
|
30
|
+
const DISPATCH_NAME_CHARSET_RE = /^[A-Za-z0-9_.-]+$/;
|
|
31
|
+
|
|
32
|
+
/** Handoff JSON of a `.json` file, or a report's first json block. */
|
|
33
|
+
export function readHandoff(file) {
|
|
34
|
+
const text = fs.readFileSync(file, "utf8");
|
|
35
|
+
if (file.endsWith(".json")) return JSON.parse(text);
|
|
36
|
+
const m = /```json\s*\n([\s\S]*?)\n```/.exec(text);
|
|
37
|
+
if (!m) throw new Error(`${file}: no fenced json block`);
|
|
38
|
+
return JSON.parse(m[1]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Problems of a parsed handoff against the schema; none when valid. */
|
|
42
|
+
export function validateHandoff(h) {
|
|
43
|
+
const errors = [];
|
|
44
|
+
if (!h || typeof h !== "object" || Array.isArray(h)) return ["handoff: not an object"];
|
|
45
|
+
const o = /** @type {Record<string, unknown>} */ (h);
|
|
46
|
+
const strList = (key) => Array.isArray(o[key]) && o[key].every((x) => typeof x === "string" && x.trim() !== "");
|
|
47
|
+
/** A glob or directory path (trailing slash, or a `* ? [ ]` glob character). */
|
|
48
|
+
const isGlobOrDir = (x) => /[*?[\]]/.test(x) || x.endsWith("/");
|
|
49
|
+
if (typeof o.task !== "string" || o.task.trim() === "") errors.push("task: non-empty string required");
|
|
50
|
+
if (typeof o.status !== "string" || !STATUSES.has(o.status)) errors.push(`status: must be one of ${[...STATUSES].join("|")}; got ${JSON.stringify(o.status)}`);
|
|
51
|
+
if (!strList("files_changed")) errors.push("files_changed: array of non-empty strings required");
|
|
52
|
+
else if (o.files_changed.some(isGlobOrDir)) errors.push("files_changed: array of non-empty strings required, one file path each, not a directory or a glob");
|
|
53
|
+
if (!strList("evidence")) errors.push("evidence: array of non-empty strings required");
|
|
54
|
+
if (!Array.isArray(o.tests_run)) errors.push("tests_run: array required");
|
|
55
|
+
else {
|
|
56
|
+
o.tests_run.forEach((t, i) => {
|
|
57
|
+
if (!t || typeof t !== "object") errors.push(`tests_run[${i}]: object required`);
|
|
58
|
+
else {
|
|
59
|
+
if (typeof t.command !== "string" || t.command.trim() === "") errors.push(`tests_run[${i}].command: non-empty string required`);
|
|
60
|
+
if (!TEST_RESULTS.has(t.result)) errors.push(`tests_run[${i}].result: must be pass|fail|unavailable`);
|
|
61
|
+
if (typeof t.summary !== "string" || t.summary.trim() === "") errors.push(`tests_run[${i}].summary: non-empty string required`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (o.concerns !== undefined && !Array.isArray(o.concerns)) errors.push("concerns: array required when present");
|
|
66
|
+
const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
|
|
67
|
+
if (o.status === "completed") {
|
|
68
|
+
if (Array.isArray(o.tests_run) && o.tests_run.length === 0) errors.push("status completed: tests_run must not be empty");
|
|
69
|
+
if (Array.isArray(o.evidence) && o.evidence.length === 0) errors.push("status completed: evidence must not be empty");
|
|
70
|
+
if (nonEmpty(o.partial_output)) errors.push("status completed: partial_output must be null");
|
|
71
|
+
if (nonEmpty(o.blocked_reason)) errors.push("status completed: blocked_reason must be null");
|
|
72
|
+
}
|
|
73
|
+
if (o.status === "partial" && !nonEmpty(o.partial_output)) errors.push("status partial: partial_output label required");
|
|
74
|
+
if (o.status === "blocked") {
|
|
75
|
+
if (!nonEmpty(o.blocked_reason)) errors.push("status blocked: blocked_reason required");
|
|
76
|
+
else if (!STOP_TOKENS.has(String(o.blocked_reason).split(":")[0].trim())) errors.push(`blocked_reason: must start with one of ${[...STOP_TOKENS].join("|")}`);
|
|
77
|
+
}
|
|
78
|
+
return errors;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Trimmed non-empty lines of one `## ` section; empty if absent. */
|
|
82
|
+
function readSection(text, title) {
|
|
83
|
+
const re = new RegExp(`^## ${title}\\s*$`, "m");
|
|
84
|
+
const section = text.split(/^(?=## )/m).find((s) => re.test(s.split("\n")[0]));
|
|
85
|
+
if (!section) return [];
|
|
86
|
+
return section
|
|
87
|
+
.split("\n")
|
|
88
|
+
.slice(1)
|
|
89
|
+
.map((l) => l.trim())
|
|
90
|
+
.filter((l) => l !== "");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Parses a brief into `{ file, header, scope, digest, digestIds, ceiling }`; `ceiling` is null
|
|
94
|
+
* without the header, NaN when not a positive integer. */
|
|
95
|
+
export function readBrief(file) {
|
|
96
|
+
const text = fs.readFileSync(file, "utf8");
|
|
97
|
+
const header = {};
|
|
98
|
+
for (const line of text.split("\n")) {
|
|
99
|
+
if (/^## /.test(line)) break;
|
|
100
|
+
const m = /^([A-Z][\w ]*):\s*(.+)$/.exec(line);
|
|
101
|
+
if (m) header[m[1]] = m[2].trim();
|
|
102
|
+
}
|
|
103
|
+
const scope = [];
|
|
104
|
+
for (const line of readSection(text, "Write scope")) {
|
|
105
|
+
const item = /^\s*-\s+`?([^`\s]+)`?\s*$/.exec(line);
|
|
106
|
+
if (item) scope.push(path.posix.normalize(item[1]));
|
|
107
|
+
}
|
|
108
|
+
const digest = readSection(text, "Why and rulings");
|
|
109
|
+
const digestIds = [];
|
|
110
|
+
for (const line of digest) {
|
|
111
|
+
const m = DIGEST_ID.exec(line);
|
|
112
|
+
if (m) digestIds.push(m[1]);
|
|
113
|
+
}
|
|
114
|
+
const ceiling = header.Ceiling === undefined ? null : CEILING_RE.test(header.Ceiling) ? Number(header.Ceiling) : NaN;
|
|
115
|
+
return { file, header, scope, digest, digestIds, ceiling };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Parses a ledger, one JSON object per line, into `{ lines, warnings }`; an unusable field is
|
|
119
|
+
* coerced with a warning, never thrown. A `task` reaches `ledgerSummary` as a path segment, so
|
|
120
|
+
* one that is not a task id becomes null. */
|
|
121
|
+
export function readLedger(file) {
|
|
122
|
+
const text = fs.readFileSync(file, "utf8");
|
|
123
|
+
const lines = [];
|
|
124
|
+
const warnings = [];
|
|
125
|
+
text.split("\n").forEach((raw, i) => {
|
|
126
|
+
if (raw.trim() === "") return;
|
|
127
|
+
const lineNumber = i + 1;
|
|
128
|
+
let obj;
|
|
129
|
+
try {
|
|
130
|
+
obj = JSON.parse(raw);
|
|
131
|
+
} catch {
|
|
132
|
+
warnings.push(`ledger line ${lineNumber}: invalid JSON, skipped`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
136
|
+
warnings.push(`ledger line ${lineNumber}: invalid JSON, skipped`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (typeof obj.cache_read === "number" && Number.isFinite(obj.cache_read) && obj.cache_read < 0) {
|
|
140
|
+
warnings.push(`ledger line ${lineNumber}: cache_read negative, treated as 0`);
|
|
141
|
+
obj = { ...obj, cache_read: 0 };
|
|
142
|
+
} else if (typeof obj.cache_read !== "number" || !Number.isFinite(obj.cache_read)) {
|
|
143
|
+
warnings.push(`ledger line ${lineNumber}: cache_read missing or non-numeric, treated as 0`);
|
|
144
|
+
obj = { ...obj, cache_read: 0 };
|
|
145
|
+
}
|
|
146
|
+
if (obj.task !== null && typeof obj.task !== "string") {
|
|
147
|
+
warnings.push(`ledger line ${lineNumber}: task missing or non-string, treated as null`);
|
|
148
|
+
obj = { ...obj, task: null };
|
|
149
|
+
} else if (typeof obj.task === "string" && !LEDGER_TASK_RE.test(obj.task)) {
|
|
150
|
+
warnings.push(`ledger line ${lineNumber}: task not a task id, treated as null`);
|
|
151
|
+
obj = { ...obj, task: null };
|
|
152
|
+
}
|
|
153
|
+
lines.push(obj);
|
|
154
|
+
});
|
|
155
|
+
return { lines, warnings };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Groups lines by `agent_id` (a resumed line joins its first line's dispatch; a line without a
|
|
160
|
+
* string `agent_id` is its own dispatch), returning each dispatch's largest `cache_read`.
|
|
161
|
+
*/
|
|
162
|
+
function groupByDispatch(lines) {
|
|
163
|
+
const perDispatch = new Map();
|
|
164
|
+
let anon = 0;
|
|
165
|
+
for (const l of lines) {
|
|
166
|
+
const id = typeof l.agent_id === "string" ? l.agent_id : `anon/${anon++}`;
|
|
167
|
+
const current = perDispatch.has(id) ? perDispatch.get(id) : -Infinity;
|
|
168
|
+
if (l.cache_read > current) perDispatch.set(id, l.cache_read);
|
|
169
|
+
}
|
|
170
|
+
return perDispatch;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Sums a task's `cache_read` spend against `ceiling`: total, reserve, dispatch count,
|
|
175
|
+
* remaining and `over`. A dispatch is one distinct `agent_id`; the reserve (the task's
|
|
176
|
+
* largest dispatch value) covers the next dispatch still to land, so the task is over once
|
|
177
|
+
* total plus reserve reaches the ceiling.
|
|
178
|
+
*/
|
|
179
|
+
export function budget(lines, task, ceiling) {
|
|
180
|
+
const taskLines = lines.filter((l) => l && l.task === task);
|
|
181
|
+
const perDispatch = groupByDispatch(taskLines);
|
|
182
|
+
let total = 0;
|
|
183
|
+
let reserve = 0;
|
|
184
|
+
for (const v of perDispatch.values()) {
|
|
185
|
+
total += v;
|
|
186
|
+
if (v > reserve) reserve = v;
|
|
187
|
+
}
|
|
188
|
+
const over = total + reserve >= ceiling;
|
|
189
|
+
const remaining = Math.max(0, ceiling - total - reserve);
|
|
190
|
+
return { total, reserve, dispatches: perDispatch.size, remaining, over };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Roles counted individually in a ledger-summary task bucket; anything else is `other`. */
|
|
194
|
+
const LEDGER_ROLES = new Set(["impl", "review", "fix"]);
|
|
195
|
+
/** Matches a why-digest P0 line inside a `<task>.review.md` package (case-sensitive). */
|
|
196
|
+
const WHY_DIGEST_P0 = /P0[^\n]*why-digest/;
|
|
197
|
+
|
|
198
|
+
/** Orders bucket keys: dotted ids naturally, then `final`, `other`. */
|
|
199
|
+
function compareTaskIds(a, b) {
|
|
200
|
+
const rank = (id) => (id === "final" ? 1 : id === "other" ? 2 : 0);
|
|
201
|
+
const ra = rank(a);
|
|
202
|
+
const rb = rank(b);
|
|
203
|
+
if (ra !== rb) return ra - rb;
|
|
204
|
+
if (ra !== 0) return 0;
|
|
205
|
+
const as = a.split(".");
|
|
206
|
+
const bs = b.split(".");
|
|
207
|
+
for (let i = 0; i < Math.max(as.length, bs.length); i++) {
|
|
208
|
+
const sa = as[i] ?? "";
|
|
209
|
+
const sb = bs[i] ?? "";
|
|
210
|
+
if (sa === sb) continue;
|
|
211
|
+
const ma = /^\d+/.exec(sa);
|
|
212
|
+
const mb = /^\d+/.exec(sb);
|
|
213
|
+
if (ma && mb) {
|
|
214
|
+
const na = Number(ma[0]);
|
|
215
|
+
const nb = Number(mb[0]);
|
|
216
|
+
if (na !== nb) return na - nb;
|
|
217
|
+
const ra2 = sa.slice(ma[0].length);
|
|
218
|
+
const rb2 = sb.slice(mb[0].length);
|
|
219
|
+
if (ra2 !== rb2) return ra2 < rb2 ? -1 : 1;
|
|
220
|
+
} else if (sa !== sb) {
|
|
221
|
+
return sa < sb ? -1 : 1;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Lines of a `<task>.review.md` matching `WHY_DIGEST_P0`; 0 if absent. */
|
|
228
|
+
function countWhyDigestP0(file) {
|
|
229
|
+
if (!fs.existsSync(file)) return 0;
|
|
230
|
+
const text = fs.readFileSync(file, "utf8");
|
|
231
|
+
return text.split("\n").filter((line) => WHY_DIGEST_P0.test(line)).length;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** p50/p75/p90 (nearest-rank) of a task-id-keyed map of total-plus-reserve values, plus the
|
|
235
|
+
* ids at or over `DEFAULT_CEILING`. */
|
|
236
|
+
function percentileSummary(totalPlusReserve) {
|
|
237
|
+
const entries = Object.entries(totalPlusReserve);
|
|
238
|
+
const sorted = entries.map(([, v]) => v).sort((a, b) => a - b);
|
|
239
|
+
const rank = (p) => (sorted.length === 0 ? 0 : sorted[Math.min(sorted.length, Math.max(1, Math.ceil((p / 100) * sorted.length))) - 1]);
|
|
240
|
+
const atOrOver = entries
|
|
241
|
+
.filter(([, v]) => v >= DEFAULT_CEILING)
|
|
242
|
+
.map(([k]) => k)
|
|
243
|
+
.sort(compareTaskIds);
|
|
244
|
+
return { p50: rank(50), p75: rank(75), p90: rank(90), atOrOver };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Groups a change directory's `ledger.jsonl` by task: `impl`, `review`, `fix`, `other`
|
|
249
|
+
* (dispatch counts, one per distinct `agent_id`), `resumed` (the line count), `cache_read`
|
|
250
|
+
* (per dispatch the largest line, summed, as in `budget`), `max_reviewers_per_round`,
|
|
251
|
+
* `why_digest_p0` and `percentiles` (total plus reserve per task), plus `readLedger`'s
|
|
252
|
+
* warnings. A missing ledger yields empty tasks; round 0 covers a missing one; the totals
|
|
253
|
+
* line's `dispatches` counts distinct `agent_id`s across the whole ledger.
|
|
254
|
+
*/
|
|
255
|
+
export function ledgerSummary(dir) {
|
|
256
|
+
const file = path.join(dir, "ledger.jsonl");
|
|
257
|
+
const { lines, warnings } = fs.existsSync(file) ? readLedger(file) : { lines: [], warnings: [] };
|
|
258
|
+
/** @type {Record<string, Record<string, number>>} */
|
|
259
|
+
const tasks = {};
|
|
260
|
+
/** @type {Map<string, Map<number, number>>} */
|
|
261
|
+
const roundCounts = new Map();
|
|
262
|
+
const taskLines = new Map();
|
|
263
|
+
const seenAgents = new Set();
|
|
264
|
+
let anon = 0;
|
|
265
|
+
for (const line of lines) {
|
|
266
|
+
const key = line.task === null ? "other" : line.task;
|
|
267
|
+
if (!tasks[key]) {
|
|
268
|
+
tasks[key] = { impl: 0, review: 0, fix: 0, other: 0, resumed: 0, cache_read: 0, max_reviewers_per_round: 0, why_digest_p0: 0 };
|
|
269
|
+
}
|
|
270
|
+
const bucket = tasks[key];
|
|
271
|
+
if (line.resumed === true) bucket.resumed += 1;
|
|
272
|
+
if (!taskLines.has(key)) taskLines.set(key, []);
|
|
273
|
+
taskLines.get(key).push(line);
|
|
274
|
+
const agentId = typeof line.agent_id === "string" ? line.agent_id : `anon/${anon++}`;
|
|
275
|
+
const isNewDispatch = !seenAgents.has(agentId);
|
|
276
|
+
seenAgents.add(agentId);
|
|
277
|
+
if (isNewDispatch) {
|
|
278
|
+
const role = LEDGER_ROLES.has(line.role) ? line.role : "other";
|
|
279
|
+
bucket[role] += 1;
|
|
280
|
+
if (role === "review") {
|
|
281
|
+
const round = typeof line.round === "number" && Number.isFinite(line.round) ? line.round : 0;
|
|
282
|
+
if (!roundCounts.has(key)) roundCounts.set(key, new Map());
|
|
283
|
+
const rounds = roundCounts.get(key);
|
|
284
|
+
rounds.set(round, (rounds.get(round) ?? 0) + 1);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const totals = { dispatches: seenAgents.size, cache_read: 0, max_reviewers_per_round: 0, why_digest_p0: 0 };
|
|
289
|
+
const totalPlusReserve = {};
|
|
290
|
+
for (const [key, bucket] of Object.entries(tasks)) {
|
|
291
|
+
const perDispatch = groupByDispatch(taskLines.get(key) ?? []);
|
|
292
|
+
let reserve = 0;
|
|
293
|
+
for (const v of perDispatch.values()) {
|
|
294
|
+
bucket.cache_read += v;
|
|
295
|
+
if (v > reserve) reserve = v;
|
|
296
|
+
}
|
|
297
|
+
if (key !== "other") totalPlusReserve[key] = bucket.cache_read + reserve;
|
|
298
|
+
const rounds = roundCounts.get(key);
|
|
299
|
+
bucket.max_reviewers_per_round = rounds ? Math.max(...rounds.values()) : 0;
|
|
300
|
+
bucket.why_digest_p0 = countWhyDigestP0(path.join(dir, `${key}.review.md`));
|
|
301
|
+
totals.cache_read += bucket.cache_read;
|
|
302
|
+
totals.max_reviewers_per_round = Math.max(totals.max_reviewers_per_round, bucket.max_reviewers_per_round);
|
|
303
|
+
totals.why_digest_p0 += bucket.why_digest_p0;
|
|
304
|
+
}
|
|
305
|
+
return { tasks, totals, warnings, percentiles: percentileSummary(totalPlusReserve) };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Git from an absolute PATH entry only; a relative one cannot supply it. */
|
|
309
|
+
function resolveGit() {
|
|
310
|
+
const bin = process.platform === "win32" ? "git.exe" : "git";
|
|
311
|
+
for (const dir of String(process.env.PATH || "").split(path.delimiter)) {
|
|
312
|
+
if (!dir || !path.isAbsolute(dir)) continue;
|
|
313
|
+
const candidate = path.join(dir, bin);
|
|
314
|
+
try {
|
|
315
|
+
if (fs.statSync(candidate).isFile()) return candidate;
|
|
316
|
+
} catch {
|
|
317
|
+
// not in this PATH entry
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Runs one git command in `cwd`, throwing with its stderr on a bad exit. */
|
|
324
|
+
function runGit(args, cwd) {
|
|
325
|
+
const bin = resolveGit();
|
|
326
|
+
if (!bin) throw new Error(`git ${args.join(" ")}: git not found on an absolute PATH entry`);
|
|
327
|
+
const r = spawnSync(bin, args, { cwd, encoding: "utf8" });
|
|
328
|
+
if (r.status !== 0) throw new Error(`git ${args.join(" ")}: ${(r.stderr || (r.error && r.error.message) || "").trim()}`);
|
|
329
|
+
return r.stdout || "";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Runs one git command and returns its trimmed stdout. */
|
|
333
|
+
function git(args, cwd) {
|
|
334
|
+
return runGit(args, cwd).trim();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Untracked (`??`) paths of a `git status --porcelain -z` stream; a rename or copy entry's
|
|
338
|
+
* extra NUL field is skipped, not misread as an entry. */
|
|
339
|
+
function untrackedPaths(raw) {
|
|
340
|
+
const entries = raw.split("\0");
|
|
341
|
+
if (entries.length && entries[entries.length - 1] === "") entries.pop();
|
|
342
|
+
const out = [];
|
|
343
|
+
for (let i = 0; i < entries.length; i++) {
|
|
344
|
+
const entry = entries[i];
|
|
345
|
+
const code = entry.slice(0, 2);
|
|
346
|
+
const p = entry.slice(3);
|
|
347
|
+
if (code === "??") out.push(p);
|
|
348
|
+
else if (code.includes("R") || code.includes("C")) i++;
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Repository-relative paths changed since `base` (a bare base diffs against the working tree,
|
|
354
|
+
* so commits and uncommitted edits both land) plus untracked paths; `--` keeps a path-shaped
|
|
355
|
+
* base off the pathspec list. */
|
|
356
|
+
export function changedPaths(base, cwd) {
|
|
357
|
+
const diff = git(["diff", "--name-only", base, "--"], cwd);
|
|
358
|
+
const status = runGit(["status", "--porcelain", "-z", "--untracked-files=all"], cwd);
|
|
359
|
+
const changed = new Set(diff.split("\n").filter((l) => l !== ""));
|
|
360
|
+
for (const p of untrackedPaths(status)) changed.add(p);
|
|
361
|
+
return [...changed];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Matches a write-scope or lead entry against a repository-relative path (trailing-`/` prefix,
|
|
365
|
+
* `*`/`**`/`?` glob, or exact). Every other regex character of an entry is a literal, so a glob
|
|
366
|
+
* never widens the scope it was written for. */
|
|
367
|
+
export function scopeMatches(entry, p) {
|
|
368
|
+
if (entry.endsWith("/")) return p.startsWith(entry);
|
|
369
|
+
if (entry.includes("*") || entry.includes("?")) {
|
|
370
|
+
const pattern = entry
|
|
371
|
+
.split(/(\*\*|\*|\?)/)
|
|
372
|
+
.map((part) =>
|
|
373
|
+
part === "**" ? ".*" : part === "*" ? "[^/]*" : part === "?" ? "[^/]" : part.replace(/[.+^${}()|[\]\\?]/g, "\\$&"),
|
|
374
|
+
)
|
|
375
|
+
.join("");
|
|
376
|
+
return new RegExp(`^${pattern}$`).test(p);
|
|
377
|
+
}
|
|
378
|
+
return p === entry;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** True for a path under `.freeharness/`, always excluded from the changed-unclaimed and scope checks. */
|
|
382
|
+
function isHarnessPath(p) {
|
|
383
|
+
return p.startsWith(".freeharness/");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Problems of a handoff's `files_changed` against `changed` and, with a scope, the brief's
|
|
387
|
+
* write scope. A `peerScope` or `leadPaths` path leaves the changed-unclaimed and scope passes,
|
|
388
|
+
* so claiming one is its own outcome (`peer-scope`/`lead-path`), never nothing; `.freeharness/`
|
|
389
|
+
* is always excluded from those same two passes. */
|
|
390
|
+
export function guardHandoff(h, changed, scope, peerScope = [], leadPaths = []) {
|
|
391
|
+
const errors = [];
|
|
392
|
+
const claimed = Array.isArray(h && h.files_changed) ? h.files_changed.filter((p) => typeof p === "string") : [];
|
|
393
|
+
const changedSet = new Set(changed);
|
|
394
|
+
const claimedSet = new Set(claimed);
|
|
395
|
+
const peer = new Set(peerScope);
|
|
396
|
+
const underLead = (p) => leadPaths.some((l) => scopeMatches(l, p));
|
|
397
|
+
const owned = changed.filter((p) => !peer.has(p) && !underLead(p) && !isHarnessPath(p));
|
|
398
|
+
for (const p of claimed) if (!changedSet.has(p)) errors.push(`claimed-unchanged ${p}`);
|
|
399
|
+
for (const p of claimed) if (peer.has(p)) errors.push(`peer-scope ${p}`);
|
|
400
|
+
for (const p of claimed) if (underLead(p)) errors.push(`lead-path ${p}`);
|
|
401
|
+
for (const p of owned) if (!claimedSet.has(p)) errors.push(`changed-unclaimed ${p}`);
|
|
402
|
+
if (scope) {
|
|
403
|
+
const inScope = (p) => scope.some((s) => scopeMatches(s, p));
|
|
404
|
+
const outside = new Set();
|
|
405
|
+
for (const p of owned) if (!inScope(p)) outside.add(p);
|
|
406
|
+
for (const p of claimed) if (!inScope(p) && !peer.has(p) && !underLead(p) && !isHarnessPath(p)) outside.add(p);
|
|
407
|
+
for (const p of outside) errors.push(`scope ${p}`);
|
|
408
|
+
}
|
|
409
|
+
return errors;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Problems of a batch: each brief's header and digest, plus scope overlap. */
|
|
413
|
+
export function validateBriefs(briefs) {
|
|
414
|
+
const errors = [];
|
|
415
|
+
const digestOwner = new Map();
|
|
416
|
+
for (const b of briefs) {
|
|
417
|
+
const name = path.basename(b.file);
|
|
418
|
+
for (const key of ["Change", "Task", "Spec", "Tier"]) if (!b.header[key]) errors.push(`${name}: header line '${key}:' missing`);
|
|
419
|
+
if (b.header.Tier && !TIERS.has(b.header.Tier)) errors.push(`${name}: Tier must be strong|standard|fast; got ${b.header.Tier}`);
|
|
420
|
+
if (b.header.Spec && (/[\\/]|\.md$/.test(b.header.Spec))) errors.push(`${name}: Spec must be the change id, not a path (${b.header.Spec})`);
|
|
421
|
+
if (b.header.Spec && b.header.Change && b.header.Spec !== b.header.Change) errors.push(`${name}: Spec (${b.header.Spec}) differs from Change (${b.header.Change})`);
|
|
422
|
+
if (b.scope.length === 0) errors.push(`${name}: '## Write scope' must list at least one path`);
|
|
423
|
+
if (b.header.Ceiling !== undefined && !CEILING_RE.test(b.header.Ceiling)) {
|
|
424
|
+
errors.push(`${name}: Ceiling must be a positive integer; got ${b.header.Ceiling}`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (b.digest.length === 0) errors.push(`${name}: '## Why and rulings' section missing`);
|
|
428
|
+
if (b.digest.length > DIGEST_BUDGET) errors.push(`${name}: '## Why and rulings' has ${b.digest.length} lines, budget is ${DIGEST_BUDGET}`);
|
|
429
|
+
if (b.digest.some((l) => l.startsWith("```"))) errors.push(`${name}: '## Why and rulings' must not contain a fenced block`);
|
|
430
|
+
|
|
431
|
+
const seen = new Set();
|
|
432
|
+
for (const line of b.digest) {
|
|
433
|
+
const m = DIGEST_ID.exec(line);
|
|
434
|
+
if (!m) continue;
|
|
435
|
+
const [, id, ruling] = m;
|
|
436
|
+
if (seen.has(id)) {
|
|
437
|
+
errors.push(`${name}: duplicate id ${id} inside the brief`);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
seen.add(id);
|
|
441
|
+
const prev = digestOwner.get(id);
|
|
442
|
+
if (!prev) digestOwner.set(id, { text: ruling, file: b.file });
|
|
443
|
+
else if (prev.text !== ruling) {
|
|
444
|
+
errors.push(`${name}: duplicate id ${id} differs from its text in ${path.basename(prev.file)}`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const owner = new Map();
|
|
449
|
+
for (const b of briefs) {
|
|
450
|
+
for (const p of b.scope) {
|
|
451
|
+
const prev = owner.get(p);
|
|
452
|
+
if (prev && prev !== b.file) errors.push(`write scope overlap: ${p} in ${path.basename(prev)} and ${path.basename(b.file)}; batch refused`);
|
|
453
|
+
else owner.set(p, b.file);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return errors;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Every complete fenced json block; an unterminated fence never matches. */
|
|
460
|
+
const JSON_BLOCK_RE = /```json\s*\n([\s\S]*?)\n```/g;
|
|
461
|
+
|
|
462
|
+
/** Assistant-authored texts of one line's `message.content`: `text` blocks joined, plus a
|
|
463
|
+
* `SendMessage` tool_use's `input.message` (the worker's reply channel in the teams runtime);
|
|
464
|
+
* no other tool input. */
|
|
465
|
+
function assistantCandidates(content) {
|
|
466
|
+
if (typeof content === "string") return content === "" ? [] : [content];
|
|
467
|
+
if (!Array.isArray(content)) return [];
|
|
468
|
+
const out = [];
|
|
469
|
+
let buf = [];
|
|
470
|
+
const flushText = () => {
|
|
471
|
+
if (buf.length > 0) out.push(buf.join(""));
|
|
472
|
+
buf = [];
|
|
473
|
+
};
|
|
474
|
+
for (const b of content) {
|
|
475
|
+
if (b && typeof b === "object" && b.type === "text" && typeof b.text === "string") {
|
|
476
|
+
buf.push(b.text);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
flushText();
|
|
480
|
+
if (b && typeof b === "object" && b.type === "tool_use" && b.name === "SendMessage" && b.input && typeof b.input.message === "string") {
|
|
481
|
+
out.push(b.input.message);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
flushText();
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** The last complete json block of a text plus the text after it; null if none. */
|
|
489
|
+
function lastJsonBlock(text) {
|
|
490
|
+
JSON_BLOCK_RE.lastIndex = 0;
|
|
491
|
+
let match;
|
|
492
|
+
let last = null;
|
|
493
|
+
while ((match = JSON_BLOCK_RE.exec(text)) !== null) last = match;
|
|
494
|
+
if (!last) return null;
|
|
495
|
+
return { block: last[1], remaining: text.slice(last.index + last[0].length) };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** The report's next text: its first json block replaced by `fenced`, or, when it has none (`existing` null for a report not yet on disk), the block before `remaining`. */
|
|
499
|
+
function spliceReport(existing, fenced, remaining) {
|
|
500
|
+
if (existing !== null) {
|
|
501
|
+
const m = /```json\s*\n[\s\S]*?\n```/.exec(existing);
|
|
502
|
+
if (m) return existing.slice(0, m.index) + fenced + existing.slice(m.index + m[0].length);
|
|
503
|
+
return `${fenced}\n\n${existing}`;
|
|
504
|
+
}
|
|
505
|
+
const body = remaining.replace(/^\n+/, "").trimEnd();
|
|
506
|
+
return body === "" ? `${fenced}\n` : `${fenced}\n\n${body}\n`;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Identity and schema validity of a report's first json block; null if none. */
|
|
510
|
+
function existingBlockInfo(text) {
|
|
511
|
+
const m = /```json\s*\n([\s\S]*?)\n```/.exec(text);
|
|
512
|
+
if (!m) return null;
|
|
513
|
+
let parsed;
|
|
514
|
+
try {
|
|
515
|
+
parsed = JSON.parse(m[1]);
|
|
516
|
+
} catch {
|
|
517
|
+
return { task: null, status: null, valid: false };
|
|
518
|
+
}
|
|
519
|
+
const o = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
520
|
+
return {
|
|
521
|
+
task: typeof o.task === "string" ? o.task : null,
|
|
522
|
+
status: typeof o.status === "string" ? o.status : null,
|
|
523
|
+
valid: validateHandoff(parsed).length === 0,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Rebuilds a handoff report from a transcript's last complete json block, so a trailing prose
|
|
528
|
+
* summary never fails recovery. A schema-valid block in the report survives unless `force`: a
|
|
529
|
+
* rerun over a stale transcript would replace a good handoff with an old one. */
|
|
530
|
+
export function recover(transcript, report, force = false) {
|
|
531
|
+
let text;
|
|
532
|
+
try {
|
|
533
|
+
text = fs.readFileSync(transcript, "utf8");
|
|
534
|
+
} catch (err) {
|
|
535
|
+
return { written: false, replaced: null, errors: [`${transcript}: ${err instanceof Error ? err.message : String(err)}`] };
|
|
536
|
+
}
|
|
537
|
+
const candidates = [];
|
|
538
|
+
for (const raw of text.split("\n")) {
|
|
539
|
+
if (raw.trim() === "") continue;
|
|
540
|
+
let obj;
|
|
541
|
+
try {
|
|
542
|
+
obj = JSON.parse(raw);
|
|
543
|
+
} catch {
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (!obj || typeof obj !== "object" || obj.type !== "assistant") continue;
|
|
547
|
+
candidates.push(...assistantCandidates(obj.message && obj.message.content));
|
|
548
|
+
}
|
|
549
|
+
let found = null;
|
|
550
|
+
for (let i = candidates.length - 1; i >= 0 && !found; i--) found = lastJsonBlock(candidates[i]);
|
|
551
|
+
if (!found) {
|
|
552
|
+
return { written: false, replaced: null, errors: [`${transcript}: no assistant message with a complete fenced json block`] };
|
|
553
|
+
}
|
|
554
|
+
let errors;
|
|
555
|
+
try {
|
|
556
|
+
errors = validateHandoff(JSON.parse(found.block));
|
|
557
|
+
} catch (err) {
|
|
558
|
+
errors = [`${transcript}: recovered block is not valid JSON: ${err instanceof Error ? err.message : String(err)}`];
|
|
559
|
+
}
|
|
560
|
+
const fenced = "```json\n" + found.block + "\n```";
|
|
561
|
+
const existing = fs.existsSync(report) ? fs.readFileSync(report, "utf8") : null;
|
|
562
|
+
const replaced = existing === null ? null : existingBlockInfo(existing);
|
|
563
|
+
if (replaced && replaced.valid && !force) {
|
|
564
|
+
return {
|
|
565
|
+
written: false,
|
|
566
|
+
replaced,
|
|
567
|
+
errors: [`${report}: existing handoff block (task ${replaced.task}, status ${replaced.status}) is valid; pass --force to replace it`],
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
fs.writeFileSync(report, spliceReport(existing, fenced, found.remaining));
|
|
572
|
+
} catch (err) {
|
|
573
|
+
return { written: false, replaced, errors: [`${report}: ${err instanceof Error ? err.message : String(err)}`] };
|
|
574
|
+
}
|
|
575
|
+
return { written: true, replaced, errors };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Brief paths from a directory (sorted `*.brief.md`) or as given. */
|
|
579
|
+
function collectBriefs(args) {
|
|
580
|
+
const out = [];
|
|
581
|
+
for (const a of args) {
|
|
582
|
+
if (fs.statSync(a).isDirectory()) {
|
|
583
|
+
for (const f of fs.readdirSync(a).sort()) if (f.endsWith(".brief.md")) out.push(path.join(a, f));
|
|
584
|
+
} else out.push(a);
|
|
585
|
+
}
|
|
586
|
+
return out;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Usage text: one line per subcommand with its arguments, in order. */
|
|
590
|
+
const USAGE = [
|
|
591
|
+
"usage: validate-handoff.mjs <subcommand> [args]",
|
|
592
|
+
" briefs <dir|file...>",
|
|
593
|
+
" handoff <file> [--base <sha>] [--brief <brief.md>] [--in-flight <brief.md>]... [--lead <path>]...",
|
|
594
|
+
" budget <ledger.jsonl> <task> [--ceiling <n>]",
|
|
595
|
+
" ledger <change-dir>",
|
|
596
|
+
" recover <transcript.jsonl> <report> [--force]",
|
|
597
|
+
" name <dispatch-name>",
|
|
598
|
+
].join("\n");
|
|
599
|
+
|
|
600
|
+
/** Splits a runner's options out of its arguments: `--name value` pairs of `allowed`, every
|
|
601
|
+
* repeated value kept, `valueless` switches recorded as `true`. A trailing valued option
|
|
602
|
+
* yields `undefined`; an unlisted option is the one `unknown option` error every subcommand
|
|
603
|
+
* prints. */
|
|
604
|
+
function parseFlags(rest, allowed, valueless = []) {
|
|
605
|
+
const values = {};
|
|
606
|
+
for (let i = 0; i < rest.length; i++) {
|
|
607
|
+
const flag = rest[i];
|
|
608
|
+
if (!values[flag]) values[flag] = [];
|
|
609
|
+
if (valueless.includes(flag)) {
|
|
610
|
+
values[flag].push(true);
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
if (!allowed.includes(flag)) return { values, error: `unknown option ${JSON.stringify(flag)}` };
|
|
614
|
+
values[flag].push(rest[i + 1]);
|
|
615
|
+
i++;
|
|
616
|
+
}
|
|
617
|
+
return { values, error: null };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** The last value given for an option; undefined when absent. */
|
|
621
|
+
function lastValue(values, flag) {
|
|
622
|
+
const list = values[flag];
|
|
623
|
+
return list ? list[list.length - 1] : undefined;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Prints one `FAIL` per problem, or `OK <label>`; returns the exit code. */
|
|
627
|
+
function report(errors, label) {
|
|
628
|
+
for (const e of errors) console.error(`FAIL ${e}`);
|
|
629
|
+
if (errors.length === 0) console.log(`OK ${label}`);
|
|
630
|
+
return errors.length === 0 ? 0 : 1;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Runs `briefs`: headers, digests, scope overlap; null without a path. */
|
|
634
|
+
function runBriefs(rest) {
|
|
635
|
+
if (rest.length < 1) return null;
|
|
636
|
+
return report(validateBriefs(collectBriefs(rest).map(readBrief)), rest.join(" "));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Runs `handoff`: the schema and, with `--base`, the diff guards. */
|
|
640
|
+
function runHandoff(rest) {
|
|
641
|
+
if (rest.length < 1) return null;
|
|
642
|
+
const file = rest[0];
|
|
643
|
+
const { values, error } = parseFlags(rest.slice(1), ["--base", "--brief", "--in-flight", "--lead"]);
|
|
644
|
+
if (error) {
|
|
645
|
+
console.error(`FAIL ${error}`);
|
|
646
|
+
return 1;
|
|
647
|
+
}
|
|
648
|
+
for (const [flag, list] of Object.entries(values)) {
|
|
649
|
+
if (list.some((v) => v === undefined)) {
|
|
650
|
+
console.error(`FAIL ${flag} requires a value`);
|
|
651
|
+
return 1;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const base = lastValue(values, "--base");
|
|
655
|
+
const briefFile = lastValue(values, "--brief");
|
|
656
|
+
const inFlight = values["--in-flight"] ?? [];
|
|
657
|
+
const leadPaths = values["--lead"] ?? [];
|
|
658
|
+
if (base !== undefined && base.startsWith("-")) {
|
|
659
|
+
console.error(`FAIL --base must not start with '-'; got ${JSON.stringify(base)}`);
|
|
660
|
+
return 1;
|
|
661
|
+
}
|
|
662
|
+
for (const l of leadPaths) {
|
|
663
|
+
if (l.startsWith("-")) {
|
|
664
|
+
console.error(`FAIL --lead must not start with '-'; got ${JSON.stringify(l)}`);
|
|
665
|
+
return 1;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
const normalizedLeadPaths = leadPaths.map((l) => path.posix.normalize(l));
|
|
669
|
+
let handoffObj = null;
|
|
670
|
+
let errors;
|
|
671
|
+
try {
|
|
672
|
+
handoffObj = readHandoff(file);
|
|
673
|
+
errors = validateHandoff(handoffObj);
|
|
674
|
+
} catch (err) {
|
|
675
|
+
errors = [err instanceof Error ? err.message : String(err)];
|
|
676
|
+
}
|
|
677
|
+
if (handoffObj && base !== undefined) {
|
|
678
|
+
try {
|
|
679
|
+
const changed = changedPaths(base, process.cwd());
|
|
680
|
+
const scope = briefFile !== undefined ? readBrief(briefFile).scope : null;
|
|
681
|
+
const peerScope = inFlight.flatMap((f) => readBrief(f).scope);
|
|
682
|
+
errors = errors.concat(guardHandoff(handoffObj, changed, scope, peerScope, normalizedLeadPaths));
|
|
683
|
+
} catch (err) {
|
|
684
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return report(errors, file);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Runs `budget`: a task's spend against the ceiling; null without both. */
|
|
691
|
+
function runBudget(rest) {
|
|
692
|
+
if (rest.length < 2) return null;
|
|
693
|
+
const [file, task, ...opts] = rest;
|
|
694
|
+
let ceiling = DEFAULT_CEILING;
|
|
695
|
+
const { values, error } = parseFlags(opts, ["--ceiling"]);
|
|
696
|
+
if (error) {
|
|
697
|
+
console.error(`FAIL ${error}`);
|
|
698
|
+
return 1;
|
|
699
|
+
}
|
|
700
|
+
if (values["--ceiling"]) {
|
|
701
|
+
const value = lastValue(values, "--ceiling");
|
|
702
|
+
if (!CEILING_RE.test(value ?? "")) {
|
|
703
|
+
console.error(`FAIL --ceiling must be a positive integer; got ${JSON.stringify(value ?? null)}`);
|
|
704
|
+
return 1;
|
|
705
|
+
}
|
|
706
|
+
ceiling = Number(value);
|
|
707
|
+
}
|
|
708
|
+
if (!fs.existsSync(file)) {
|
|
709
|
+
console.log(`NOTICE ledger ${file} absent; nothing to check`);
|
|
710
|
+
return 0;
|
|
711
|
+
}
|
|
712
|
+
const { lines, warnings } = readLedger(file);
|
|
713
|
+
for (const w of warnings) console.error(`WARN ${w}`);
|
|
714
|
+
const b = budget(lines, task, ceiling);
|
|
715
|
+
console.log(`task ${task}: dispatches ${b.dispatches}, total ${b.total}, reserve ${b.reserve}, ceiling ${ceiling}, remaining ${b.remaining}`);
|
|
716
|
+
console.log(b.over ? "FAIL ceiling reached" : "OK under ceiling");
|
|
717
|
+
return b.over ? 1 : 0;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Runs `ledger`: the per-task and totals summary; null without a directory. */
|
|
721
|
+
function runLedger(rest) {
|
|
722
|
+
if (rest.length < 1) return null;
|
|
723
|
+
const dir = rest[0];
|
|
724
|
+
const file = path.join(dir, "ledger.jsonl");
|
|
725
|
+
if (!fs.existsSync(file)) {
|
|
726
|
+
console.log(`NOTICE ledger ${file} absent; nothing to summarize`);
|
|
727
|
+
return 0;
|
|
728
|
+
}
|
|
729
|
+
const { tasks, totals, warnings, percentiles } = ledgerSummary(dir);
|
|
730
|
+
for (const w of warnings) console.error(`WARN ${w}`);
|
|
731
|
+
for (const id of Object.keys(tasks).sort(compareTaskIds)) {
|
|
732
|
+
const t = tasks[id];
|
|
733
|
+
console.log(
|
|
734
|
+
`task ${id}: impl ${t.impl}, review ${t.review}, fix ${t.fix}, other ${t.other}, resumed ${t.resumed}, cache_read ${t.cache_read}, max reviewers per round ${t.max_reviewers_per_round}, why-digest P0 ${t.why_digest_p0}`,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
console.log(
|
|
738
|
+
`totals: dispatches ${totals.dispatches}, cache_read ${totals.cache_read}, max reviewers per round ${totals.max_reviewers_per_round}, why-digest P0 ${totals.why_digest_p0}`,
|
|
739
|
+
);
|
|
740
|
+
const atOrOver = percentiles.atOrOver.length ? percentiles.atOrOver.join(", ") : "none";
|
|
741
|
+
console.log(
|
|
742
|
+
`percentiles: p50 ${percentiles.p50}, p75 ${percentiles.p75}, p90 ${percentiles.p90} (total plus reserve per task); at or over ${DEFAULT_CEILING}: ${atOrOver}`,
|
|
743
|
+
);
|
|
744
|
+
return 0;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** Reason a name fails `DISPATCH_NAME_RE` case-insensitively: the first of `role`, `dot`,
|
|
748
|
+
* `task`, `round` or `slug` to disagree with the accepted form, case differences included so a
|
|
749
|
+
* mismatch is never misattributed to `charset` (already ruled out by `parseDispatchName`'s gate). */
|
|
750
|
+
function diagnoseDispatchName(raw) {
|
|
751
|
+
const hyphenIdx = raw.indexOf("-");
|
|
752
|
+
const roleSeg = hyphenIdx === -1 ? raw : raw.slice(0, hyphenIdx);
|
|
753
|
+
if (!["impl", "review", "fix"].includes(roleSeg.toLowerCase())) return "role";
|
|
754
|
+
if (roleSeg !== roleSeg.toLowerCase()) return "role";
|
|
755
|
+
const rest = hyphenIdx === -1 ? "" : raw.slice(hyphenIdx + 1);
|
|
756
|
+
const tokens = rest.split("-");
|
|
757
|
+
const taskTok = tokens[0] ?? "";
|
|
758
|
+
if (taskTok.includes(".")) return "dot";
|
|
759
|
+
if (!/^(\d+(?:_\d+)*b?|final)$/i.test(taskTok)) return "task";
|
|
760
|
+
if (taskTok !== taskTok.toLowerCase()) return "task";
|
|
761
|
+
let idx = 1;
|
|
762
|
+
if (tokens[idx] !== undefined && /^r/i.test(tokens[idx])) {
|
|
763
|
+
if (!/^r\d+$/i.test(tokens[idx])) return "round";
|
|
764
|
+
if (tokens[idx] !== tokens[idx].toLowerCase()) return "round";
|
|
765
|
+
idx++;
|
|
766
|
+
}
|
|
767
|
+
if (idx < tokens.length) {
|
|
768
|
+
const slugTok = tokens.slice(idx).join("-");
|
|
769
|
+
if (!/^[a-z0-9-]+$/i.test(slugTok)) return "slug";
|
|
770
|
+
if (slugTok !== slugTok.toLowerCase()) return "slug";
|
|
771
|
+
}
|
|
772
|
+
return "charset";
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Parses a dispatch name against `<role>-<task>[-r<round>][-<slug>]`; `corrected` carries the
|
|
776
|
+
* dots-to-underscore, lowercased form when that form itself parses, else `null`. */
|
|
777
|
+
export function parseDispatchName(raw) {
|
|
778
|
+
if (typeof raw !== "string" || raw === "" || /\s/.test(raw) || !DISPATCH_NAME_CHARSET_RE.test(raw)) {
|
|
779
|
+
return { ok: false, reason: "charset", corrected: null };
|
|
780
|
+
}
|
|
781
|
+
const direct = DISPATCH_NAME_RE.exec(raw);
|
|
782
|
+
if (direct) {
|
|
783
|
+
return {
|
|
784
|
+
ok: true,
|
|
785
|
+
role: direct[1],
|
|
786
|
+
task: direct[2].replace(/_/g, "."),
|
|
787
|
+
round: direct[3] !== undefined ? Number(direct[3]) : null,
|
|
788
|
+
slug: direct[4] ?? null,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
const corrected = raw.replace(/\./g, "_").toLowerCase();
|
|
792
|
+
const correctedOk = corrected !== raw && DISPATCH_NAME_RE.test(corrected);
|
|
793
|
+
return { ok: false, reason: diagnoseDispatchName(raw), corrected: correctedOk ? corrected : null };
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Runs `recover`, naming the replaced block so no overwrite is silent. */
|
|
797
|
+
function runRecover(rest) {
|
|
798
|
+
if (rest.length < 2) return null;
|
|
799
|
+
const [transcript, reportPath, ...opts] = rest;
|
|
800
|
+
const { values, error } = parseFlags(opts, [], ["--force"]);
|
|
801
|
+
if (error) {
|
|
802
|
+
console.error(`FAIL ${error}`);
|
|
803
|
+
return 1;
|
|
804
|
+
}
|
|
805
|
+
const force = Boolean(values["--force"]);
|
|
806
|
+
const { written, errors, replaced } = recover(transcript, reportPath, force);
|
|
807
|
+
if (!written || errors.length > 0) {
|
|
808
|
+
for (const e of errors) console.error(`FAIL ${e}`);
|
|
809
|
+
return 1;
|
|
810
|
+
}
|
|
811
|
+
console.log(replaced ? `recovered ${reportPath} (replaced task ${replaced.task}, status ${replaced.status})` : `recovered ${reportPath}`);
|
|
812
|
+
return 0;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** Runs `name`: reports a dispatch name's validity; never refuses a dispatch itself. */
|
|
816
|
+
function runName(rest) {
|
|
817
|
+
if (rest.length < 1) return null;
|
|
818
|
+
const result = parseDispatchName(rest[0]);
|
|
819
|
+
if (result.ok) {
|
|
820
|
+
console.log(`OK role=${result.role} task=${result.task} round=${result.round === null ? "none" : result.round} slug=${result.slug === null ? "none" : result.slug}`);
|
|
821
|
+
return 0;
|
|
822
|
+
}
|
|
823
|
+
console.error(`FAIL ${result.reason}`);
|
|
824
|
+
if (result.corrected) console.log(`use ${result.corrected}`);
|
|
825
|
+
return 1;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Handlers in usage order; a `Map`, so an `Object.prototype` name resolves to none. */
|
|
829
|
+
const SUBCOMMANDS = new Map([
|
|
830
|
+
["briefs", runBriefs],
|
|
831
|
+
["handoff", runHandoff],
|
|
832
|
+
["budget", runBudget],
|
|
833
|
+
["ledger", runLedger],
|
|
834
|
+
["recover", runRecover],
|
|
835
|
+
["name", runName],
|
|
836
|
+
]);
|
|
837
|
+
|
|
838
|
+
/** CLI entry over `argv` after the script name; returns the exit code. */
|
|
839
|
+
export function main(argv) {
|
|
840
|
+
const [mode, ...rest] = argv;
|
|
841
|
+
const handler = SUBCOMMANDS.get(mode);
|
|
842
|
+
const code = handler ? handler(rest) : null;
|
|
843
|
+
if (code !== null) return code;
|
|
844
|
+
console.error(USAGE);
|
|
845
|
+
return 1;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === new URL(import.meta.url).pathname) {
|
|
849
|
+
process.exit(main(process.argv.slice(2)));
|
|
850
|
+
}
|