@rsaglobaltech/specgate 0.8.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/CHANGELOG.md +1016 -0
- package/README.md +148 -0
- package/bin/specgate.js +2 -0
- package/bin/specgate.ts +383 -0
- package/dist/bin/specgate.js +342 -0
- package/dist/bin/specgate.js.map +1 -0
- package/dist/packages/core/src/application/AddRequirementUseCase.js +24 -0
- package/dist/packages/core/src/application/AddRequirementUseCase.js.map +1 -0
- package/dist/packages/core/src/application/ArchiveChangeUseCase.js +185 -0
- package/dist/packages/core/src/application/ArchiveChangeUseCase.js.map +1 -0
- package/dist/packages/core/src/application/CheckAgainstLockUseCase.js +134 -0
- package/dist/packages/core/src/application/CheckAgainstLockUseCase.js.map +1 -0
- package/dist/packages/core/src/application/GenerateAgentPromptUseCase.js +25 -0
- package/dist/packages/core/src/application/GenerateAgentPromptUseCase.js.map +1 -0
- package/dist/packages/core/src/application/GeneratePlanUseCase.js +135 -0
- package/dist/packages/core/src/application/GeneratePlanUseCase.js.map +1 -0
- package/dist/packages/core/src/application/LinkRequirementUseCase.js +25 -0
- package/dist/packages/core/src/application/LinkRequirementUseCase.js.map +1 -0
- package/dist/packages/core/src/application/RemovePackUseCase.js +28 -0
- package/dist/packages/core/src/application/RemovePackUseCase.js.map +1 -0
- package/dist/packages/core/src/application/UpdateRequirementStatusUseCase.js +37 -0
- package/dist/packages/core/src/application/UpdateRequirementStatusUseCase.js.map +1 -0
- package/dist/packages/core/src/application/ValidateChangeUseCase.js +86 -0
- package/dist/packages/core/src/application/ValidateChangeUseCase.js.map +1 -0
- package/dist/packages/core/src/application/ValidateProjectUseCase.js +101 -0
- package/dist/packages/core/src/application/ValidateProjectUseCase.js.map +1 -0
- package/dist/packages/core/src/application/ports/IDomainPackRepository.js +3 -0
- package/dist/packages/core/src/application/ports/IDomainPackRepository.js.map +1 -0
- package/dist/packages/core/src/application/ports/IHarnessConfigRepository.js +3 -0
- package/dist/packages/core/src/application/ports/IHarnessConfigRepository.js.map +1 -0
- package/dist/packages/core/src/application/ports/ILockfileRepository.js +3 -0
- package/dist/packages/core/src/application/ports/ILockfileRepository.js.map +1 -0
- package/dist/packages/core/src/application/ports/IManifestRepository.js +3 -0
- package/dist/packages/core/src/application/ports/IManifestRepository.js.map +1 -0
- package/dist/packages/core/src/application/ports/IProjectRepository.js +3 -0
- package/dist/packages/core/src/application/ports/IProjectRepository.js.map +1 -0
- package/dist/packages/core/src/application/ports/IRequirementGraphRepository.js +3 -0
- package/dist/packages/core/src/application/ports/IRequirementGraphRepository.js.map +1 -0
- package/dist/packages/core/src/application/ports/ITraceabilityRepository.js +3 -0
- package/dist/packages/core/src/application/ports/ITraceabilityRepository.js.map +1 -0
- package/dist/packages/core/src/domain/AgentPrompt.js +79 -0
- package/dist/packages/core/src/domain/AgentPrompt.js.map +1 -0
- package/dist/packages/core/src/domain/ArchivePlan.js +26 -0
- package/dist/packages/core/src/domain/ArchivePlan.js.map +1 -0
- package/dist/packages/core/src/domain/AuthoringScope.js +48 -0
- package/dist/packages/core/src/domain/AuthoringScope.js.map +1 -0
- package/dist/packages/core/src/domain/ChangeTemplates.js +133 -0
- package/dist/packages/core/src/domain/ChangeTemplates.js.map +1 -0
- package/dist/packages/core/src/domain/CucumberMessages.js +256 -0
- package/dist/packages/core/src/domain/CucumberMessages.js.map +1 -0
- package/dist/packages/core/src/domain/DeclaredArtifacts.js +117 -0
- package/dist/packages/core/src/domain/DeclaredArtifacts.js.map +1 -0
- package/dist/packages/core/src/domain/DeltaSpec.js +219 -0
- package/dist/packages/core/src/domain/DeltaSpec.js.map +1 -0
- package/dist/packages/core/src/domain/Diagnostic.js +75 -0
- package/dist/packages/core/src/domain/Diagnostic.js.map +1 -0
- package/dist/packages/core/src/domain/DomainPack.js +62 -0
- package/dist/packages/core/src/domain/DomainPack.js.map +1 -0
- package/dist/packages/core/src/domain/EmptyAttempt.js +60 -0
- package/dist/packages/core/src/domain/EmptyAttempt.js.map +1 -0
- package/dist/packages/core/src/domain/FileChangeSet.js +37 -0
- package/dist/packages/core/src/domain/FileChangeSet.js.map +1 -0
- package/dist/packages/core/src/domain/Gherkin.js +272 -0
- package/dist/packages/core/src/domain/Gherkin.js.map +1 -0
- package/dist/packages/core/src/domain/GherkinDialects.js +97 -0
- package/dist/packages/core/src/domain/GherkinDialects.js.map +1 -0
- package/dist/packages/core/src/domain/GherkinQuality.js +160 -0
- package/dist/packages/core/src/domain/GherkinQuality.js.map +1 -0
- package/dist/packages/core/src/domain/GherkinTags.js +119 -0
- package/dist/packages/core/src/domain/GherkinTags.js.map +1 -0
- package/dist/packages/core/src/domain/GitSafety.js +77 -0
- package/dist/packages/core/src/domain/GitSafety.js.map +1 -0
- package/dist/packages/core/src/domain/HarnessConfig.js +58 -0
- package/dist/packages/core/src/domain/HarnessConfig.js.map +1 -0
- package/dist/packages/core/src/domain/HarnessReport.js +162 -0
- package/dist/packages/core/src/domain/HarnessReport.js.map +1 -0
- package/dist/packages/core/src/domain/HarnessRun.js +154 -0
- package/dist/packages/core/src/domain/HarnessRun.js.map +1 -0
- package/dist/packages/core/src/domain/InboundChange.js +127 -0
- package/dist/packages/core/src/domain/InboundChange.js.map +1 -0
- package/dist/packages/core/src/domain/Language.js +112 -0
- package/dist/packages/core/src/domain/Language.js.map +1 -0
- package/dist/packages/core/src/domain/Lockfile.js +64 -0
- package/dist/packages/core/src/domain/Lockfile.js.map +1 -0
- package/dist/packages/core/src/domain/PackContribution.js +106 -0
- package/dist/packages/core/src/domain/PackContribution.js.map +1 -0
- package/dist/packages/core/src/domain/PackDelta.js +298 -0
- package/dist/packages/core/src/domain/PackDelta.js.map +1 -0
- package/dist/packages/core/src/domain/PackSpec.js +481 -0
- package/dist/packages/core/src/domain/PackSpec.js.map +1 -0
- package/dist/packages/core/src/domain/ProfileMatch.js +87 -0
- package/dist/packages/core/src/domain/ProfileMatch.js.map +1 -0
- package/dist/packages/core/src/domain/Reconciliation.js +72 -0
- package/dist/packages/core/src/domain/Reconciliation.js.map +1 -0
- package/dist/packages/core/src/domain/RequirementGraph.js +197 -0
- package/dist/packages/core/src/domain/RequirementGraph.js.map +1 -0
- package/dist/packages/core/src/domain/RequirementPlan.js +85 -0
- package/dist/packages/core/src/domain/RequirementPlan.js.map +1 -0
- package/dist/packages/core/src/domain/RequirementReadiness.js +115 -0
- package/dist/packages/core/src/domain/RequirementReadiness.js.map +1 -0
- package/dist/packages/core/src/domain/RequirementSyntax.js +91 -0
- package/dist/packages/core/src/domain/RequirementSyntax.js.map +1 -0
- package/dist/packages/core/src/domain/ResumeState.js +115 -0
- package/dist/packages/core/src/domain/ResumeState.js.map +1 -0
- package/dist/packages/core/src/domain/RunBudget.js +103 -0
- package/dist/packages/core/src/domain/RunBudget.js.map +1 -0
- package/dist/packages/core/src/domain/SpecDiff.js +48 -0
- package/dist/packages/core/src/domain/SpecDiff.js.map +1 -0
- package/dist/packages/core/src/domain/SpecParser.js +406 -0
- package/dist/packages/core/src/domain/SpecParser.js.map +1 -0
- package/dist/packages/core/src/domain/SpecopsManifest.js +90 -0
- package/dist/packages/core/src/domain/SpecopsManifest.js.map +1 -0
- package/dist/packages/core/src/domain/TraceabilityFormat.js +317 -0
- package/dist/packages/core/src/domain/TraceabilityFormat.js.map +1 -0
- package/dist/packages/core/src/domain/TraceabilityMatrix.js +200 -0
- package/dist/packages/core/src/domain/TraceabilityMatrix.js.map +1 -0
- package/dist/packages/core/src/domain/TraceabilityMerge.js +180 -0
- package/dist/packages/core/src/domain/TraceabilityMerge.js.map +1 -0
- package/dist/packages/core/src/domain/ValidationReport.js +53 -0
- package/dist/packages/core/src/domain/ValidationReport.js.map +1 -0
- package/dist/packages/core/src/domain/ValueAnnotations.js +138 -0
- package/dist/packages/core/src/domain/ValueAnnotations.js.map +1 -0
- package/dist/packages/core/src/domain/WriteScope.js +155 -0
- package/dist/packages/core/src/domain/WriteScope.js.map +1 -0
- package/dist/packages/core/src/domain/YamlLite.js +226 -0
- package/dist/packages/core/src/domain/YamlLite.js.map +1 -0
- package/dist/packages/core/src/index.js +120 -0
- package/dist/packages/core/src/index.js.map +1 -0
- package/dist/packages/core/src/infrastructure/ChangeWorkspace.js +276 -0
- package/dist/packages/core/src/infrastructure/ChangeWorkspace.js.map +1 -0
- package/dist/packages/core/src/infrastructure/ConsoleReporter.js +19 -0
- package/dist/packages/core/src/infrastructure/ConsoleReporter.js.map +1 -0
- package/dist/packages/core/src/infrastructure/DirectorySnapshot.js +106 -0
- package/dist/packages/core/src/infrastructure/DirectorySnapshot.js.map +1 -0
- package/dist/packages/core/src/infrastructure/DiskDomainPackRepository.js +57 -0
- package/dist/packages/core/src/infrastructure/DiskDomainPackRepository.js.map +1 -0
- package/dist/packages/core/src/infrastructure/DiskHarnessConfigRepository.js +58 -0
- package/dist/packages/core/src/infrastructure/DiskHarnessConfigRepository.js.map +1 -0
- package/dist/packages/core/src/infrastructure/DiskLanguageRepository.js +69 -0
- package/dist/packages/core/src/infrastructure/DiskLanguageRepository.js.map +1 -0
- package/dist/packages/core/src/infrastructure/DiskLockfileRepository.js +64 -0
- package/dist/packages/core/src/infrastructure/DiskLockfileRepository.js.map +1 -0
- package/dist/packages/core/src/infrastructure/DiskManifestRepository.js +88 -0
- package/dist/packages/core/src/infrastructure/DiskManifestRepository.js.map +1 -0
- package/dist/packages/core/src/infrastructure/DiskPackDeltaRepository.js +138 -0
- package/dist/packages/core/src/infrastructure/DiskPackDeltaRepository.js.map +1 -0
- package/dist/packages/core/src/infrastructure/DiskPackRepository.js +130 -0
- package/dist/packages/core/src/infrastructure/DiskPackRepository.js.map +1 -0
- package/dist/packages/core/src/infrastructure/DiskProjectRepository.js +127 -0
- package/dist/packages/core/src/infrastructure/DiskProjectRepository.js.map +1 -0
- package/dist/packages/core/src/infrastructure/DiskRequirementGraphRepository.js +124 -0
- package/dist/packages/core/src/infrastructure/DiskRequirementGraphRepository.js.map +1 -0
- package/dist/packages/core/src/infrastructure/DiskTraceabilityRepository.js +53 -0
- package/dist/packages/core/src/infrastructure/DiskTraceabilityRepository.js.map +1 -0
- package/dist/packages/core/src/infrastructure/GitContributionStager.js +99 -0
- package/dist/packages/core/src/infrastructure/GitContributionStager.js.map +1 -0
- package/dist/packages/core/src/infrastructure/GitMergeDriver.js +103 -0
- package/dist/packages/core/src/infrastructure/GitMergeDriver.js.map +1 -0
- package/dist/packages/core/src/infrastructure/HarnessConfigFile.js +348 -0
- package/dist/packages/core/src/infrastructure/HarnessConfigFile.js.map +1 -0
- package/dist/packages/core/src/infrastructure/PackChangeDeposit.js +106 -0
- package/dist/packages/core/src/infrastructure/PackChangeDeposit.js.map +1 -0
- package/dist/packages/core/src/infrastructure/RemotePackResolver.js +181 -0
- package/dist/packages/core/src/infrastructure/RemotePackResolver.js.map +1 -0
- package/dist/packages/core/src/infrastructure/SpecopsConfigFile.js +94 -0
- package/dist/packages/core/src/infrastructure/SpecopsConfigFile.js.map +1 -0
- package/dist/packages/core/test/unit/authoring-scope.test.js +102 -0
- package/dist/packages/core/test/unit/authoring-scope.test.js.map +1 -0
- package/dist/packages/core/test/unit/change-templates.test.js +96 -0
- package/dist/packages/core/test/unit/change-templates.test.js.map +1 -0
- package/dist/packages/core/test/unit/cucumber-messages.test.js +232 -0
- package/dist/packages/core/test/unit/cucumber-messages.test.js.map +1 -0
- package/dist/packages/core/test/unit/declared-artifacts.test.js +124 -0
- package/dist/packages/core/test/unit/declared-artifacts.test.js.map +1 -0
- package/dist/packages/core/test/unit/empty-attempt.test.js +79 -0
- package/dist/packages/core/test/unit/empty-attempt.test.js.map +1 -0
- package/dist/packages/core/test/unit/gherkin-dialects.test.js +115 -0
- package/dist/packages/core/test/unit/gherkin-dialects.test.js.map +1 -0
- package/dist/packages/core/test/unit/gherkin-keyword-case.test.js +110 -0
- package/dist/packages/core/test/unit/gherkin-keyword-case.test.js.map +1 -0
- package/dist/packages/core/test/unit/gherkin-quality.test.js +151 -0
- package/dist/packages/core/test/unit/gherkin-quality.test.js.map +1 -0
- package/dist/packages/core/test/unit/gherkin-tags.test.js +127 -0
- package/dist/packages/core/test/unit/gherkin-tags.test.js.map +1 -0
- package/dist/packages/core/test/unit/git-safety.test.js +104 -0
- package/dist/packages/core/test/unit/git-safety.test.js.map +1 -0
- package/dist/packages/core/test/unit/harness-report.test.js +192 -0
- package/dist/packages/core/test/unit/harness-report.test.js.map +1 -0
- package/dist/packages/core/test/unit/harness-roles.test.js +86 -0
- package/dist/packages/core/test/unit/harness-roles.test.js.map +1 -0
- package/dist/packages/core/test/unit/inbound-change.test.js +145 -0
- package/dist/packages/core/test/unit/inbound-change.test.js.map +1 -0
- package/dist/packages/core/test/unit/matrix-dependencies.test.js +100 -0
- package/dist/packages/core/test/unit/matrix-dependencies.test.js.map +1 -0
- package/dist/packages/core/test/unit/profile-match.test.js +115 -0
- package/dist/packages/core/test/unit/profile-match.test.js.map +1 -0
- package/dist/packages/core/test/unit/reconciliation.test.js +112 -0
- package/dist/packages/core/test/unit/reconciliation.test.js.map +1 -0
- package/dist/packages/core/test/unit/requirement-readiness.test.js +161 -0
- package/dist/packages/core/test/unit/requirement-readiness.test.js.map +1 -0
- package/dist/packages/core/test/unit/resume-state.test.js +124 -0
- package/dist/packages/core/test/unit/resume-state.test.js.map +1 -0
- package/dist/packages/core/test/unit/run-budget.test.js +113 -0
- package/dist/packages/core/test/unit/run-budget.test.js.map +1 -0
- package/dist/packages/core/test/unit/traceability-merge.test.js +159 -0
- package/dist/packages/core/test/unit/traceability-merge.test.js.map +1 -0
- package/dist/packages/core/test/unit/validate-project.test.js +156 -0
- package/dist/packages/core/test/unit/validate-project.test.js.map +1 -0
- package/dist/packages/core/test/unit/value-annotations.test.js +149 -0
- package/dist/packages/core/test/unit/value-annotations.test.js.map +1 -0
- package/dist/packages/core/test/unit/write-scope.test.js +165 -0
- package/dist/packages/core/test/unit/write-scope.test.js.map +1 -0
- package/dist/packages/core/test/unit/yaml-lite.test.js +78 -0
- package/dist/packages/core/test/unit/yaml-lite.test.js.map +1 -0
- package/dist/scripts/adopt_project.js +11 -0
- package/dist/scripts/adopt_project.js.map +1 -0
- package/dist/scripts/agents/commands.js +100 -0
- package/dist/scripts/agents/commands.js.map +1 -0
- package/dist/scripts/agents/init.js +482 -0
- package/dist/scripts/agents/init.js.map +1 -0
- package/dist/scripts/alm/cli.js +154 -0
- package/dist/scripts/alm/cli.js.map +1 -0
- package/dist/scripts/alm/clients.js +30 -0
- package/dist/scripts/alm/clients.js.map +1 -0
- package/dist/scripts/alm/core.js +258 -0
- package/dist/scripts/alm/core.js.map +1 -0
- package/dist/scripts/alm/port.js +116 -0
- package/dist/scripts/alm/port.js.map +1 -0
- package/dist/scripts/alm/providers/azure.js +79 -0
- package/dist/scripts/alm/providers/azure.js.map +1 -0
- package/dist/scripts/alm/providers/github.js +124 -0
- package/dist/scripts/alm/providers/github.js.map +1 -0
- package/dist/scripts/alm/providers/http-client.js +47 -0
- package/dist/scripts/alm/providers/http-client.js.map +1 -0
- package/dist/scripts/alm/providers/index.js +107 -0
- package/dist/scripts/alm/providers/index.js.map +1 -0
- package/dist/scripts/alm/providers/jira.js +129 -0
- package/dist/scripts/alm/providers/jira.js.map +1 -0
- package/dist/scripts/bundle_pack.js +10 -0
- package/dist/scripts/bundle_pack.js.map +1 -0
- package/dist/scripts/change/archive.js +68 -0
- package/dist/scripts/change/archive.js.map +1 -0
- package/dist/scripts/change/artifacts.js +143 -0
- package/dist/scripts/change/artifacts.js.map +1 -0
- package/dist/scripts/change/cli.js +39 -0
- package/dist/scripts/change/cli.js.map +1 -0
- package/dist/scripts/change/delta.js +20 -0
- package/dist/scripts/change/delta.js.map +1 -0
- package/dist/scripts/change/instructions.js +339 -0
- package/dist/scripts/change/instructions.js.map +1 -0
- package/dist/scripts/ci_init.js +12 -0
- package/dist/scripts/ci_init.js.map +1 -0
- package/dist/scripts/cli/commands/alm/PullCommand.js +238 -0
- package/dist/scripts/cli/commands/alm/PullCommand.js.map +1 -0
- package/dist/scripts/cli/commands/change/ChangeCommand.js +867 -0
- package/dist/scripts/cli/commands/change/ChangeCommand.js.map +1 -0
- package/dist/scripts/cli/commands/config/CiInitCommand.js +144 -0
- package/dist/scripts/cli/commands/config/CiInitCommand.js.map +1 -0
- package/dist/scripts/cli/commands/config/CompletionCommand.js +160 -0
- package/dist/scripts/cli/commands/config/CompletionCommand.js.map +1 -0
- package/dist/scripts/cli/commands/config/ConfigInitCommand.js +108 -0
- package/dist/scripts/cli/commands/config/ConfigInitCommand.js.map +1 -0
- package/dist/scripts/cli/commands/config/ConfigSetCommand.js +166 -0
- package/dist/scripts/cli/commands/config/ConfigSetCommand.js.map +1 -0
- package/dist/scripts/cli/commands/config/GenAgentContractCommand.js +212 -0
- package/dist/scripts/cli/commands/config/GenAgentContractCommand.js.map +1 -0
- package/dist/scripts/cli/commands/harness/RunCommand.js +1922 -0
- package/dist/scripts/cli/commands/harness/RunCommand.js.map +1 -0
- package/dist/scripts/cli/commands/mcp/McpInstallCommand.js +114 -0
- package/dist/scripts/cli/commands/mcp/McpInstallCommand.js.map +1 -0
- package/dist/scripts/cli/commands/mcp/index.js +29 -0
- package/dist/scripts/cli/commands/mcp/index.js.map +1 -0
- package/dist/scripts/cli/commands/pack/BundlePackCommand.js +59 -0
- package/dist/scripts/cli/commands/pack/BundlePackCommand.js.map +1 -0
- package/dist/scripts/cli/commands/pack/ExpandDomainPackCommand.js +559 -0
- package/dist/scripts/cli/commands/pack/ExpandDomainPackCommand.js.map +1 -0
- package/dist/scripts/cli/commands/pack/InferPackCommand.js +304 -0
- package/dist/scripts/cli/commands/pack/InferPackCommand.js.map +1 -0
- package/dist/scripts/cli/commands/pack/InitPackCommand.js +368 -0
- package/dist/scripts/cli/commands/pack/InitPackCommand.js.map +1 -0
- package/dist/scripts/cli/commands/pack/LintPackCommand.js +631 -0
- package/dist/scripts/cli/commands/pack/LintPackCommand.js.map +1 -0
- package/dist/scripts/cli/commands/pack/pack-args.js +83 -0
- package/dist/scripts/cli/commands/pack/pack-args.js.map +1 -0
- package/dist/scripts/cli/commands/project/AdoptCommand.js +414 -0
- package/dist/scripts/cli/commands/project/AdoptCommand.js.map +1 -0
- package/dist/scripts/cli/commands/project/InitFromPackCommand.js +188 -0
- package/dist/scripts/cli/commands/project/InitFromPackCommand.js.map +1 -0
- package/dist/scripts/cli/commands/project/InitProjectCommand.js +791 -0
- package/dist/scripts/cli/commands/project/InitProjectCommand.js.map +1 -0
- package/dist/scripts/cli/commands/project/OnboardCommand.js +655 -0
- package/dist/scripts/cli/commands/project/OnboardCommand.js.map +1 -0
- package/dist/scripts/cli/commands/project/UpdateCommand.js +206 -0
- package/dist/scripts/cli/commands/project/UpdateCommand.js.map +1 -0
- package/dist/scripts/cli/commands/project/WizardCommand.js +166 -0
- package/dist/scripts/cli/commands/project/WizardCommand.js.map +1 -0
- package/dist/scripts/cli/commands/quality/DoctorCommand.js +553 -0
- package/dist/scripts/cli/commands/quality/DoctorCommand.js.map +1 -0
- package/dist/scripts/cli/commands/quality/FixCommand.js +201 -0
- package/dist/scripts/cli/commands/quality/FixCommand.js.map +1 -0
- package/dist/scripts/cli/commands/quality/LicenseCheckCommand.js +187 -0
- package/dist/scripts/cli/commands/quality/LicenseCheckCommand.js.map +1 -0
- package/dist/scripts/cli/commands/quality/ReportCommand.js +557 -0
- package/dist/scripts/cli/commands/quality/ReportCommand.js.map +1 -0
- package/dist/scripts/cli/commands/quality/ValidateSpecsCommand.js +764 -0
- package/dist/scripts/cli/commands/quality/ValidateSpecsCommand.js.map +1 -0
- package/dist/scripts/cli/commands/spec/DoneCommand.js +116 -0
- package/dist/scripts/cli/commands/spec/DoneCommand.js.map +1 -0
- package/dist/scripts/cli/commands/spec/PlanCommand.js +397 -0
- package/dist/scripts/cli/commands/spec/PlanCommand.js.map +1 -0
- package/dist/scripts/cli/commands/spec/ReqCommand.js +303 -0
- package/dist/scripts/cli/commands/spec/ReqCommand.js.map +1 -0
- package/dist/scripts/cli/commands/spec/StatusCommand.js +206 -0
- package/dist/scripts/cli/commands/spec/StatusCommand.js.map +1 -0
- package/dist/scripts/cli/commands/spec/StudioCommand.js +241 -0
- package/dist/scripts/cli/commands/spec/StudioCommand.js.map +1 -0
- package/dist/scripts/cli/commands/specops/ContributeCommand.js +313 -0
- package/dist/scripts/cli/commands/specops/ContributeCommand.js.map +1 -0
- package/dist/scripts/cli/commands/specops/DiffCommand.js +394 -0
- package/dist/scripts/cli/commands/specops/DiffCommand.js.map +1 -0
- package/dist/scripts/cli/commands/specops/SyncCommand.js +380 -0
- package/dist/scripts/cli/commands/specops/SyncCommand.js.map +1 -0
- package/dist/scripts/completion.js +24 -0
- package/dist/scripts/completion.js.map +1 -0
- package/dist/scripts/config_init.js +12 -0
- package/dist/scripts/config_init.js.map +1 -0
- package/dist/scripts/config_set.js +20 -0
- package/dist/scripts/config_set.js.map +1 -0
- package/dist/scripts/docs/blocks.js +137 -0
- package/dist/scripts/docs/blocks.js.map +1 -0
- package/dist/scripts/docs/build-site.js +425 -0
- package/dist/scripts/docs/build-site.js.map +1 -0
- package/dist/scripts/docs/nav.js +198 -0
- package/dist/scripts/docs/nav.js.map +1 -0
- package/dist/scripts/docs/record-terminal.js +477 -0
- package/dist/scripts/docs/record-terminal.js.map +1 -0
- package/dist/scripts/docs/terminal-storyboard.js +121 -0
- package/dist/scripts/docs/terminal-storyboard.js.map +1 -0
- package/dist/scripts/doctor.js +14 -0
- package/dist/scripts/doctor.js.map +1 -0
- package/dist/scripts/done.js +17 -0
- package/dist/scripts/done.js.map +1 -0
- package/dist/scripts/expand_domain_pack.js +18 -0
- package/dist/scripts/expand_domain_pack.js.map +1 -0
- package/dist/scripts/fix.js +12 -0
- package/dist/scripts/fix.js.map +1 -0
- package/dist/scripts/gen_agent_contract.js +14 -0
- package/dist/scripts/gen_agent_contract.js.map +1 -0
- package/dist/scripts/gen_mcp_tools.js +264 -0
- package/dist/scripts/gen_mcp_tools.js.map +1 -0
- package/dist/scripts/harness/init.js +358 -0
- package/dist/scripts/harness/init.js.map +1 -0
- package/dist/scripts/harness/prompt.js +18 -0
- package/dist/scripts/harness/prompt.js.map +1 -0
- package/dist/scripts/harness/report.js +323 -0
- package/dist/scripts/harness/report.js.map +1 -0
- package/dist/scripts/harness/run.js +33 -0
- package/dist/scripts/harness/run.js.map +1 -0
- package/dist/scripts/infer_pack.js +17 -0
- package/dist/scripts/infer_pack.js.map +1 -0
- package/dist/scripts/init_from_pack.js +16 -0
- package/dist/scripts/init_from_pack.js.map +1 -0
- package/dist/scripts/init_pack.js +16 -0
- package/dist/scripts/init_pack.js.map +1 -0
- package/dist/scripts/init_project.js +17 -0
- package/dist/scripts/init_project.js.map +1 -0
- package/dist/scripts/lib/agent.js +99 -0
- package/dist/scripts/lib/agent.js.map +1 -0
- package/dist/scripts/lib/capability-specs.js +80 -0
- package/dist/scripts/lib/capability-specs.js.map +1 -0
- package/dist/scripts/lib/command.js +17 -0
- package/dist/scripts/lib/command.js.map +1 -0
- package/dist/scripts/lib/diagnostics.js +62 -0
- package/dist/scripts/lib/diagnostics.js.map +1 -0
- package/dist/scripts/lib/monorepo-fanout.js +118 -0
- package/dist/scripts/lib/monorepo-fanout.js.map +1 -0
- package/dist/scripts/lib/placeholders.js +190 -0
- package/dist/scripts/lib/placeholders.js.map +1 -0
- package/dist/scripts/lib/project-root.js +124 -0
- package/dist/scripts/lib/project-root.js.map +1 -0
- package/dist/scripts/lib/requirement-graph.js +31 -0
- package/dist/scripts/lib/requirement-graph.js.map +1 -0
- package/dist/scripts/lib/surface.js +609 -0
- package/dist/scripts/lib/surface.js.map +1 -0
- package/dist/scripts/license_check.js +15 -0
- package/dist/scripts/license_check.js.map +1 -0
- package/dist/scripts/lint_pack.js +18 -0
- package/dist/scripts/lint_pack.js.map +1 -0
- package/dist/scripts/merge-traceability.js +106 -0
- package/dist/scripts/merge-traceability.js.map +1 -0
- package/dist/scripts/onboard.js +22 -0
- package/dist/scripts/onboard.js.map +1 -0
- package/dist/scripts/plan.js +17 -0
- package/dist/scripts/plan.js.map +1 -0
- package/dist/scripts/plugin/gate-hook.js +171 -0
- package/dist/scripts/plugin/gate-hook.js.map +1 -0
- package/dist/scripts/report.js +17 -0
- package/dist/scripts/report.js.map +1 -0
- package/dist/scripts/req.js +22 -0
- package/dist/scripts/req.js.map +1 -0
- package/dist/scripts/schema/cli.js +215 -0
- package/dist/scripts/schema/cli.js.map +1 -0
- package/dist/scripts/schema/registry.js +264 -0
- package/dist/scripts/schema/registry.js.map +1 -0
- package/dist/scripts/specops/add.js +180 -0
- package/dist/scripts/specops/add.js.map +1 -0
- package/dist/scripts/specops/against_lock.js +100 -0
- package/dist/scripts/specops/against_lock.js.map +1 -0
- package/dist/scripts/specops/as_change.js +49 -0
- package/dist/scripts/specops/as_change.js.map +1 -0
- package/dist/scripts/specops/contribute.js +24 -0
- package/dist/scripts/specops/contribute.js.map +1 -0
- package/dist/scripts/specops/diff.js +32 -0
- package/dist/scripts/specops/diff.js.map +1 -0
- package/dist/scripts/specops/lock.js +39 -0
- package/dist/scripts/specops/lock.js.map +1 -0
- package/dist/scripts/specops/manifest.js +90 -0
- package/dist/scripts/specops/manifest.js.map +1 -0
- package/dist/scripts/specops/remove.js +102 -0
- package/dist/scripts/specops/remove.js.map +1 -0
- package/dist/scripts/specops/sync.js +27 -0
- package/dist/scripts/specops/sync.js.map +1 -0
- package/dist/scripts/specops/verify.js +149 -0
- package/dist/scripts/specops/verify.js.map +1 -0
- package/dist/scripts/status.js +17 -0
- package/dist/scripts/status.js.map +1 -0
- package/dist/scripts/studio.js +14 -0
- package/dist/scripts/studio.js.map +1 -0
- package/dist/scripts/update.js +17 -0
- package/dist/scripts/update.js.map +1 -0
- package/dist/scripts/validate_specs.js +10 -0
- package/dist/scripts/validate_specs.js.map +1 -0
- package/examples/project.config.example +19 -0
- package/examples/project.yaml.example +17 -0
- package/package.json +105 -0
- package/templates/adopt/AI_RULES.md.tpl +27 -0
- package/templates/adopt/adr-readme.md.tpl +5 -0
- package/templates/adopt/baseline.feature.tpl +9 -0
- package/templates/adopt/spec.md.tpl +25 -0
- package/templates/adopt/traceability.md.tpl +11 -0
- package/templates/backend/AI_RULES.md.tpl +35 -0
- package/templates/backend/features/core/health.feature.tpl +7 -0
- package/templates/base/.devcontainer/devcontainer.json.tpl +18 -0
- package/templates/base/.dockerignore.tpl +11 -0
- package/templates/base/.env.dev.app.tpl +13 -0
- package/templates/base/.env.dev.infra.tpl +6 -0
- package/templates/base/.env.dev.tpl +16 -0
- package/templates/base/.env.example.tpl +17 -0
- package/templates/base/.env.feature.app.tpl +11 -0
- package/templates/base/.env.feature.infra.tpl +4 -0
- package/templates/base/.env.feature.tpl +16 -0
- package/templates/base/.env.prod.app.tpl +13 -0
- package/templates/base/.env.prod.infra.tpl +6 -0
- package/templates/base/.env.prod.tpl +16 -0
- package/templates/base/.github/workflows/specs.yml.tpl +26 -0
- package/templates/base/.gitignore.tpl +9 -0
- package/templates/base/.pre-commit-config.yaml.tpl +10 -0
- package/templates/base/AGENTS.md.tpl +32 -0
- package/templates/base/README.md.tpl +32 -0
- package/templates/base/docker-compose.yml.tpl +13 -0
- package/templates/base/docs/specs/adr/README.md.tpl +10 -0
- package/templates/base/docs/specs/aggregates.md.tpl +5 -0
- package/templates/base/docs/specs/commands.md.tpl +13 -0
- package/templates/base/docs/specs/domain-model.md.tpl +30 -0
- package/templates/base/docs/specs/events.md.tpl +5 -0
- package/templates/base/docs/specs/review-checklist.md.tpl +18 -0
- package/templates/base/docs/specs/runtime-environments.md.tpl +26 -0
- package/templates/base/docs/specs/status-model.md.tpl +16 -0
- package/templates/base/docs/specs/traceability.md.tpl +7 -0
- package/templates/base/docs/specs/use-cases.md.tpl +5 -0
- package/templates/base/spec.md.tpl +63 -0
- package/templates/ci/azure.yml.tpl +40 -0
- package/templates/ci/github.yml.tpl +40 -0
- package/templates/ci/gitlab.yml.tpl +21 -0
- package/templates/ci/jenkins.tpl +35 -0
- package/templates/contracts/AI_RULES.md.tpl +55 -0
- package/templates/contracts/asyncapi-stub.yaml.tpl +24 -0
- package/templates/contracts/contract-traceability.md.tpl +10 -0
- package/templates/contracts/openapi-stub.yaml.tpl +29 -0
- package/templates/contracts/pacts-gitkeep.tpl +0 -0
- package/templates/contracts/test-strategy.md.tpl +94 -0
- package/templates/frontend/AI_RULES.md.tpl +34 -0
- package/templates/frontend/features/core/health.feature.tpl +6 -0
- package/templates/harness/harness.config.yaml.tpl +74 -0
- package/templates/harness/prompt-prefix.md.tpl +60 -0
- package/templates/mobile/AI_RULES.md.tpl +56 -0
- package/templates/mobile/features/core/health.feature.tpl +7 -0
- package/templates/modules/auth/backend/registration_and_login.feature.tpl +10 -0
- package/templates/modules/auth/frontend/login.feature.tpl +5 -0
- package/templates/modules/billing/backend/billing_settlement.feature.tpl +5 -0
- package/templates/modules/billing/frontend/billing_summary.feature.tpl +5 -0
- package/templates/modules/dashboard/backend/dashboard_kpi.feature.tpl +5 -0
- package/templates/modules/dashboard/frontend/dashboard.feature.tpl +5 -0
- package/vendor/mermaid.min.js +3636 -0
|
@@ -0,0 +1,1922 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* `specgate harness run` — the spec-driven delivery loop for AI coding agents.
|
|
5
|
+
*
|
|
6
|
+
* A spec-driven repo is already an environment for an agent: `plan` is the
|
|
7
|
+
* task queue, the feature file + AI_RULES.md are the per-task context,
|
|
8
|
+
* `validate --strict-tdd` + the project test command are the reward signal,
|
|
9
|
+
* and `done` is the state transition. This command is the missing
|
|
10
|
+
* orchestration layer — it runs plan → context → agent → verify → done
|
|
11
|
+
* without a human copy-pasting prompts.
|
|
12
|
+
*
|
|
13
|
+
* For each pending requirement, in an isolated `git worktree` on a fresh
|
|
14
|
+
* `harness/REQ-NNN` branch:
|
|
15
|
+
* 1. Build a self-contained prompt (Gherkin + AI_RULES + paths + retry feedback).
|
|
16
|
+
* 2. Shell out to the user-configured agent ({prompt_file} placeholder).
|
|
17
|
+
* 3. Gate: `validate --strict-tdd` + the project test command.
|
|
18
|
+
* 4. Green → `done REQ-NNN` + commit. Red → retry N times feeding the failure.
|
|
19
|
+
* 5. Emit a pass/fail/attempts report.
|
|
20
|
+
*
|
|
21
|
+
* Vendor-neutral by construction: the agent is any shell command. The
|
|
22
|
+
* harness never merges a branch — a human reviews and merges.
|
|
23
|
+
*/
|
|
24
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
25
|
+
if (k2 === undefined) k2 = k;
|
|
26
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
27
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
28
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
29
|
+
}
|
|
30
|
+
Object.defineProperty(o, k2, desc);
|
|
31
|
+
}) : (function(o, m, k, k2) {
|
|
32
|
+
if (k2 === undefined) k2 = k;
|
|
33
|
+
o[k2] = m[k];
|
|
34
|
+
}));
|
|
35
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
36
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
37
|
+
}) : function(o, v) {
|
|
38
|
+
o["default"] = v;
|
|
39
|
+
});
|
|
40
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
41
|
+
var ownKeys = function(o) {
|
|
42
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
43
|
+
var ar = [];
|
|
44
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
45
|
+
return ar;
|
|
46
|
+
};
|
|
47
|
+
return ownKeys(o);
|
|
48
|
+
};
|
|
49
|
+
return function (mod) {
|
|
50
|
+
if (mod && mod.__esModule) return mod;
|
|
51
|
+
var result = {};
|
|
52
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
53
|
+
__setModuleDefault(result, mod);
|
|
54
|
+
return result;
|
|
55
|
+
};
|
|
56
|
+
})();
|
|
57
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
58
|
+
exports.RunCommand = exports.RUNS_DIR = void 0;
|
|
59
|
+
exports.setJsonMode = setJsonMode;
|
|
60
|
+
exports.error = error;
|
|
61
|
+
exports.parseArgs = parseArgs;
|
|
62
|
+
exports.printReport = printReport;
|
|
63
|
+
exports.runLevels = runLevels;
|
|
64
|
+
const fs = __importStar(require("node:fs"));
|
|
65
|
+
const os = __importStar(require("node:os"));
|
|
66
|
+
const path = __importStar(require("node:path"));
|
|
67
|
+
const crypto = __importStar(require("node:crypto"));
|
|
68
|
+
const node_child_process_1 = require("node:child_process");
|
|
69
|
+
const command_1 = require("../../../lib/command");
|
|
70
|
+
const project_root_1 = require("../../../lib/project-root");
|
|
71
|
+
const prompt_1 = require("../../../harness/prompt");
|
|
72
|
+
const GherkinQuality_1 = require("../../../../packages/core/src/domain/GherkinQuality");
|
|
73
|
+
const HarnessConfigFile_1 = require("../../../../packages/core/src/infrastructure/HarnessConfigFile");
|
|
74
|
+
const agent_1 = require("../../../lib/agent");
|
|
75
|
+
const WriteScope_1 = require("../../../../packages/core/src/domain/WriteScope");
|
|
76
|
+
const EmptyAttempt_1 = require("../../../../packages/core/src/domain/EmptyAttempt");
|
|
77
|
+
const DeclaredArtifacts_1 = require("../../../../packages/core/src/domain/DeclaredArtifacts");
|
|
78
|
+
const ResumeState_1 = require("../../../../packages/core/src/domain/ResumeState");
|
|
79
|
+
const RequirementReadiness_1 = require("../../../../packages/core/src/domain/RequirementReadiness");
|
|
80
|
+
const ProfileMatch_1 = require("../../../../packages/core/src/domain/ProfileMatch");
|
|
81
|
+
const TraceabilityFormat_1 = require("../../../../packages/core/src/domain/TraceabilityFormat");
|
|
82
|
+
const RequirementPlan_1 = require("../../../../packages/core/src/domain/RequirementPlan");
|
|
83
|
+
const RunBudget_1 = require("../../../../packages/core/src/domain/RunBudget");
|
|
84
|
+
const CucumberMessages_1 = require("../../../../packages/core/src/domain/CucumberMessages");
|
|
85
|
+
const HarnessRun_1 = require("../../../../packages/core/src/domain/HarnessRun");
|
|
86
|
+
// Three levels up from dist/scripts/cli/commands/harness is dist/scripts,
|
|
87
|
+
// where the command entry points live.
|
|
88
|
+
const SCRIPTS_DIR = path.join(__dirname, "..", "..", "..");
|
|
89
|
+
const PLAN_SCRIPT = path.join(SCRIPTS_DIR, "plan.js");
|
|
90
|
+
const DONE_SCRIPT = path.join(SCRIPTS_DIR, "done.js");
|
|
91
|
+
const VALIDATE_SCRIPT = path.join(SCRIPTS_DIR, "validate_specs.js");
|
|
92
|
+
/**
|
|
93
|
+
* What a worker process runs.
|
|
94
|
+
*
|
|
95
|
+
* It must be the file the command registry spawns, not this module: this one
|
|
96
|
+
* defines the command but does not execute it, so pointing a worker at
|
|
97
|
+
* `__filename` starts a process that loads, does nothing and exits 0 — which
|
|
98
|
+
* the parent reports as "Worker produced no report".
|
|
99
|
+
*/
|
|
100
|
+
const WORKER_ENTRY = path.join(SCRIPTS_DIR, "harness", "run.js");
|
|
101
|
+
/**
|
|
102
|
+
* In `--format json` mode stdout carries exactly one JSON document and nothing
|
|
103
|
+
* else — rule 1 of the agent contract (ADR-0017). It did not: progress lines
|
|
104
|
+
* went to stdout alongside the report, so
|
|
105
|
+
* `harness run --format json 2>/dev/null | jq .` did not parse. Nobody noticed
|
|
106
|
+
* because nothing parsed it until the worker pool did.
|
|
107
|
+
*/
|
|
108
|
+
let jsonMode = false;
|
|
109
|
+
function setJsonMode(on) {
|
|
110
|
+
jsonMode = Boolean(on);
|
|
111
|
+
}
|
|
112
|
+
function info(msg) {
|
|
113
|
+
(jsonMode ? process.stderr : process.stdout).write(`ℹ️ [harness] ${msg}\n`);
|
|
114
|
+
}
|
|
115
|
+
function warn(msg) {
|
|
116
|
+
(jsonMode ? process.stderr : process.stdout).write(`⚠️ [harness] ${msg}\n`);
|
|
117
|
+
}
|
|
118
|
+
function error(msg) {
|
|
119
|
+
process.stderr.write(`❌ [harness] ${msg}\n`);
|
|
120
|
+
}
|
|
121
|
+
function usage() {
|
|
122
|
+
process.stdout.write("Usage:\n" +
|
|
123
|
+
" specgate harness run [options]\n\n" +
|
|
124
|
+
"Runs the plan → agent → verify → done loop for every pending requirement.\n\n" +
|
|
125
|
+
" --agent <cmd> Agent command; must contain the {prompt_file} placeholder.\n" +
|
|
126
|
+
' e.g. --agent "claude -p < {prompt_file}"\n' +
|
|
127
|
+
" --test-cmd <cmd> Project test command run as part of the gate (optional).\n" +
|
|
128
|
+
" Substitutes {req}, {scenario} and {feature_file}, so the\n" +
|
|
129
|
+
" gate can run the scenario under test.\n" +
|
|
130
|
+
" --max-attempts <n> Retries per requirement, feeding back the failure (default 3).\n" +
|
|
131
|
+
" --concurrency <n> Requirements in flight at once (default 1). Only requirements\n" +
|
|
132
|
+
" that do not depend on each other ever run together.\n" +
|
|
133
|
+
" --req <REQ-NNN> Limit to specific requirement(s); repeatable.\n" +
|
|
134
|
+
" --project-dir <path> Project root (auto-detected from cwd if omitted).\n" +
|
|
135
|
+
" --base-branch <ref> Branch/ref each worktree is cut from (default: current HEAD).\n" +
|
|
136
|
+
" --timeout <seconds> Per-agent-invocation timeout (default 1200).\n" +
|
|
137
|
+
" --keep-worktrees Do not remove worktrees after each requirement.\n" +
|
|
138
|
+
" --force Recreate harness/REQ-NNN branches that already exist.\n" +
|
|
139
|
+
" --format <text|json> Report format (default text).\n" +
|
|
140
|
+
" --dry-run Build prompts and print them; never invoke the agent.\n\n" +
|
|
141
|
+
"CI mode (unattended runners — a nightly job that leaves PRs to review):\n" +
|
|
142
|
+
" --resume Continue an interrupted run: reuse the existing\n" +
|
|
143
|
+
" harness/REQ-NNN branch and worktree instead of\n" +
|
|
144
|
+
" skipping it, and pick up at the attempt it stopped on.\n" +
|
|
145
|
+
" --budget-seconds <n> Wall-clock ceiling for the whole run. On reaching it\n" +
|
|
146
|
+
" the run stops cleanly and still reports what it did.\n" +
|
|
147
|
+
" --max-requirements <n> Attempt at most n requirements in this run.\n" +
|
|
148
|
+
" --skip-not-ready Skip requirements an agent could not succeed at — no\n" +
|
|
149
|
+
" feature, an unrunnable scenario, unmet dependencies,\n" +
|
|
150
|
+
" Deprecated or Needs Clarification. Default: warn and\n" +
|
|
151
|
+
" run them anyway.\n" +
|
|
152
|
+
" --strict-artifacts Fail an attempt whose diff never touches the paths the\n" +
|
|
153
|
+
" matrix declares for the requirement (default: warn).\n" +
|
|
154
|
+
" --push Push each green harness/REQ-NNN branch to the remote.\n" +
|
|
155
|
+
" --remote <name> Remote to push to (default origin).\n" +
|
|
156
|
+
" --pr-cmd <cmd> Command run after a successful push, with {branch} and\n" +
|
|
157
|
+
' {req} placeholders. e.g. --pr-cmd "gh pr create --head {branch} \\\n' +
|
|
158
|
+
" --title '{req} via harness' --fill\"\n\n" +
|
|
159
|
+
"`--agent`, `--test-cmd`, `push`, `remote` and `pr_cmd` may also be set in harness.config.yaml.\n");
|
|
160
|
+
}
|
|
161
|
+
function parseArgs(argv) {
|
|
162
|
+
const args = {
|
|
163
|
+
projectDir: ".",
|
|
164
|
+
agent: "",
|
|
165
|
+
testCmd: "",
|
|
166
|
+
maxAttempts: 0,
|
|
167
|
+
concurrency: 0,
|
|
168
|
+
reqs: [],
|
|
169
|
+
baseBranch: "",
|
|
170
|
+
// 600 was the original guess. Both real runs disproved it: the first
|
|
171
|
+
// REQ-001 attempt hit 900s while the agent installed dependencies and
|
|
172
|
+
// worked, and 1500 was needed comfortably. A default that times out on
|
|
173
|
+
// ordinary work turns every first attempt into a wasted one.
|
|
174
|
+
timeout: 1200,
|
|
175
|
+
keepWorktrees: false,
|
|
176
|
+
force: false,
|
|
177
|
+
resume: false,
|
|
178
|
+
format: "text",
|
|
179
|
+
dryRun: false,
|
|
180
|
+
strictArtifacts: false,
|
|
181
|
+
skipNotReady: false,
|
|
182
|
+
budgetSeconds: 0,
|
|
183
|
+
maxRequirements: 0,
|
|
184
|
+
push: false,
|
|
185
|
+
remote: "",
|
|
186
|
+
prCmd: "",
|
|
187
|
+
};
|
|
188
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
189
|
+
const token = argv[i];
|
|
190
|
+
if (token === "--project-dir") {
|
|
191
|
+
args.projectDir = argv[++i] || "";
|
|
192
|
+
}
|
|
193
|
+
else if (token === "--agent") {
|
|
194
|
+
args.agent = argv[++i] || "";
|
|
195
|
+
}
|
|
196
|
+
else if (token === "--test-cmd") {
|
|
197
|
+
args.testCmd = argv[++i] || "";
|
|
198
|
+
}
|
|
199
|
+
else if (token === "--max-attempts") {
|
|
200
|
+
const n = Number(argv[++i]);
|
|
201
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
202
|
+
throw new Error("--max-attempts must be a positive integer");
|
|
203
|
+
}
|
|
204
|
+
args.maxAttempts = n;
|
|
205
|
+
}
|
|
206
|
+
else if (token === "--concurrency") {
|
|
207
|
+
const n = Number(argv[++i]);
|
|
208
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
209
|
+
throw new Error("--concurrency must be a positive integer");
|
|
210
|
+
}
|
|
211
|
+
args.concurrency = n;
|
|
212
|
+
}
|
|
213
|
+
else if (token === "--req") {
|
|
214
|
+
const r = argv[++i] || "";
|
|
215
|
+
if (!/^REQ-\d+$/.test(r))
|
|
216
|
+
throw new Error(`--req expects REQ-NNN, got: ${r}`);
|
|
217
|
+
args.reqs.push(r);
|
|
218
|
+
}
|
|
219
|
+
else if (token === "--base-branch") {
|
|
220
|
+
args.baseBranch = argv[++i] || "";
|
|
221
|
+
}
|
|
222
|
+
else if (token === "--timeout") {
|
|
223
|
+
const n = Number(argv[++i]);
|
|
224
|
+
if (!Number.isInteger(n) || n < 1)
|
|
225
|
+
throw new Error("--timeout must be a positive integer");
|
|
226
|
+
args.timeout = n;
|
|
227
|
+
}
|
|
228
|
+
else if (token === "--push") {
|
|
229
|
+
args.push = true;
|
|
230
|
+
}
|
|
231
|
+
else if (token === "--remote") {
|
|
232
|
+
args.remote = argv[++i] || "";
|
|
233
|
+
}
|
|
234
|
+
else if (token === "--pr-cmd") {
|
|
235
|
+
args.prCmd = argv[++i] || "";
|
|
236
|
+
}
|
|
237
|
+
else if (token === "--keep-worktrees") {
|
|
238
|
+
args.keepWorktrees = true;
|
|
239
|
+
}
|
|
240
|
+
else if (token === "--force") {
|
|
241
|
+
args.force = true;
|
|
242
|
+
}
|
|
243
|
+
else if (token === "--resume") {
|
|
244
|
+
args.resume = true;
|
|
245
|
+
}
|
|
246
|
+
else if (token === "--format") {
|
|
247
|
+
args.format = argv[++i] || "";
|
|
248
|
+
}
|
|
249
|
+
else if (token === "--dry-run") {
|
|
250
|
+
args.dryRun = true;
|
|
251
|
+
}
|
|
252
|
+
else if (token === "--strict-artifacts") {
|
|
253
|
+
args.strictArtifacts = true;
|
|
254
|
+
}
|
|
255
|
+
else if (token === "--skip-not-ready") {
|
|
256
|
+
args.skipNotReady = true;
|
|
257
|
+
}
|
|
258
|
+
else if (token === "--budget-seconds") {
|
|
259
|
+
const n = Number(argv[++i]);
|
|
260
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
261
|
+
throw new Error("--budget-seconds must be a positive integer");
|
|
262
|
+
}
|
|
263
|
+
args.budgetSeconds = n;
|
|
264
|
+
}
|
|
265
|
+
else if (token === "--max-requirements") {
|
|
266
|
+
const n = Number(argv[++i]);
|
|
267
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
268
|
+
throw new Error("--max-requirements must be a positive integer");
|
|
269
|
+
}
|
|
270
|
+
args.maxRequirements = n;
|
|
271
|
+
}
|
|
272
|
+
else if (token === "--help" || token === "-h") {
|
|
273
|
+
usage();
|
|
274
|
+
process.exit(0);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
throw new Error(`Unknown argument: ${token}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// `--force` throws the branch away; `--resume` continues it. Asking for both
|
|
281
|
+
// is asking for two opposite things, and silently picking one is how work
|
|
282
|
+
// gets deleted after an interruption — the exact loss --resume exists to stop.
|
|
283
|
+
if (args.force && args.resume) {
|
|
284
|
+
throw new Error("--force and --resume are opposites: --force deletes the existing branch, " +
|
|
285
|
+
"--resume continues it. Pick one.");
|
|
286
|
+
}
|
|
287
|
+
if (!["text", "json"].includes(args.format)) {
|
|
288
|
+
throw new Error(`Invalid --format: ${args.format}. Expected: text | json.`);
|
|
289
|
+
}
|
|
290
|
+
return args;
|
|
291
|
+
}
|
|
292
|
+
function git(projectDir, gitArgs, opts = {}) {
|
|
293
|
+
return (0, node_child_process_1.spawnSync)("git", ["-C", projectDir, ...gitArgs], { encoding: "utf8", ...opts });
|
|
294
|
+
}
|
|
295
|
+
function isGitClean(projectDir) {
|
|
296
|
+
const r = git(projectDir, ["status", "--porcelain"]);
|
|
297
|
+
if (r.status !== 0) {
|
|
298
|
+
throw new Error(`git status failed: ${r.stderr || r.stdout}`);
|
|
299
|
+
}
|
|
300
|
+
return r.stdout.trim() === "";
|
|
301
|
+
}
|
|
302
|
+
function branchExists(projectDir, branch) {
|
|
303
|
+
const r = git(projectDir, ["branch", "--list", branch]);
|
|
304
|
+
return r.status === 0 && r.stdout.trim() !== "";
|
|
305
|
+
}
|
|
306
|
+
function runPlan(projectDir) {
|
|
307
|
+
const r = (0, node_child_process_1.spawnSync)(process.execPath, [PLAN_SCRIPT, "--project-dir", projectDir, "--format", "json"], {
|
|
308
|
+
encoding: "utf8",
|
|
309
|
+
});
|
|
310
|
+
if (r.status !== 0) {
|
|
311
|
+
throw new Error(`plan failed:\n${r.stderr || r.stdout}`);
|
|
312
|
+
}
|
|
313
|
+
let parsed;
|
|
314
|
+
try {
|
|
315
|
+
parsed = JSON.parse(r.stdout);
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
throw new Error(`plan produced invalid JSON: ${err.message}`);
|
|
319
|
+
}
|
|
320
|
+
return parsed;
|
|
321
|
+
}
|
|
322
|
+
// Generous maxBuffer for captured subprocess output — Maven/Gradle first runs
|
|
323
|
+
// can easily produce >1 MB of dependency-download log, and `spawnSync`'s
|
|
324
|
+
// default 1 MB ceiling otherwise kills the gate with ENOBUFS.
|
|
325
|
+
const SUBPROCESS_MAX_BUFFER = 64 * 1024 * 1024;
|
|
326
|
+
/** Run the gate (validate --strict-tdd, then the optional test command). */
|
|
327
|
+
/**
|
|
328
|
+
* What Cucumber says it did, when it can be asked (F5).
|
|
329
|
+
*
|
|
330
|
+
* The gate's question has always been "did the command exit zero?", and H14
|
|
331
|
+
* showed what that misses: `1 scenario (1 passed) · 0 steps · exit 0` is a
|
|
332
|
+
* scenario that tested nothing and reported success. Cucumber has published a
|
|
333
|
+
* machine-readable channel for years; this reads it.
|
|
334
|
+
*
|
|
335
|
+
* Two ways in, and neither guesses:
|
|
336
|
+
*
|
|
337
|
+
* - `message_report` in `harness.config.yaml` — the project writes the stream
|
|
338
|
+
* wherever it likes and says where. Works for any command.
|
|
339
|
+
* - a **direct** `cucumber-js` invocation, which the harness appends
|
|
340
|
+
* `--format message:<tmp>` to. Deliberately narrow: `npm test` may well run
|
|
341
|
+
* Cucumber and there is no way to know from here, so it is left alone.
|
|
342
|
+
*
|
|
343
|
+
* Returns `null` when there is nothing to read — a project that does not use
|
|
344
|
+
* Cucumber must not be failed by a check that never applied.
|
|
345
|
+
*/
|
|
346
|
+
function readGateMessages(worktreeDir, reportPath) {
|
|
347
|
+
if (!reportPath)
|
|
348
|
+
return null;
|
|
349
|
+
const file = path.isAbsolute(reportPath) ? reportPath : path.join(worktreeDir, reportPath);
|
|
350
|
+
let raw;
|
|
351
|
+
try {
|
|
352
|
+
raw = fs.readFileSync(file, "utf8");
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
const run = (0, CucumberMessages_1.parseCucumberMessages)(raw);
|
|
358
|
+
return run.parsed ? run : null;
|
|
359
|
+
}
|
|
360
|
+
function runGate(worktreeDir, testCmd, timeoutMs, req = {}, settings = {}) {
|
|
361
|
+
const validate = (0, node_child_process_1.spawnSync)(process.execPath, [VALIDATE_SCRIPT, worktreeDir, "--strict-tdd"], {
|
|
362
|
+
encoding: "utf8",
|
|
363
|
+
timeout: timeoutMs,
|
|
364
|
+
maxBuffer: SUBPROCESS_MAX_BUFFER,
|
|
365
|
+
});
|
|
366
|
+
if (validate.status !== 0) {
|
|
367
|
+
return {
|
|
368
|
+
ok: false,
|
|
369
|
+
stage: "validate --strict-tdd",
|
|
370
|
+
output: validate.stdout + validate.stderr,
|
|
371
|
+
hint: "",
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
if (testCmd) {
|
|
375
|
+
let resolved = (0, HarnessRun_1.substituteGateCommand)(testCmd, req);
|
|
376
|
+
// Where the message stream will be, if there is one to have.
|
|
377
|
+
let reportPath = settings.messageReport || "";
|
|
378
|
+
if (!reportPath && (0, CucumberMessages_1.invokesCucumberDirectly)(resolved)) {
|
|
379
|
+
reportPath = path.join(".harness", `messages-${req.requirement || "run"}-${crypto.randomBytes(3).toString("hex")}.ndjson`);
|
|
380
|
+
fs.mkdirSync(path.join(worktreeDir, ".harness"), { recursive: true });
|
|
381
|
+
resolved = (0, CucumberMessages_1.withMessageReport)(resolved, reportPath);
|
|
382
|
+
}
|
|
383
|
+
// A fresh worktree carries only what git tracks, so a project with
|
|
384
|
+
// dependencies has no node_modules here. Verified the hard way: an agent
|
|
385
|
+
// spent its first attempt installing them and timed out. The gate command
|
|
386
|
+
// is the right place to say so, since only the project knows how.
|
|
387
|
+
const test = (0, node_child_process_1.spawnSync)(resolved, {
|
|
388
|
+
shell: true,
|
|
389
|
+
cwd: worktreeDir,
|
|
390
|
+
encoding: "utf8",
|
|
391
|
+
timeout: timeoutMs,
|
|
392
|
+
maxBuffer: SUBPROCESS_MAX_BUFFER,
|
|
393
|
+
});
|
|
394
|
+
if (test.status !== 0) {
|
|
395
|
+
// Name the command. A gate that silently does the wrong thing — running
|
|
396
|
+
// the whole suite because a filter did not apply, say — produces a
|
|
397
|
+
// failure indistinguishable from a real one, and the operator has no way
|
|
398
|
+
// to tell without the command in front of them.
|
|
399
|
+
return {
|
|
400
|
+
ok: false,
|
|
401
|
+
stage: `test command: ${resolved}`,
|
|
402
|
+
output: test.stdout + test.stderr,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
// Exit zero is where the old gate stopped. This is the part that catches a
|
|
406
|
+
// suite that passed without covering the requirement at all.
|
|
407
|
+
const messages = readGateMessages(worktreeDir, reportPath);
|
|
408
|
+
if (messages) {
|
|
409
|
+
const findings = (0, CucumberMessages_1.checkGateRun)(messages, {
|
|
410
|
+
requirement: req.requirement,
|
|
411
|
+
scenarioId: req.scenarioId || req.scenario_id,
|
|
412
|
+
featureFile: (0, HarnessRun_1.featureFilePath)(req),
|
|
413
|
+
});
|
|
414
|
+
const errors = findings.filter((f) => f.severity === "error");
|
|
415
|
+
const warnings = findings.filter((f) => f.severity !== "error");
|
|
416
|
+
if (errors.length > 0) {
|
|
417
|
+
return {
|
|
418
|
+
ok: false,
|
|
419
|
+
stage: "cucumber messages",
|
|
420
|
+
output: `The test command exited 0, but the run it reported does not support ` +
|
|
421
|
+
`that verdict:\n\n` +
|
|
422
|
+
errors.map((e) => ` ${e.message}\n fix: ${e.fix}`).join("\n\n"),
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
if (warnings.length > 0) {
|
|
426
|
+
return { ok: true, stage: "", output: "", hint: warnings[0].message };
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return { ok: true, stage: "", output: "", hint: "" };
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Undo whatever an advisory agent touched.
|
|
434
|
+
*
|
|
435
|
+
* A reviewer returns text. If it also edited files, those edits would be gated
|
|
436
|
+
* and committed as if the implementer had made them, and nobody would know
|
|
437
|
+
* which agent wrote what. Discarding is cheaper to reason about than trusting
|
|
438
|
+
* the reviewer to behave.
|
|
439
|
+
*/
|
|
440
|
+
/**
|
|
441
|
+
* Keep a copy of exactly what each agent was given.
|
|
442
|
+
*
|
|
443
|
+
* It goes in the *worktree*, so `git add -A` commits it with the work and it
|
|
444
|
+
* arrives in the branch a human reviews — which is the point — while the main
|
|
445
|
+
* tree stays clean. It used to be written to the project directory, which
|
|
446
|
+
* dirtied it and blocked the next run.
|
|
447
|
+
*
|
|
448
|
+
* The file name carries the role, so an attempt that ran a reviewer and then an
|
|
449
|
+
* implementer leaves two files and it is obvious which prompt went to whom.
|
|
450
|
+
*
|
|
451
|
+
* Best-effort: never fail a run over a bookkeeping write.
|
|
452
|
+
*/
|
|
453
|
+
function archivePrompt(worktreeDir, requirement, attempt, step, prompt) {
|
|
454
|
+
try {
|
|
455
|
+
const dir = path.join(worktreeDir, ...EmptyAttempt_1.PROMPT_ARCHIVE_DIR.split("/"));
|
|
456
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
457
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
458
|
+
const role = step.profile || (step.advisory ? "reviewer" : "agent");
|
|
459
|
+
fs.writeFileSync(path.join(dir, `${requirement}-${ts}-attempt-${attempt}-${role}.md`), prompt, "utf8");
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
/* never fail the run on an audit-log write */
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function discardWorktreeChanges(worktreeDir) {
|
|
466
|
+
git(worktreeDir, ["checkout", "--", "."]);
|
|
467
|
+
// The prompt archive is untracked, so an unqualified `clean -fd` deletes it —
|
|
468
|
+
// taking with it the record of what the reviewer and every earlier attempt
|
|
469
|
+
// were given. Auditability is the reason the archive exists; discarding the
|
|
470
|
+
// reviewer's edits must not discard the evidence.
|
|
471
|
+
git(worktreeDir, ["clean", "-fd", "-e", EmptyAttempt_1.PROMPT_ARCHIVE_DIR]);
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Did the agent edit the contract it was being judged against? (A1, closes H16)
|
|
475
|
+
*
|
|
476
|
+
* The prompt asks it not to; nothing checked. An agent that cannot make the
|
|
477
|
+
* scenario pass can relax the scenario, or add a permissive line to
|
|
478
|
+
* `AI_RULES.md`, and the gate approves — `validate --strict-tdd` verifies that
|
|
479
|
+
* the feature exists and is in the matrix, never that it still says what it
|
|
480
|
+
* said. "Specs as executable contracts" stops being true the moment the
|
|
481
|
+
* executor may edit the contract.
|
|
482
|
+
*
|
|
483
|
+
* Run **before** the gate, not after: once the scenario has been loosened, a
|
|
484
|
+
* green gate means nothing, so there is no point asking it.
|
|
485
|
+
*
|
|
486
|
+
* The diff of the offending paths goes back into the next attempt's prompt.
|
|
487
|
+
* That is not politeness — in practice the agent almost always did it without
|
|
488
|
+
* meaning to, and being shown the hunk is what corrects it. A refusal with no
|
|
489
|
+
* evidence just gets repeated.
|
|
490
|
+
*/
|
|
491
|
+
/**
|
|
492
|
+
* What the agent wrote, as git sees it.
|
|
493
|
+
*
|
|
494
|
+
* `-uall` is not optional. Plain `--porcelain` collapses a wholly new directory
|
|
495
|
+
* into a single `?? src/` entry, so `src/Health.java` never appears and any
|
|
496
|
+
* check reading it concludes the file was never written. Found by an agent that
|
|
497
|
+
* had correctly created both declared artifacts and was reported as having
|
|
498
|
+
* touched neither.
|
|
499
|
+
*/
|
|
500
|
+
function worktreeChanges(worktreeDir) {
|
|
501
|
+
const status = git(worktreeDir, ["status", "--porcelain", "-uall"]);
|
|
502
|
+
if (status.status !== 0)
|
|
503
|
+
return null;
|
|
504
|
+
return (0, WriteScope_1.parseGitStatus)(status.stdout || "");
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Did the agent write anything at all? (H19)
|
|
508
|
+
*
|
|
509
|
+
* The gate runs *before* `specgate done`, so at gate time the requirement is still
|
|
510
|
+
* `Draft` — and `--strict-tdd`'s "no Test Artifact = TBD past Draft" rule does
|
|
511
|
+
* not apply to a Draft row. `done` then flips the status to `Implemented` and
|
|
512
|
+
* nothing validates again. The consequence, reproduced on 2026-08-26 with
|
|
513
|
+
* `--agent "cat {prompt_file} > /dev/null"` against a freshly generated
|
|
514
|
+
* project: **an agent that wrote zero files reported `pass (1 attempt)`**, with
|
|
515
|
+
* the row moved to `Implemented` and its Test artifact still `TBD`.
|
|
516
|
+
*
|
|
517
|
+
* `--strict-artifacts` cannot catch this either: it compares the diff against
|
|
518
|
+
* the paths the row declares, and a row that declares none has nothing to
|
|
519
|
+
* compare.
|
|
520
|
+
*
|
|
521
|
+
* So the harness asks the one question that needs no declaration and has no
|
|
522
|
+
* legitimate negative answer: did anything change? An agent that produced no
|
|
523
|
+
* files cannot have implemented a requirement, whatever the gate says about the
|
|
524
|
+
* documents around it. That makes this a hard failure rather than an opt-in
|
|
525
|
+
* flag, under the rule ADR-0023 sets out — a content check is a gate only when
|
|
526
|
+
* failing it is always a defect, and this one is.
|
|
527
|
+
*
|
|
528
|
+
* The prompt archive does not count. The harness writes it itself, so counting
|
|
529
|
+
* it would mean the harness's own bookkeeping vouches for the agent.
|
|
530
|
+
*
|
|
531
|
+
* Run **before** the gate, for the same reason `checkWriteScopeInWorktree` is:
|
|
532
|
+
* a green gate over an empty diff proves nothing, so there is no point asking
|
|
533
|
+
* it. It is also the cheaper order — an empty attempt stops before the project's
|
|
534
|
+
* test command runs.
|
|
535
|
+
*/
|
|
536
|
+
function producedNothing(worktreeDir) {
|
|
537
|
+
return (0, EmptyAttempt_1.isEmptyAttempt)(touchedPaths(worktreeDir));
|
|
538
|
+
}
|
|
539
|
+
function checkWriteScopeInWorktree(worktreeDir, settings) {
|
|
540
|
+
const changes = worktreeChanges(worktreeDir);
|
|
541
|
+
if (!changes)
|
|
542
|
+
return null;
|
|
543
|
+
const violations = (0, WriteScope_1.checkWriteScope)(changes, {
|
|
544
|
+
protectedPaths: settings.protectedPaths.length ? settings.protectedPaths : undefined,
|
|
545
|
+
allowPaths: settings.allowPaths,
|
|
546
|
+
});
|
|
547
|
+
if (violations.length === 0)
|
|
548
|
+
return null;
|
|
549
|
+
const diff = git(worktreeDir, ["diff", "--", ...violations.map((v) => v.path)]);
|
|
550
|
+
const evidence = (diff.stdout || "").trim();
|
|
551
|
+
return {
|
|
552
|
+
violations,
|
|
553
|
+
message: `agent_touched_protected_path — the agent modified ${violations.length} file(s) it is ` +
|
|
554
|
+
`not allowed to change:\n` +
|
|
555
|
+
violations.map((v) => ` ${v.path} (protected by \`${v.pattern}\`)`).join("\n") +
|
|
556
|
+
`\n\nThese files are the contract this requirement is judged against. Editing them ` +
|
|
557
|
+
`makes a passing gate meaningless. Revert them and satisfy the scenario as written.\n` +
|
|
558
|
+
(evidence ? `\nWhat was changed:\n\n\`\`\`diff\n${evidence}\n\`\`\`\n` : "") +
|
|
559
|
+
`\nIf the change is genuinely required, it belongs in a spec change ` +
|
|
560
|
+
`(\`specgate change new\`), reviewed by a person — not in this attempt.`,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Every path the agent wrote in this worktree — added and modified alike.
|
|
565
|
+
*
|
|
566
|
+
* Shared with the write-scope guard, which is why A2 costs almost nothing: the
|
|
567
|
+
* diff was already being read.
|
|
568
|
+
*/
|
|
569
|
+
function touchedPaths(worktreeDir) {
|
|
570
|
+
const changes = worktreeChanges(worktreeDir);
|
|
571
|
+
return changes ? [...changes.modified, ...changes.added] : [];
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Did the green diff touch what the matrix said it would? (A2)
|
|
575
|
+
*
|
|
576
|
+
* The row declares `test_artifact` and `technical_artifact` and the prompt
|
|
577
|
+
* hands both to the agent, but nothing checked the diff contained them. An
|
|
578
|
+
* agent can implement somewhere else, pass the scenario, and leave the matrix
|
|
579
|
+
* pointing at a file where the logic does not live — the documentary lie
|
|
580
|
+
* `AI_RULES.md` forbids this repository.
|
|
581
|
+
*
|
|
582
|
+
* Run after a **green** gate, because the claim being checked is about a diff
|
|
583
|
+
* that already works. A red attempt has a more urgent problem.
|
|
584
|
+
*
|
|
585
|
+
* A warning by default. An implementation can legitimately land in a shared
|
|
586
|
+
* module that already exists, and a hard failure there would be the kind of
|
|
587
|
+
* gate that rejects good work — which already cost two runs on REQ-002.
|
|
588
|
+
* `--strict-artifacts` is for a project that wants it enforced.
|
|
589
|
+
*/
|
|
590
|
+
function checkDeclaredArtifactsInWorktree(worktreeDir, req, strict) {
|
|
591
|
+
const findings = (0, DeclaredArtifacts_1.checkDeclaredArtifacts)({
|
|
592
|
+
touched: touchedPaths(worktreeDir),
|
|
593
|
+
testArtifact: req.testArtifact || req.test_artifact,
|
|
594
|
+
technicalArtifact: req.technicalArtifact || req.technical_artifact,
|
|
595
|
+
requirement: req.requirement,
|
|
596
|
+
}, strict);
|
|
597
|
+
return findings.length > 0 ? findings : null;
|
|
598
|
+
}
|
|
599
|
+
function attemptRequirement(req, ctx) {
|
|
600
|
+
const { worktreeDir, settings, timeoutMs, hint } = ctx;
|
|
601
|
+
/**
|
|
602
|
+
* What the gate said last time, recovered from the prompt archive when this
|
|
603
|
+
* is a resumed run. Without it the agent starts the resumed attempt blind and
|
|
604
|
+
* repeats whatever it already got wrong.
|
|
605
|
+
*/
|
|
606
|
+
let previousFailure = (ctx.resumeAt && ctx.resumeAt.previousFailure) || "";
|
|
607
|
+
/**
|
|
608
|
+
* What each attempt cost and where it ended.
|
|
609
|
+
*
|
|
610
|
+
* Wall-clock, because it is the one cost the harness can measure without the
|
|
611
|
+
* agent's cooperation: an agent is any shell command, and only the agent
|
|
612
|
+
* knows what it spent in tokens. `specgate harness report` is built on this.
|
|
613
|
+
*/
|
|
614
|
+
const attemptLog = [];
|
|
615
|
+
/** The reviewer's findings from the previous attempt, fed into this prompt. */
|
|
616
|
+
let reviewFindings = "";
|
|
617
|
+
const firstAttempt = Math.max(1, (ctx.resumeAt && ctx.resumeAt.attempt) || 1);
|
|
618
|
+
for (let attempt = firstAttempt; attempt <= settings.maxAttempts; attempt += 1) {
|
|
619
|
+
info(`${req.requirement}: attempt ${attempt}/${settings.maxAttempts}`);
|
|
620
|
+
const attemptStart = Date.now();
|
|
621
|
+
let agentMs = 0;
|
|
622
|
+
/** Which roles ran this attempt, in order — the run record's answer to "who did this". */
|
|
623
|
+
const profilesUsed = [];
|
|
624
|
+
const record = (stage) => {
|
|
625
|
+
attemptLog.push({
|
|
626
|
+
attempt,
|
|
627
|
+
endedAt: stage,
|
|
628
|
+
agentMs,
|
|
629
|
+
totalMs: Date.now() - attemptStart,
|
|
630
|
+
profiles: [...profilesUsed],
|
|
631
|
+
});
|
|
632
|
+
};
|
|
633
|
+
// An attempt is a sequence of agent invocations, all bound to this one
|
|
634
|
+
// requirement. Attempt 1 is a single implementing step; a retry may run an
|
|
635
|
+
// advisory reviewer first, whose findings feed the step that follows.
|
|
636
|
+
const steps = (0, HarnessRun_1.planAttempt)(attempt, {
|
|
637
|
+
attemptProfiles: settings.attemptProfiles,
|
|
638
|
+
reviewProfile: settings.reviewProfile || null,
|
|
639
|
+
});
|
|
640
|
+
let stepFailed = false;
|
|
641
|
+
try {
|
|
642
|
+
for (const step of steps) {
|
|
643
|
+
// Built per step, not per attempt: the reviewer runs first and the
|
|
644
|
+
// implementing step that follows must see what it said. Building once
|
|
645
|
+
// up front meant findings only ever reached the *next* attempt, which
|
|
646
|
+
// is not what a reviewer is for.
|
|
647
|
+
const prompt = (0, prompt_1.buildPrompt)(req, worktreeDir, {
|
|
648
|
+
promptPrefix: settings.promptPrefix,
|
|
649
|
+
hint,
|
|
650
|
+
previousFailure: previousFailure || undefined,
|
|
651
|
+
reviewFindings: step.advisory ? undefined : reviewFindings || undefined,
|
|
652
|
+
attempt,
|
|
653
|
+
maxAttempts: settings.maxAttempts,
|
|
654
|
+
});
|
|
655
|
+
const promptFile = path.join(os.tmpdir(), `csda-harness-prompt-${req.requirement}-${crypto.randomBytes(4).toString("hex")}.md`);
|
|
656
|
+
fs.writeFileSync(promptFile, prompt, "utf8");
|
|
657
|
+
archivePrompt(worktreeDir, req.requirement, attempt, step, prompt);
|
|
658
|
+
// D1: a profile chosen by matching this requirement, when one matches.
|
|
659
|
+
// The role ladder still wins — `attempt_profiles` names a profile for
|
|
660
|
+
// *this step*, and a step that says which role it is has already
|
|
661
|
+
// answered the question.
|
|
662
|
+
const profile = step.profile || ctx.matchedProfile || null;
|
|
663
|
+
const agentCommand = profile
|
|
664
|
+
? settings.profileAgents[profile] || settings.agent
|
|
665
|
+
: settings.agent;
|
|
666
|
+
const command = (0, HarnessRun_1.substituteAgentCommand)(agentCommand, promptFile);
|
|
667
|
+
if (profile) {
|
|
668
|
+
info(`${req.requirement}: ${step.advisory ? "reviewing" : "running"} as '${profile}'`);
|
|
669
|
+
}
|
|
670
|
+
const agentStart = Date.now();
|
|
671
|
+
const agent = (0, node_child_process_1.spawnSync)(command, {
|
|
672
|
+
shell: true,
|
|
673
|
+
cwd: worktreeDir,
|
|
674
|
+
encoding: "utf8",
|
|
675
|
+
timeout: timeoutMs,
|
|
676
|
+
maxBuffer: SUBPROCESS_MAX_BUFFER,
|
|
677
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
678
|
+
});
|
|
679
|
+
// Node reports a timeout as an errno-carrying Error; the base `Error`
|
|
680
|
+
// type the spawnSync signature declares does not have `code`.
|
|
681
|
+
agentMs += Date.now() - agentStart;
|
|
682
|
+
profilesUsed.push(profile ?? (step.advisory ? "reviewer" : "agent"));
|
|
683
|
+
fs.rmSync(promptFile, { force: true });
|
|
684
|
+
const agentError = agent.error;
|
|
685
|
+
if (agentError?.code === "ETIMEDOUT") {
|
|
686
|
+
if (step.advisory) {
|
|
687
|
+
warn(`${req.requirement}: reviewer timed out — continuing without findings`);
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
previousFailure = `Agent timed out after ${ctx.timeoutSeconds}s.`;
|
|
691
|
+
warn(`${req.requirement}: agent timed out`);
|
|
692
|
+
record("agent-timeout");
|
|
693
|
+
stepFailed = true;
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
if (step.advisory) {
|
|
697
|
+
// The reviewer advises and nothing more. Its findings become input to
|
|
698
|
+
// the next prompt; anything it wrote is discarded, so it cannot reach
|
|
699
|
+
// the gate. That is the line between this and a committee — the gate
|
|
700
|
+
// stays the only judge, and a reviewer can never approve.
|
|
701
|
+
reviewFindings = `${agent.stdout || ""}${agent.stderr || ""}`.trim();
|
|
702
|
+
discardWorktreeChanges(worktreeDir);
|
|
703
|
+
if (agent.status !== 0) {
|
|
704
|
+
warn(`${req.requirement}: reviewer exited ${agent.status} — findings may be partial`);
|
|
705
|
+
}
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
if (agent.status !== 0) {
|
|
709
|
+
previousFailure = `Agent exited ${agent.status}.\n${agent.stdout || ""}${agent.stderr || ""}`;
|
|
710
|
+
warn(`${req.requirement}: agent exited ${agent.status}`);
|
|
711
|
+
record("agent-error");
|
|
712
|
+
stepFailed = true;
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
finally {
|
|
718
|
+
/* each step removes its own prompt file */
|
|
719
|
+
}
|
|
720
|
+
if (stepFailed)
|
|
721
|
+
continue;
|
|
722
|
+
// Before the gate: an agent that wrote nothing cannot have implemented
|
|
723
|
+
// anything, and the gate would approve it. See `producedNothing` (H19).
|
|
724
|
+
if (producedNothing(worktreeDir)) {
|
|
725
|
+
previousFailure =
|
|
726
|
+
`You produced no files. Nothing in the worktree changed, so there is ` +
|
|
727
|
+
`nothing implementing ${req.requirement}.\n\n` +
|
|
728
|
+
`If you could not write, that is the problem to report — a non-interactive ` +
|
|
729
|
+
`agent without tool permissions reads the prompt and cannot act on it. ` +
|
|
730
|
+
`See docs/automation.md.`;
|
|
731
|
+
warn(`${req.requirement}: the agent produced no files — nothing to gate`);
|
|
732
|
+
warn(` fix: check the agent's write permissions (see docs/automation.md)`);
|
|
733
|
+
record("no-op");
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
// Before the gate: a scenario the agent has just loosened cannot fail, so a
|
|
737
|
+
// green gate proves nothing. See `checkWriteScopeInWorktree`.
|
|
738
|
+
const scope = checkWriteScopeInWorktree(worktreeDir, settings);
|
|
739
|
+
if (scope) {
|
|
740
|
+
previousFailure = scope.message;
|
|
741
|
+
warn(`${req.requirement}: ${scope.violations.length} protected path(s) modified`);
|
|
742
|
+
for (const v of scope.violations)
|
|
743
|
+
warn(` ${v.path} (${v.pattern})`);
|
|
744
|
+
record("write-scope");
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
const gate = runGate(worktreeDir, settings.testCmd, timeoutMs, req, settings);
|
|
748
|
+
if (!gate.ok) {
|
|
749
|
+
previousFailure =
|
|
750
|
+
`Gate failed at: ${gate.stage}\n\n` + (gate.hint ? `⚠ ${gate.hint}\n\n` : "") + gate.output;
|
|
751
|
+
warn(`${req.requirement}: gate failed at ${gate.stage}`);
|
|
752
|
+
if (gate.hint)
|
|
753
|
+
warn(gate.hint);
|
|
754
|
+
record("gate");
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
// Green — but does the diff contain what the row promised? (A2)
|
|
758
|
+
const artifacts = checkDeclaredArtifactsInWorktree(worktreeDir, req, Boolean(ctx.strictArtifacts));
|
|
759
|
+
if (artifacts) {
|
|
760
|
+
for (const f of artifacts) {
|
|
761
|
+
warn(`${req.requirement}: ${f.message} [${f.code}]`);
|
|
762
|
+
if (f.fix)
|
|
763
|
+
warn(` fix: ${f.fix}`);
|
|
764
|
+
}
|
|
765
|
+
if (ctx.strictArtifacts) {
|
|
766
|
+
previousFailure =
|
|
767
|
+
`The gate passed, but the diff never touches the artifacts the matrix ` +
|
|
768
|
+
`declares for ${req.requirement}:\n\n` +
|
|
769
|
+
artifacts.map((f) => ` ${f.message}\n fix: ${f.fix}`).join("\n\n") +
|
|
770
|
+
`\n\nA matrix that points at a file where the logic does not live is a ` +
|
|
771
|
+
`documentary lie. Implement it there, or correct the row.`;
|
|
772
|
+
record("artifacts");
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
// Close the loop inside the worktree.
|
|
777
|
+
const done = (0, node_child_process_1.spawnSync)(process.execPath, [DONE_SCRIPT, req.requirement, "--project-dir", worktreeDir], { encoding: "utf8" });
|
|
778
|
+
if (done.status !== 0) {
|
|
779
|
+
previousFailure = `done ${req.requirement} failed:\n${done.stdout}${done.stderr}`;
|
|
780
|
+
warn(`${req.requirement}: done failed`);
|
|
781
|
+
record("done");
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
git(worktreeDir, ["add", "-A"]);
|
|
785
|
+
const commit = git(worktreeDir, [
|
|
786
|
+
"commit",
|
|
787
|
+
"-m",
|
|
788
|
+
`feat(${req.requirement}): implement via specgate harness\n\nAttempt ${attempt}/${settings.maxAttempts}.`,
|
|
789
|
+
]);
|
|
790
|
+
if (commit.status !== 0) {
|
|
791
|
+
previousFailure = `git commit failed:\n${commit.stderr || commit.stdout}`;
|
|
792
|
+
warn(`${req.requirement}: commit failed`);
|
|
793
|
+
record("commit");
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
record("pass");
|
|
797
|
+
return { result: "pass", attempts: attempt, attemptLog };
|
|
798
|
+
}
|
|
799
|
+
// Every attempt is spent. Commit what the agent produced anyway, on the
|
|
800
|
+
// branch, before the worktree is removed.
|
|
801
|
+
//
|
|
802
|
+
// It used to be discarded: `continue` moved to the next attempt and the
|
|
803
|
+
// worktree was deleted at the end, so a failed requirement left a branch
|
|
804
|
+
// identical to its base and nothing to look at. Diagnosing a failure then
|
|
805
|
+
// cost a second full agent run with --keep-worktrees purely to see what had
|
|
806
|
+
// been written — fifteen minutes to recover information the first run had.
|
|
807
|
+
//
|
|
808
|
+
// The commit subject says it failed, and `specgate done` never ran, so the
|
|
809
|
+
// requirement is still Draft in the matrix. A human decides whether the work
|
|
810
|
+
// is worth keeping; git decides nothing.
|
|
811
|
+
const preserved = preserveFailedAttempt(worktreeDir, req, previousFailure);
|
|
812
|
+
return {
|
|
813
|
+
result: "fail",
|
|
814
|
+
// Attempts *this* run spent. On a resume that starts at 3 of 3, one attempt
|
|
815
|
+
// was spent here, and reporting 3 would double-count the earlier run's.
|
|
816
|
+
attempts: settings.maxAttempts - firstAttempt + 1,
|
|
817
|
+
error: previousFailure,
|
|
818
|
+
workPreserved: preserved,
|
|
819
|
+
attemptLog,
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
/** Where a run's record lands. Local to the machine — see `writeRunRecord`. */
|
|
823
|
+
exports.RUNS_DIR = path.join(".harness", "runs");
|
|
824
|
+
/**
|
|
825
|
+
* Write what a run did, so the next question about it has an answer.
|
|
826
|
+
*
|
|
827
|
+
* The harness printed a report and forgot it. That is fine for one run and
|
|
828
|
+
* useless for the question that decides whether agent roles are worth paying
|
|
829
|
+
* for (E2-01): **what does a delivered requirement cost, and how often does the
|
|
830
|
+
* first attempt work?** Neither is answerable from memory, and both are
|
|
831
|
+
* answerable from a directory of these.
|
|
832
|
+
*
|
|
833
|
+
* The agent command is deliberately *not* recorded: it is a shell command a
|
|
834
|
+
* user composed, and it is exactly the kind of string that ends up carrying an
|
|
835
|
+
* API key.
|
|
836
|
+
*
|
|
837
|
+
* The directory ignores itself. Two reasons, and the first is not optional:
|
|
838
|
+
* the harness refuses to start on a dirty tree, so a ledger git could see
|
|
839
|
+
* would mean the first run makes the second one refuse — H2 of the closure
|
|
840
|
+
* plan, where the harness blocked itself by writing into the project. The
|
|
841
|
+
* second is that a file rewritten by every run is a merge conflict waiting to
|
|
842
|
+
* happen; these numbers are local measurements, not shared history.
|
|
843
|
+
*
|
|
844
|
+
* Best-effort — a run that produced branches must not fail over bookkeeping.
|
|
845
|
+
*/
|
|
846
|
+
function writeRunRecord(projectDir, record) {
|
|
847
|
+
try {
|
|
848
|
+
const dir = path.join(projectDir, exports.RUNS_DIR);
|
|
849
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
850
|
+
const ignore = path.join(dir, ".gitignore");
|
|
851
|
+
if (!fs.existsSync(ignore))
|
|
852
|
+
fs.writeFileSync(ignore, "*\n", "utf8");
|
|
853
|
+
const file = path.join(dir, `${record.startedAt.replace(/[:.]/g, "-")}.json`);
|
|
854
|
+
fs.writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
855
|
+
return file;
|
|
856
|
+
}
|
|
857
|
+
catch {
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* The repository's main line, for the staleness check.
|
|
863
|
+
*
|
|
864
|
+
* `origin/HEAD` is authoritative when it is set; otherwise the conventional
|
|
865
|
+
* names, in order. A repository with none of them simply gets no warning —
|
|
866
|
+
* guessing wrong would be worse than staying quiet.
|
|
867
|
+
*/
|
|
868
|
+
function defaultBranch(projectDir) {
|
|
869
|
+
const head = git(projectDir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]);
|
|
870
|
+
if (head.status === 0) {
|
|
871
|
+
const ref = String(head.stdout).trim().replace("refs/remotes/", "");
|
|
872
|
+
if (ref)
|
|
873
|
+
return ref;
|
|
874
|
+
}
|
|
875
|
+
for (const name of ["main", "master"]) {
|
|
876
|
+
if (git(projectDir, ["rev-parse", "--verify", "--quiet", name]).status === 0)
|
|
877
|
+
return name;
|
|
878
|
+
}
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
881
|
+
/** How many commits `target` has that `ref` does not. */
|
|
882
|
+
function commitsBehind(projectDir, ref, target) {
|
|
883
|
+
const r = git(projectDir, ["rev-list", "--count", `${ref}..${target}`]);
|
|
884
|
+
if (r.status !== 0)
|
|
885
|
+
return 0;
|
|
886
|
+
return Number(String(r.stdout).trim()) || 0;
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Warn when the base a requirement is cut from is missing work that the main
|
|
890
|
+
* line already has.
|
|
891
|
+
*
|
|
892
|
+
* This is H9. `--base-branch harness/REQ-001` behaves exactly as git says it
|
|
893
|
+
* should — the new branch inherits its base, not `main` — and it cost two
|
|
894
|
+
* agent runs to discover, because a fix that had landed on `main` was simply
|
|
895
|
+
* not there and the gate failed for a reason that had nothing to do with the
|
|
896
|
+
* requirement. Deriving the base (below) removes the need to pass the flag;
|
|
897
|
+
* this says so out loud when the derived base is stale anyway.
|
|
898
|
+
*/
|
|
899
|
+
function warnIfBaseIsStale(projectDir, reqId, base) {
|
|
900
|
+
const mainLine = defaultBranch(projectDir);
|
|
901
|
+
if (!mainLine || mainLine === base)
|
|
902
|
+
return;
|
|
903
|
+
const behind = commitsBehind(projectDir, base, mainLine);
|
|
904
|
+
if (behind === 0)
|
|
905
|
+
return;
|
|
906
|
+
warn(`${reqId}: base ${base} is ${behind} commit(s) behind ${mainLine}. ` +
|
|
907
|
+
`A fix that landed on ${mainLine} is not in this worktree — a gate failure may not be about ${reqId}.`);
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Files the harness writes itself, and whose conflicts on an integration base
|
|
911
|
+
* therefore mean nothing.
|
|
912
|
+
*
|
|
913
|
+
* Two sibling requirement branches *always* conflict here: each run ends with
|
|
914
|
+
* `specgate done REQ-NNN`, which edits the same traceability matrix. The
|
|
915
|
+
* integration base exists only so an agent can see the code its dependencies
|
|
916
|
+
* produced; the matrix state on a throwaway branch is consulted by nobody, and
|
|
917
|
+
* each real `harness/REQ-NNN` branch keeps its own row untouched.
|
|
918
|
+
*/
|
|
919
|
+
const HARNESS_WRITTEN = ["docs/specs/traceability.md"];
|
|
920
|
+
/**
|
|
921
|
+
* Resolve conflicts in generated files by keeping the base's version, and
|
|
922
|
+
* report any conflict that is left.
|
|
923
|
+
*
|
|
924
|
+
* @returns conflicted paths that are *not* generated — the ones a human owns
|
|
925
|
+
*/
|
|
926
|
+
function resolveGeneratedConflicts(worktree) {
|
|
927
|
+
const listed = git(worktree, ["diff", "--name-only", "--diff-filter=U"]);
|
|
928
|
+
if (listed.status !== 0)
|
|
929
|
+
return ["(could not list conflicts)"];
|
|
930
|
+
const conflicted = String(listed.stdout)
|
|
931
|
+
.split("\n")
|
|
932
|
+
.map((l) => l.trim())
|
|
933
|
+
.filter(Boolean);
|
|
934
|
+
const rest = conflicted.filter((p) => !HARNESS_WRITTEN.includes(p));
|
|
935
|
+
if (rest.length > 0)
|
|
936
|
+
return rest;
|
|
937
|
+
for (const file of conflicted) {
|
|
938
|
+
git(worktree, ["checkout", "--ours", "--", file]);
|
|
939
|
+
git(worktree, ["add", "--", file]);
|
|
940
|
+
}
|
|
941
|
+
const commit = git(worktree, ["commit", "--no-edit"]);
|
|
942
|
+
return commit.status === 0 ? [] : ["(could not conclude the merge)"];
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Where a requirement's worktree is cut from.
|
|
946
|
+
*
|
|
947
|
+
* A requirement that builds on another needs that other's code to exist, and
|
|
948
|
+
* the only place it exists during a run is the dependency's own
|
|
949
|
+
* `harness/REQ-NNN` branch. Deriving this is what closes H12's other half:
|
|
950
|
+
* nobody has to know the order and pass `--base-branch` by hand.
|
|
951
|
+
*
|
|
952
|
+
* Dependencies that are already `DONE` contribute nothing here — their work is
|
|
953
|
+
* in the run's base already.
|
|
954
|
+
*
|
|
955
|
+
* @param deps the requirement's dependencies, in declaration order
|
|
956
|
+
* @param passed REQ → branch, for the dependencies that passed in this run
|
|
957
|
+
*/
|
|
958
|
+
function deriveBase(projectDir, reqId, deps, passed, ctx) {
|
|
959
|
+
const branches = deps.map((d) => passed.get(d)).filter(Boolean);
|
|
960
|
+
if (branches.length === 0)
|
|
961
|
+
return { base: ctx.baseRef };
|
|
962
|
+
if (branches.length === 1)
|
|
963
|
+
return { base: branches[0] };
|
|
964
|
+
// More than one dependency produced code, on branches that know nothing of
|
|
965
|
+
// each other. Cutting from one would silently omit the rest, so they are
|
|
966
|
+
// integrated into a throwaway base first. This is not the merge the harness
|
|
967
|
+
// refuses to do — that one is into a branch a human reviews; this is
|
|
968
|
+
// assembling the context the requirement was declared to need, and a
|
|
969
|
+
// conflict here is a real finding rather than a nuisance.
|
|
970
|
+
const integration = `harness/base/${reqId}`;
|
|
971
|
+
git(projectDir, ["branch", "-D", integration]);
|
|
972
|
+
const cut = git(projectDir, ["branch", integration, ctx.baseRef]);
|
|
973
|
+
if (cut.status !== 0) {
|
|
974
|
+
return { error: `could not create ${integration}: ${cut.stderr || cut.stdout}` };
|
|
975
|
+
}
|
|
976
|
+
const worktree = path.join(os.tmpdir(), `csda-harness-base-${reqId}-${crypto.randomBytes(4).toString("hex")}`);
|
|
977
|
+
const add = git(projectDir, ["worktree", "add", worktree, integration]);
|
|
978
|
+
if (add.status !== 0) {
|
|
979
|
+
return { error: `could not check out ${integration}: ${add.stderr || add.stdout}` };
|
|
980
|
+
}
|
|
981
|
+
try {
|
|
982
|
+
for (const branch of branches) {
|
|
983
|
+
const merge = git(worktree, ["merge", "--no-edit", branch]);
|
|
984
|
+
if (merge.status === 0)
|
|
985
|
+
continue;
|
|
986
|
+
const unresolved = resolveGeneratedConflicts(worktree);
|
|
987
|
+
if (unresolved.length > 0) {
|
|
988
|
+
git(worktree, ["merge", "--abort"]);
|
|
989
|
+
return {
|
|
990
|
+
error: `${branch} conflicts with the other dependencies of ${reqId} in ` +
|
|
991
|
+
`${unresolved.join(", ")}. Those are source files; a human has to decide.`,
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
finally {
|
|
997
|
+
git(projectDir, ["worktree", "remove", "--force", worktree]);
|
|
998
|
+
}
|
|
999
|
+
info(`${reqId}: base is ${branches.join(" + ")} integrated into ${integration}`);
|
|
1000
|
+
return { base: integration };
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Commit a failed attempt so the branch carries what the agent wrote.
|
|
1004
|
+
*
|
|
1005
|
+
* Returns false when there was nothing to commit — an agent that produced no
|
|
1006
|
+
* files at all, which is itself worth knowing and is reported as such.
|
|
1007
|
+
*/
|
|
1008
|
+
/**
|
|
1009
|
+
* The worktree already checked out on `branch`, if git still knows of one.
|
|
1010
|
+
*
|
|
1011
|
+
* After an interruption the worktree survives — the process died before its
|
|
1012
|
+
* `finally` could remove it — holding the agent's uncommitted partial work.
|
|
1013
|
+
* Re-using it is the difference between resuming and starting over.
|
|
1014
|
+
*/
|
|
1015
|
+
function existingWorktreeFor(projectDir, branch) {
|
|
1016
|
+
const listed = git(projectDir, ["worktree", "list", "--porcelain"]);
|
|
1017
|
+
if (listed.status !== 0)
|
|
1018
|
+
return null;
|
|
1019
|
+
let current = null;
|
|
1020
|
+
for (const line of (listed.stdout || "").split("\n")) {
|
|
1021
|
+
if (line.startsWith("worktree "))
|
|
1022
|
+
current = line.slice("worktree ".length).trim();
|
|
1023
|
+
else if (line.trim() === `branch refs/heads/${branch}` && current) {
|
|
1024
|
+
return fs.existsSync(current) ? current : null;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
return null;
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Where a resumed run picks up, and what the gate last said.
|
|
1031
|
+
*
|
|
1032
|
+
* Read from the prompt archive rather than the run ledger. The ledger is
|
|
1033
|
+
* written when a run *finishes*, and `--resume` is for the runs that do not —
|
|
1034
|
+
* a killed run leaves `.harness/runs/` empty. Measured, not assumed.
|
|
1035
|
+
*
|
|
1036
|
+
* The archive is reachable the same way in both endings: still uncommitted in a
|
|
1037
|
+
* surviving worktree after an interruption, and committed onto the branch by
|
|
1038
|
+
* `preserveFailedAttempt` once the attempts are spent, so a freshly added
|
|
1039
|
+
* worktree checks it out.
|
|
1040
|
+
*/
|
|
1041
|
+
function resumeFrom(worktreeDir, requirement) {
|
|
1042
|
+
const dir = path.join(worktreeDir, ...EmptyAttempt_1.PROMPT_ARCHIVE_DIR.split("/"));
|
|
1043
|
+
let names = [];
|
|
1044
|
+
try {
|
|
1045
|
+
names = fs.readdirSync(dir);
|
|
1046
|
+
}
|
|
1047
|
+
catch {
|
|
1048
|
+
return { attempt: 1, previousFailure: "" };
|
|
1049
|
+
}
|
|
1050
|
+
// Interrupted or exhausted? The two deserve different answers, and the branch
|
|
1051
|
+
// says which: `preserveFailedAttempt` leaves a `wip(...): FAILED the gate`
|
|
1052
|
+
// commit when the attempts run out, and an interruption leaves none.
|
|
1053
|
+
//
|
|
1054
|
+
// exhausted → the last attempt reached a verdict → resume at the next one
|
|
1055
|
+
// interrupted → it never did → re-run it, without charging the budget
|
|
1056
|
+
const head = git(worktreeDir, ["log", "-1", "--format=%s"]);
|
|
1057
|
+
const lastCompleted = /^wip\([^)]*\): FAILED the gate/.test((head.stdout || "").trim());
|
|
1058
|
+
const point = (0, ResumeState_1.resumePoint)(names, requirement, lastCompleted);
|
|
1059
|
+
if (!point.latest)
|
|
1060
|
+
return { attempt: 1, previousFailure: "" };
|
|
1061
|
+
let previousFailure = "";
|
|
1062
|
+
try {
|
|
1063
|
+
previousFailure = (0, ResumeState_1.previousFailureFromPrompt)(fs.readFileSync(path.join(dir, point.latest.fileName), "utf8"));
|
|
1064
|
+
}
|
|
1065
|
+
catch {
|
|
1066
|
+
/* an unreadable archive is not a reason to refuse to resume */
|
|
1067
|
+
}
|
|
1068
|
+
return { attempt: point.attempt, previousFailure };
|
|
1069
|
+
}
|
|
1070
|
+
function preserveFailedAttempt(worktreeDir, req, failure) {
|
|
1071
|
+
if (isGitClean(worktreeDir))
|
|
1072
|
+
return false;
|
|
1073
|
+
git(worktreeDir, ["add", "-A"]);
|
|
1074
|
+
const firstLine = String(failure || "").split("\n")[0] || "gate failed";
|
|
1075
|
+
const commit = git(worktreeDir, [
|
|
1076
|
+
"commit",
|
|
1077
|
+
"-m",
|
|
1078
|
+
`wip(${req.requirement}): FAILED the gate — do not merge as is\n\n` +
|
|
1079
|
+
`${firstLine}\n\n` +
|
|
1080
|
+
"Committed by `specgate harness run` so the attempt is reviewable rather than\n" +
|
|
1081
|
+
"discarded. The requirement is still Draft: `specgate done` never ran.",
|
|
1082
|
+
]);
|
|
1083
|
+
return commit.status === 0;
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Is this requirement's scenario worth spending an agent on? (A3)
|
|
1087
|
+
*
|
|
1088
|
+
* The harness gates a requirement by running its scenario. If Cucumber sees no
|
|
1089
|
+
* steps in that scenario, the gate reports `0 steps · exit 0` and the run is
|
|
1090
|
+
* recorded as a pass — the agent is paid for, the branch is published, and
|
|
1091
|
+
* nothing was verified. That is H14 seen from the harness: not a weak signal, a
|
|
1092
|
+
* counterfeit one.
|
|
1093
|
+
*
|
|
1094
|
+
* So the check runs **before** `git worktree add`, before the prompt, before
|
|
1095
|
+
* the agent: an attempt costs up to `max_attempts` × the timeout, and there is
|
|
1096
|
+
* no point buying that against a scenario that cannot fail.
|
|
1097
|
+
*
|
|
1098
|
+
* Only errors block. Warnings — a thin scenario, a vague step — are printed and
|
|
1099
|
+
* allowed through: they weaken the signal without faking it, and a project
|
|
1100
|
+
* brought in with `specgate adopt` would otherwise be unable to run the harness at
|
|
1101
|
+
* all. `specgate validate --strict-scenarios` is where a project opts into the
|
|
1102
|
+
* stricter reading.
|
|
1103
|
+
*/
|
|
1104
|
+
/**
|
|
1105
|
+
* Is this requirement fit to hand to an agent? (B2, and A3's guard folded in)
|
|
1106
|
+
*
|
|
1107
|
+
* `plan` has always known that a requirement's feature does not exist, that its
|
|
1108
|
+
* dependencies are unmet, or that its row is Deprecated. `harness run` never
|
|
1109
|
+
* used any of it as a filter, so the agent found out halfway through and the
|
|
1110
|
+
* run paid `max_attempts` × the timeout to discover it. That is the worst
|
|
1111
|
+
* cost-to-result attempt in the loop.
|
|
1112
|
+
*
|
|
1113
|
+
* The rules are in `core/domain/RequirementReadiness`; what happens here is
|
|
1114
|
+
* delivery, and it is not uniform:
|
|
1115
|
+
*
|
|
1116
|
+
* - **an unrunnable scenario always skips**, flag or no flag. That guard came
|
|
1117
|
+
* from A3 and is not a matter of preference: Cucumber passes an empty
|
|
1118
|
+
* scenario, so the reward signal is counterfeit and a green run would prove
|
|
1119
|
+
* nothing (H14).
|
|
1120
|
+
* - **every other blocker warns and runs anyway**, unless `--skip-not-ready`.
|
|
1121
|
+
* Default behaviour stays what it was — this ships in a minor — and a person
|
|
1122
|
+
* who wants to point an agent at a half-ready requirement is allowed to.
|
|
1123
|
+
*/
|
|
1124
|
+
function readinessOf(projectDir, req) {
|
|
1125
|
+
const rel = (0, HarnessRun_1.featureFilePath)(req);
|
|
1126
|
+
const file = rel ? path.resolve(projectDir, rel) : "";
|
|
1127
|
+
const featureExists = Boolean(file && fs.existsSync(file));
|
|
1128
|
+
let scenarioFindings = [];
|
|
1129
|
+
if (featureExists) {
|
|
1130
|
+
try {
|
|
1131
|
+
scenarioFindings = (0, GherkinQuality_1.analyseGherkinSource)(fs.readFileSync(file, "utf8"), rel);
|
|
1132
|
+
}
|
|
1133
|
+
catch {
|
|
1134
|
+
// An unreadable feature is the gate's problem to report, not a reason to
|
|
1135
|
+
// refuse the run here.
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
const readiness = (0, RequirementReadiness_1.requirementReadiness)({
|
|
1139
|
+
requirement: req.requirement,
|
|
1140
|
+
status: req.status || "",
|
|
1141
|
+
featureFile: rel,
|
|
1142
|
+
// `plan` decides whether a feature is missing; when the harness is handed a
|
|
1143
|
+
// requirement some other way, fall back to looking.
|
|
1144
|
+
featureExists: req.featureExists !== undefined ? Boolean(req.featureExists) : featureExists,
|
|
1145
|
+
scenarioFindings,
|
|
1146
|
+
// Deliberately **not** `blockedBy`. That comes from the plan snapshot taken
|
|
1147
|
+
// before the run, so a requirement whose predecessor passed *in this run*
|
|
1148
|
+
// still reads as blocked — and with `--skip-not-ready` it would be skipped
|
|
1149
|
+
// after the scheduler had correctly unblocked it. Measured: REQ-002 was
|
|
1150
|
+
// reported "depends on REQ-001, which is not done" moments after REQ-001
|
|
1151
|
+
// passed and its branch became REQ-002's base.
|
|
1152
|
+
//
|
|
1153
|
+
// `runLevels` owns ordering during a run: it schedules by level and marks
|
|
1154
|
+
// dependents `blocked` when a predecessor actually fails. Readiness answers
|
|
1155
|
+
// the questions the scheduler cannot.
|
|
1156
|
+
blockedBy: [],
|
|
1157
|
+
technicalDeclared: RequirementPlan_1.RequirementPlan.isMeaningful(req.technicalArtifact || req.technical_artifact),
|
|
1158
|
+
testDeclared: RequirementPlan_1.RequirementPlan.isMeaningful(req.testArtifact || req.test_artifact),
|
|
1159
|
+
});
|
|
1160
|
+
return {
|
|
1161
|
+
...readiness,
|
|
1162
|
+
scenarioErrors: scenarioFindings.filter((f) => f.severity === "error"),
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
/** The settings a context carries, tolerating the worker path's shape. */
|
|
1166
|
+
function settingsOf(ctx) {
|
|
1167
|
+
return (ctx && ctx.settings) || {};
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* The bounded context a requirement belongs to, as `expand` wrote it (D1).
|
|
1171
|
+
*
|
|
1172
|
+
* Read from the matrix rather than the pack: a project may install several
|
|
1173
|
+
* packs, and the matrix is its own record of what it has.
|
|
1174
|
+
*/
|
|
1175
|
+
function contextOfRequirement(projectDir, requirement) {
|
|
1176
|
+
const matrix = path.join(projectDir, "docs", "specs", "traceability.md");
|
|
1177
|
+
try {
|
|
1178
|
+
return (0, TraceabilityFormat_1.parseMatrixContexts)(fs.readFileSync(matrix, "utf8"))[requirement] || "";
|
|
1179
|
+
}
|
|
1180
|
+
catch {
|
|
1181
|
+
return "";
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Can git commit here? Returns the problem, or `""`.
|
|
1186
|
+
*
|
|
1187
|
+
* Asked before the first worktree rather than at the commit step. The harness
|
|
1188
|
+
* commits for every green requirement and again to preserve a failed attempt,
|
|
1189
|
+
* so a missing identity costs a whole attempt — agent time included — to
|
|
1190
|
+
* discover something a single command fixes.
|
|
1191
|
+
*/
|
|
1192
|
+
function gitIdentityProblem(projectDir) {
|
|
1193
|
+
const missing = ["user.name", "user.email"].filter((key) => {
|
|
1194
|
+
const r = git(projectDir, ["config", "--get", key]);
|
|
1195
|
+
return r.status !== 0 || !String(r.stdout || "").trim();
|
|
1196
|
+
});
|
|
1197
|
+
if (missing.length === 0)
|
|
1198
|
+
return "";
|
|
1199
|
+
return (`git has no ${missing.join(" and ")} configured, and the harness commits on ` +
|
|
1200
|
+
`every requirement it lands. It would fail after the agent had already run.\n` +
|
|
1201
|
+
`Fix: git config user.name "Your Name" && git config user.email "you@example.com"`);
|
|
1202
|
+
}
|
|
1203
|
+
function processRequirement(req, ctx) {
|
|
1204
|
+
const { projectDir, baseRef, keepWorktrees, force } = ctx;
|
|
1205
|
+
const branch = `harness/${req.requirement}`;
|
|
1206
|
+
// D1: which profile this requirement matches, resolved once. The bounded
|
|
1207
|
+
// context comes from the matrix, where `expand` derived it — see
|
|
1208
|
+
// `boundedContextOf`.
|
|
1209
|
+
const matchedProfile = (0, ProfileMatch_1.selectProfile)(settingsOf(ctx).profileRules || [], {
|
|
1210
|
+
requirement: req.requirement,
|
|
1211
|
+
boundedContext: contextOfRequirement(projectDir, req.requirement),
|
|
1212
|
+
featureFile: (0, HarnessRun_1.featureFilePath)(req),
|
|
1213
|
+
category: req.category,
|
|
1214
|
+
});
|
|
1215
|
+
if (matchedProfile)
|
|
1216
|
+
info(`${req.requirement}: profile '${matchedProfile}' matched`);
|
|
1217
|
+
const readiness = readinessOf(projectDir, req);
|
|
1218
|
+
const unrunnable = readiness.blockers.find((b) => b.code === "requirement_scenario_unrunnable");
|
|
1219
|
+
const hardStop = unrunnable || (ctx.skipNotReady && !readiness.ready);
|
|
1220
|
+
for (const b of readiness.blockers) {
|
|
1221
|
+
const line = `${req.requirement}: ${b.message} [${b.code}]`;
|
|
1222
|
+
if (b.severity === "error")
|
|
1223
|
+
error(line);
|
|
1224
|
+
else
|
|
1225
|
+
warn(line);
|
|
1226
|
+
if (b.fix)
|
|
1227
|
+
warn(` fix: ${b.fix}`);
|
|
1228
|
+
}
|
|
1229
|
+
// The readiness blocker says *that* the scenario cannot fail; these say
|
|
1230
|
+
// which line and which keyword. Collapsing A3's findings into one summary
|
|
1231
|
+
// would have cost the only part a person can act on directly.
|
|
1232
|
+
if (unrunnable) {
|
|
1233
|
+
for (const f of readiness.scenarioErrors || []) {
|
|
1234
|
+
error(`${req.requirement}: ${f.file}:${f.line} ${f.message} [${f.code}]`);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
if (hardStop) {
|
|
1238
|
+
return {
|
|
1239
|
+
requirement: req.requirement,
|
|
1240
|
+
category: req.category,
|
|
1241
|
+
result: "skipped",
|
|
1242
|
+
attempts: 0,
|
|
1243
|
+
branch,
|
|
1244
|
+
error: unrunnable
|
|
1245
|
+
? `Its scenario would pass without testing anything, so the gate could not ` +
|
|
1246
|
+
`tell success from failure. Run \`specgate validate <dir> --strict-scenarios\` ` +
|
|
1247
|
+
`to see every one.`
|
|
1248
|
+
: `Not ready for an agent: ${readiness.blockers
|
|
1249
|
+
.filter((b) => b.severity === "error")
|
|
1250
|
+
.map((b) => b.code)
|
|
1251
|
+
.join(", ")}. Fix the blockers above, or drop --skip-not-ready to run it anyway.`,
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
const worktreeDir = path.join(os.tmpdir(), `csda-harness-${req.requirement}-${crypto.randomBytes(4).toString("hex")}`);
|
|
1255
|
+
// Three ways to meet an existing branch, and until C3 there were only two:
|
|
1256
|
+
// skip it, or delete it with --force. After an interruption — a crash, a
|
|
1257
|
+
// Ctrl-C, a spend limit — neither is what anyone wants.
|
|
1258
|
+
let resuming = false;
|
|
1259
|
+
if (branchExists(projectDir, branch)) {
|
|
1260
|
+
if (ctx.resume) {
|
|
1261
|
+
resuming = true;
|
|
1262
|
+
}
|
|
1263
|
+
else if (!force) {
|
|
1264
|
+
return {
|
|
1265
|
+
requirement: req.requirement,
|
|
1266
|
+
category: req.category,
|
|
1267
|
+
result: "skipped",
|
|
1268
|
+
attempts: 0,
|
|
1269
|
+
branch,
|
|
1270
|
+
error: `Branch ${branch} already exists. Re-run with --resume to continue it, ` +
|
|
1271
|
+
`or --force to recreate it from scratch.`,
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
else {
|
|
1275
|
+
git(projectDir, ["branch", "-D", branch]);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
else if (ctx.resume) {
|
|
1279
|
+
// Nothing to resume is not an error: --resume over a whole plan should run
|
|
1280
|
+
// the requirements that never started, not refuse the lot.
|
|
1281
|
+
info(`${req.requirement}: nothing to resume, starting fresh`);
|
|
1282
|
+
}
|
|
1283
|
+
warnIfBaseIsStale(projectDir, req.requirement, baseRef);
|
|
1284
|
+
const startedAt = Date.now();
|
|
1285
|
+
// A surviving worktree still holds the partial work; re-attaching to it is
|
|
1286
|
+
// what makes this a resume rather than a restart.
|
|
1287
|
+
const survivor = resuming ? existingWorktreeFor(projectDir, branch) : null;
|
|
1288
|
+
const dir = survivor || worktreeDir;
|
|
1289
|
+
if (!survivor) {
|
|
1290
|
+
const args = resuming
|
|
1291
|
+
? ["worktree", "add", dir, branch]
|
|
1292
|
+
: ["worktree", "add", "-b", branch, dir, baseRef];
|
|
1293
|
+
const add = git(projectDir, args);
|
|
1294
|
+
if (add.status !== 0) {
|
|
1295
|
+
return {
|
|
1296
|
+
requirement: req.requirement,
|
|
1297
|
+
category: req.category,
|
|
1298
|
+
result: "fail",
|
|
1299
|
+
attempts: 0,
|
|
1300
|
+
branch,
|
|
1301
|
+
error: `git worktree add failed:\n${add.stderr || add.stdout}`,
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
try {
|
|
1306
|
+
const resumeAt = resuming ? resumeFrom(dir, req.requirement) : null;
|
|
1307
|
+
if (resumeAt && resumeAt.attempt > 1) {
|
|
1308
|
+
info(`${req.requirement}: resuming at attempt ${resumeAt.attempt}`);
|
|
1309
|
+
}
|
|
1310
|
+
const outcome = attemptRequirement(req, {
|
|
1311
|
+
...ctx,
|
|
1312
|
+
worktreeDir: dir,
|
|
1313
|
+
resumeAt,
|
|
1314
|
+
matchedProfile,
|
|
1315
|
+
});
|
|
1316
|
+
const result = {
|
|
1317
|
+
requirement: req.requirement,
|
|
1318
|
+
category: req.category,
|
|
1319
|
+
branch,
|
|
1320
|
+
base: baseRef,
|
|
1321
|
+
durationMs: Date.now() - startedAt,
|
|
1322
|
+
...outcome,
|
|
1323
|
+
};
|
|
1324
|
+
if (outcome.result === "pass") {
|
|
1325
|
+
Object.assign(result, publishBranch(projectDir, branch, req, ctx.settings));
|
|
1326
|
+
}
|
|
1327
|
+
return result;
|
|
1328
|
+
}
|
|
1329
|
+
finally {
|
|
1330
|
+
// `dir`, not `worktreeDir`: a resumed run re-attached to the worktree that
|
|
1331
|
+
// survived the interruption, and `worktreeDir` names a path that was never
|
|
1332
|
+
// created. Removing the wrong one leaves the real worktree registered and
|
|
1333
|
+
// the next --resume unable to attach.
|
|
1334
|
+
if (!keepWorktrees) {
|
|
1335
|
+
git(projectDir, ["worktree", "remove", "--force", dir]);
|
|
1336
|
+
}
|
|
1337
|
+
else {
|
|
1338
|
+
info(`${req.requirement}: worktree kept at ${dir}`);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* CI mode (B7): after a green requirement, optionally push the branch and
|
|
1344
|
+
* open a PR/MR via a user-configured command. Publication problems never
|
|
1345
|
+
* flip a pass to a fail — the code is good; the human just has to publish
|
|
1346
|
+
* manually — but they are reported.
|
|
1347
|
+
*/
|
|
1348
|
+
function publishBranch(projectDir, branch, req, settings) {
|
|
1349
|
+
const published = {};
|
|
1350
|
+
if (!settings.push)
|
|
1351
|
+
return published;
|
|
1352
|
+
const push = git(projectDir, ["push", "--force-with-lease", "-u", settings.remote, branch]);
|
|
1353
|
+
if (push.status !== 0) {
|
|
1354
|
+
published.pushed = false;
|
|
1355
|
+
published.publishError = `git push failed:\n${push.stderr || push.stdout}`;
|
|
1356
|
+
warn(`${req.requirement}: push to ${settings.remote} failed`);
|
|
1357
|
+
return published;
|
|
1358
|
+
}
|
|
1359
|
+
published.pushed = true;
|
|
1360
|
+
info(`${req.requirement}: pushed ${branch} to ${settings.remote}`);
|
|
1361
|
+
if (settings.prCmd) {
|
|
1362
|
+
const command = settings.prCmd
|
|
1363
|
+
.split("{branch}")
|
|
1364
|
+
.join(branch)
|
|
1365
|
+
.split("{req}")
|
|
1366
|
+
.join(req.requirement);
|
|
1367
|
+
const pr = (0, node_child_process_1.spawnSync)(command, {
|
|
1368
|
+
shell: true,
|
|
1369
|
+
cwd: projectDir,
|
|
1370
|
+
encoding: "utf8",
|
|
1371
|
+
maxBuffer: SUBPROCESS_MAX_BUFFER,
|
|
1372
|
+
});
|
|
1373
|
+
if (pr.status !== 0) {
|
|
1374
|
+
published.prCreated = false;
|
|
1375
|
+
published.publishError = `pr command failed:\n${pr.stderr || pr.stdout}`;
|
|
1376
|
+
warn(`${req.requirement}: pr command failed`);
|
|
1377
|
+
}
|
|
1378
|
+
else {
|
|
1379
|
+
published.prCreated = true;
|
|
1380
|
+
const firstLine = (pr.stdout || "").trim().split("\n").pop();
|
|
1381
|
+
if (firstLine)
|
|
1382
|
+
published.prOutput = firstLine;
|
|
1383
|
+
info(`${req.requirement}: pr command succeeded${firstLine ? ` → ${firstLine}` : ""}`);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
return published;
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* How much of a failing gate's output the text report shows.
|
|
1390
|
+
*
|
|
1391
|
+
* Runners put the useful part at the end, so the tail is what a human needs.
|
|
1392
|
+
* Twenty lines is enough for a failing assertion with its stack, and short
|
|
1393
|
+
* enough that ten failed requirements do not bury the summary.
|
|
1394
|
+
*/
|
|
1395
|
+
const FAILURE_TAIL_LINES = 20;
|
|
1396
|
+
function printReport(results, format) {
|
|
1397
|
+
if (format === "json") {
|
|
1398
|
+
const summary = results.reduce((acc, r) => {
|
|
1399
|
+
acc[r.result] = (acc[r.result] || 0) + 1;
|
|
1400
|
+
return acc;
|
|
1401
|
+
}, {});
|
|
1402
|
+
process.stdout.write(JSON.stringify({ schemaVersion: 1, total: results.length, summary, results }, null, 2) + "\n");
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
process.stdout.write("\n── harness report ──\n");
|
|
1406
|
+
for (const r of results) {
|
|
1407
|
+
const icon = r.result === "pass"
|
|
1408
|
+
? "✅"
|
|
1409
|
+
: r.result === "skipped"
|
|
1410
|
+
? "⏭️ "
|
|
1411
|
+
: r.result === "blocked"
|
|
1412
|
+
? "⛔"
|
|
1413
|
+
: "❌";
|
|
1414
|
+
process.stdout.write(` ${icon} ${r.requirement} ${r.result} (${r.attempts} attempt${r.attempts === 1 ? "" : "s"}) → ${r.branch}\n`);
|
|
1415
|
+
if (r.result === "blocked") {
|
|
1416
|
+
// Nothing ran, so there is no gate output to show — only the reason.
|
|
1417
|
+
process.stdout.write(` ${r.error}\n`);
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1420
|
+
if (r.result !== "pass" && r.error) {
|
|
1421
|
+
// The full gate output — the test failure that explains *why* — was
|
|
1422
|
+
// captured and then thrown away here, leaving "Gate failed at: test
|
|
1423
|
+
// command" and nothing to act on. With the worktree removed by default,
|
|
1424
|
+
// that made a failed run undiagnosable. Show the tail, where runners put
|
|
1425
|
+
// the actual failure, and name the two flags that give more.
|
|
1426
|
+
const lines = String(r.error).split("\n");
|
|
1427
|
+
const head = lines[0];
|
|
1428
|
+
const tail = lines
|
|
1429
|
+
.slice(1)
|
|
1430
|
+
.filter((l) => l.trim() !== "")
|
|
1431
|
+
.slice(-FAILURE_TAIL_LINES);
|
|
1432
|
+
process.stdout.write(` ${head}\n`);
|
|
1433
|
+
for (const line of tail)
|
|
1434
|
+
process.stdout.write(` │ ${line}\n`);
|
|
1435
|
+
if (tail.length > 0) {
|
|
1436
|
+
process.stdout.write(" └ full output: --format json · reproduce: --keep-worktrees\n");
|
|
1437
|
+
}
|
|
1438
|
+
if (r.workPreserved) {
|
|
1439
|
+
process.stdout.write(` ↳ the attempt is committed on ${r.branch} — review it\n`);
|
|
1440
|
+
}
|
|
1441
|
+
else if (r.result === "fail") {
|
|
1442
|
+
process.stdout.write(" ↳ the agent produced no files\n");
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
if (r.pushed) {
|
|
1446
|
+
process.stdout.write(` pushed${r.prCreated ? ` · PR created${r.prOutput ? `: ${r.prOutput}` : ""}` : ""}\n`);
|
|
1447
|
+
}
|
|
1448
|
+
if (r.publishError) {
|
|
1449
|
+
process.stdout.write(` publish issue: ${String(r.publishError).split("\n")[0]}\n`);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
const pass = results.filter((r) => r.result === "pass").length;
|
|
1453
|
+
const fail = results.filter((r) => r.result === "fail").length;
|
|
1454
|
+
const skip = results.filter((r) => r.result === "skipped").length;
|
|
1455
|
+
const blocked = results.filter((r) => r.result === "blocked").length;
|
|
1456
|
+
// Blocked is counted apart from failed on purpose: one broken predecessor
|
|
1457
|
+
// used to produce N failures and N wasted agent invocations, which said
|
|
1458
|
+
// nothing about the N-1 requirements that were never attempted.
|
|
1459
|
+
const blockedNote = blocked > 0 ? ` · ${blocked} blocked` : "";
|
|
1460
|
+
process.stdout.write(`\n ${pass} passed · ${fail} failed · ${skip} skipped${blockedNote}\n`);
|
|
1461
|
+
if (pass > 0) {
|
|
1462
|
+
process.stdout.write(` Review and merge the harness/* branches you trust.\n`);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Run one level.
|
|
1467
|
+
*
|
|
1468
|
+
* At concurrency 1 this is the loop the harness has always had: one
|
|
1469
|
+
* requirement, in this process, synchronously. Above 1 each requirement is
|
|
1470
|
+
* handed to a worker process running this same script with `--req`, because
|
|
1471
|
+
* every step inside `processRequirement` is a blocking `spawnSync` and there
|
|
1472
|
+
* is no way to interleave two of them in one process without rewriting all of
|
|
1473
|
+
* it.
|
|
1474
|
+
*
|
|
1475
|
+
* The worker is not a reduced version of the real thing: it *is* `harness run`
|
|
1476
|
+
* scoped to one requirement, so a parallel run and a serial run execute the
|
|
1477
|
+
* same code.
|
|
1478
|
+
*/
|
|
1479
|
+
async function dispatchLevel(runnable, byId, hintByReq, ctx, concurrency) {
|
|
1480
|
+
if (concurrency <= 1) {
|
|
1481
|
+
const results = [];
|
|
1482
|
+
for (const id of runnable) {
|
|
1483
|
+
// Asked before each one, not once for the level. Independent
|
|
1484
|
+
// requirements all land in a single level — the common case — so a
|
|
1485
|
+
// ceiling checked only between levels never fires. Measured: a
|
|
1486
|
+
// `--budget-seconds 2` run of three 3-second agents ran all three.
|
|
1487
|
+
if (ctx.mayStart && !ctx.mayStart()) {
|
|
1488
|
+
results.push(notAttempted(byId.get(id), id, ctx.stopReason()));
|
|
1489
|
+
continue;
|
|
1490
|
+
}
|
|
1491
|
+
results.push(processRequirement(byId.get(id), {
|
|
1492
|
+
...ctx,
|
|
1493
|
+
baseRef: ctx.baseFor ? ctx.baseFor(id) : ctx.baseRef,
|
|
1494
|
+
hint: hintByReq.get(id),
|
|
1495
|
+
}));
|
|
1496
|
+
}
|
|
1497
|
+
return results;
|
|
1498
|
+
}
|
|
1499
|
+
return runWorkers(runnable, byId, ctx, concurrency);
|
|
1500
|
+
}
|
|
1501
|
+
/** A requirement the budget never let start. Not a failure — it was not tried. */
|
|
1502
|
+
function notAttempted(req, id, reason) {
|
|
1503
|
+
return {
|
|
1504
|
+
requirement: id,
|
|
1505
|
+
category: req ? req.category : "",
|
|
1506
|
+
result: "skipped",
|
|
1507
|
+
attempts: 0,
|
|
1508
|
+
branch: `harness/${id}`,
|
|
1509
|
+
error: `Not attempted: ${reason}`,
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* Run requirements in parallel worker processes, at most `concurrency` at a
|
|
1514
|
+
* time, and collect their reports.
|
|
1515
|
+
*
|
|
1516
|
+
* Each worker prints a one-requirement JSON report; the parent parses it. A
|
|
1517
|
+
* worker that dies without printing one is reported as a failure carrying its
|
|
1518
|
+
* output, rather than vanishing from the run.
|
|
1519
|
+
*/
|
|
1520
|
+
function runWorkers(runnable, byId, ctx, concurrency) {
|
|
1521
|
+
return new Promise((resolve) => {
|
|
1522
|
+
const results = [];
|
|
1523
|
+
const queue = [...runnable];
|
|
1524
|
+
const running = new Map();
|
|
1525
|
+
const fill = () => {
|
|
1526
|
+
while (running.size < concurrency && queue.length > 0) {
|
|
1527
|
+
const id = queue.shift();
|
|
1528
|
+
// Same question as the sequential path: a worker already in flight is
|
|
1529
|
+
// never interrupted, but the next one need not be started.
|
|
1530
|
+
if (ctx.mayStart && !ctx.mayStart()) {
|
|
1531
|
+
results.push(notAttempted(byId ? byId.get(id) : null, id, ctx.stopReason()));
|
|
1532
|
+
continue;
|
|
1533
|
+
}
|
|
1534
|
+
const child = (0, node_child_process_1.spawn)(process.execPath, [WORKER_ENTRY, ...workerArgs(id, ctx)], {
|
|
1535
|
+
env: { ...process.env, CSDA_HARNESS_WORKER: "1" },
|
|
1536
|
+
});
|
|
1537
|
+
let stdout = "";
|
|
1538
|
+
let stderr = "";
|
|
1539
|
+
child.stdout.on("data", (d) => {
|
|
1540
|
+
stdout += d;
|
|
1541
|
+
});
|
|
1542
|
+
// A worker's prose goes straight through, so a long run is not silent.
|
|
1543
|
+
// Its JSON report is the only thing the parent parses.
|
|
1544
|
+
child.stderr.on("data", (d) => {
|
|
1545
|
+
stderr += d;
|
|
1546
|
+
process.stderr.write(d);
|
|
1547
|
+
});
|
|
1548
|
+
running.set(id, child);
|
|
1549
|
+
info(`${id}: started (${running.size}/${concurrency} in flight)`);
|
|
1550
|
+
child.on("close", () => {
|
|
1551
|
+
running.delete(id);
|
|
1552
|
+
results.push(parseWorkerReport(id, stdout, stderr));
|
|
1553
|
+
if (queue.length === 0 && running.size === 0)
|
|
1554
|
+
resolve(results);
|
|
1555
|
+
else
|
|
1556
|
+
fill();
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
};
|
|
1560
|
+
fill();
|
|
1561
|
+
if (running.size === 0)
|
|
1562
|
+
resolve(results);
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* The argv a worker gets: this same command, scoped to one requirement and
|
|
1567
|
+
* pinned to concurrency 1 so a worker never spawns workers of its own.
|
|
1568
|
+
*
|
|
1569
|
+
* Settings are passed explicitly rather than left to the worker's own read of
|
|
1570
|
+
* `harness.config.yaml`, so a profile resolved once in the parent cannot
|
|
1571
|
+
* resolve differently in a child.
|
|
1572
|
+
*/
|
|
1573
|
+
function workerArgs(id, ctx) {
|
|
1574
|
+
return [
|
|
1575
|
+
"--req",
|
|
1576
|
+
id,
|
|
1577
|
+
"--project-dir",
|
|
1578
|
+
ctx.projectDir,
|
|
1579
|
+
"--format",
|
|
1580
|
+
"json",
|
|
1581
|
+
"--concurrency",
|
|
1582
|
+
"1",
|
|
1583
|
+
"--base-branch",
|
|
1584
|
+
ctx.baseFor ? ctx.baseFor(id) : ctx.baseRef,
|
|
1585
|
+
"--timeout",
|
|
1586
|
+
String(ctx.timeoutSeconds),
|
|
1587
|
+
"--max-attempts",
|
|
1588
|
+
String(ctx.settings.maxAttempts),
|
|
1589
|
+
...(ctx.force ? ["--force"] : []),
|
|
1590
|
+
...(ctx.keepWorktrees ? ["--keep-worktrees"] : []),
|
|
1591
|
+
...(ctx.settings.agent ? ["--agent", ctx.settings.agent] : []),
|
|
1592
|
+
...(ctx.settings.testCmd ? ["--test-cmd", ctx.settings.testCmd] : []),
|
|
1593
|
+
];
|
|
1594
|
+
}
|
|
1595
|
+
/** A worker's report, or an honest failure describing why there is none. */
|
|
1596
|
+
function parseWorkerReport(id, stdout, stderr) {
|
|
1597
|
+
try {
|
|
1598
|
+
const parsed = JSON.parse(stdout);
|
|
1599
|
+
const one = (parsed.results || []).find((r) => r.requirement === id);
|
|
1600
|
+
if (one)
|
|
1601
|
+
return one;
|
|
1602
|
+
}
|
|
1603
|
+
catch {
|
|
1604
|
+
// fall through
|
|
1605
|
+
}
|
|
1606
|
+
return {
|
|
1607
|
+
requirement: id,
|
|
1608
|
+
category: "",
|
|
1609
|
+
result: "fail",
|
|
1610
|
+
attempts: 0,
|
|
1611
|
+
branch: `harness/${id}`,
|
|
1612
|
+
error: `Worker produced no report.\n${(stderr || stdout || "").slice(-2000)}`,
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Run the queue level by level.
|
|
1617
|
+
*
|
|
1618
|
+
* **Why concurrency 1 stays on the in-process path.** Every step of a
|
|
1619
|
+
* requirement — the gate, the agent, `done`, git — is a `spawnSync`, and
|
|
1620
|
+
* §12.11 of the closure plan is a list of eleven defects that only appeared
|
|
1621
|
+
* when this loop ran against a real agent. Converting all of it to async so
|
|
1622
|
+
* that one requirement could run "in parallel" with nothing would put the one
|
|
1623
|
+
* path that has actually been exercised behind an untested rewrite. So the
|
|
1624
|
+
* default path is the path it has always been, and only `--concurrency > 1`
|
|
1625
|
+
* dispatches through worker processes.
|
|
1626
|
+
*
|
|
1627
|
+
* **Why a failure blocks rather than fails.** Without dependencies expressed,
|
|
1628
|
+
* `harness run` processed the matrix in order and a broken predecessor made
|
|
1629
|
+
* every successor fail too — N failures for one cause, and N wasted agent
|
|
1630
|
+
* invocations. A requirement whose dependency failed has not been attempted;
|
|
1631
|
+
* calling that a failure would be a lie in the report.
|
|
1632
|
+
*/
|
|
1633
|
+
async function runLevels(pending, ctx, opts) {
|
|
1634
|
+
const { concurrency, hintByReq, runOne } = opts;
|
|
1635
|
+
/**
|
|
1636
|
+
* The run's ceiling (C1). Checked before starting a level, never inside one:
|
|
1637
|
+
* interrupting an attempt mid-flight would throw away the money already spent
|
|
1638
|
+
* on it and leave a worktree nobody asked for. A budget bounds what a run
|
|
1639
|
+
* *begins*.
|
|
1640
|
+
*/
|
|
1641
|
+
const limits = {
|
|
1642
|
+
budgetSeconds: ctx.budgetSeconds || 0,
|
|
1643
|
+
maxRequirements: ctx.maxRequirements || 0,
|
|
1644
|
+
};
|
|
1645
|
+
const budgetStartedAt = Date.now();
|
|
1646
|
+
let started = 0;
|
|
1647
|
+
let stoppedBy = null;
|
|
1648
|
+
const { levels, cycles, dependsOn, graph } = (0, HarnessRun_1.scheduleLevels)(pending);
|
|
1649
|
+
const byId = new Map(pending.map((r) => [r.requirement, r]));
|
|
1650
|
+
if (cycles.length > 0) {
|
|
1651
|
+
// `validate` is the gate that reports the cycle properly. Here we only
|
|
1652
|
+
// have to refuse to loop forever, and say which requirements are stuck.
|
|
1653
|
+
for (const cycle of cycles) {
|
|
1654
|
+
warn(`Dependency cycle, not attempted: ${[...cycle, cycle[0]].join(" → ")}`);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
const results = [];
|
|
1658
|
+
const blocked = new Set();
|
|
1659
|
+
/** REQ → the branch its work landed on, for the requirements that follow it. */
|
|
1660
|
+
const passedBranches = new Map();
|
|
1661
|
+
for (const level of levels) {
|
|
1662
|
+
const runnable = level.filter((id) => !blocked.has(id));
|
|
1663
|
+
for (const id of level) {
|
|
1664
|
+
if (!blocked.has(id))
|
|
1665
|
+
continue;
|
|
1666
|
+
const req = byId.get(id);
|
|
1667
|
+
results.push({
|
|
1668
|
+
requirement: id,
|
|
1669
|
+
category: req ? req.category : "",
|
|
1670
|
+
result: "blocked",
|
|
1671
|
+
attempts: 0,
|
|
1672
|
+
branch: `harness/${id}`,
|
|
1673
|
+
error: `Not attempted: ${(dependsOn[id] || []).filter((d) => blocked.has(d) || results.some((r) => r.requirement === d && r.result !== "pass")).join(", ")} did not pass.`,
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
if (runnable.length === 0)
|
|
1677
|
+
continue;
|
|
1678
|
+
// A requirement is cut from its dependencies' branches, not from the run's
|
|
1679
|
+
// base: that is where the code it builds on exists during a run.
|
|
1680
|
+
const derivationFailures = new Map();
|
|
1681
|
+
const baseFor = (id) => {
|
|
1682
|
+
const derived = deriveBase(ctx.projectDir, id, dependsOn[id] || [], passedBranches, ctx);
|
|
1683
|
+
if (derived.error) {
|
|
1684
|
+
derivationFailures.set(id, derived.error);
|
|
1685
|
+
return ctx.baseRef;
|
|
1686
|
+
}
|
|
1687
|
+
return derived.base;
|
|
1688
|
+
};
|
|
1689
|
+
const bases = new Map(runnable.map((id) => [id, baseFor(id)]));
|
|
1690
|
+
const attemptable = runnable.filter((id) => !derivationFailures.has(id));
|
|
1691
|
+
for (const [id, reason] of derivationFailures) {
|
|
1692
|
+
const req = byId.get(id);
|
|
1693
|
+
results.push({
|
|
1694
|
+
requirement: id,
|
|
1695
|
+
category: req ? req.category : "",
|
|
1696
|
+
result: "blocked",
|
|
1697
|
+
attempts: 0,
|
|
1698
|
+
branch: `harness/${id}`,
|
|
1699
|
+
error: `Not attempted: could not assemble its base. ${reason}`,
|
|
1700
|
+
});
|
|
1701
|
+
for (const dependent of graph.transitiveDependents(id))
|
|
1702
|
+
blocked.add(dependent);
|
|
1703
|
+
}
|
|
1704
|
+
if (attemptable.length === 0)
|
|
1705
|
+
continue;
|
|
1706
|
+
if (stoppedBy)
|
|
1707
|
+
break;
|
|
1708
|
+
// The ceiling bites *inside* the level, through `mayStart` below —
|
|
1709
|
+
// independent requirements all land in one level, which is the common
|
|
1710
|
+
// case, so a check that only ran between levels would let a run of
|
|
1711
|
+
// fourteen sail past `--max-requirements 2`. Measured: it did exactly
|
|
1712
|
+
// that, and again with `--budget-seconds`.
|
|
1713
|
+
const levelResults = await runOne(attemptable, byId, hintByReq, {
|
|
1714
|
+
...ctx,
|
|
1715
|
+
baseFor: (id) => bases.get(id),
|
|
1716
|
+
// The level asks before starting each requirement. `started` counts
|
|
1717
|
+
// what was actually begun, so the count ceiling and the clock agree.
|
|
1718
|
+
mayStart: () => {
|
|
1719
|
+
if (stoppedBy)
|
|
1720
|
+
return false;
|
|
1721
|
+
const verdict = (0, RunBudget_1.budgetVerdict)(limits, { startedAt: budgetStartedAt, started }, Date.now());
|
|
1722
|
+
if (verdict.stop) {
|
|
1723
|
+
stoppedBy = verdict;
|
|
1724
|
+
return false;
|
|
1725
|
+
}
|
|
1726
|
+
started += 1;
|
|
1727
|
+
return true;
|
|
1728
|
+
},
|
|
1729
|
+
stopReason: () => (stoppedBy ? stoppedBy.reason : ""),
|
|
1730
|
+
}, concurrency);
|
|
1731
|
+
results.push(...levelResults);
|
|
1732
|
+
for (const r of levelResults) {
|
|
1733
|
+
if (r.result === "pass")
|
|
1734
|
+
passedBranches.set(r.requirement, r.branch);
|
|
1735
|
+
}
|
|
1736
|
+
for (const r of levelResults) {
|
|
1737
|
+
if (r.result !== "pass") {
|
|
1738
|
+
for (const dependent of graph.transitiveDependents(r.requirement)) {
|
|
1739
|
+
blocked.add(dependent);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
// Once the budget is spent, later levels are not even walked: their
|
|
1745
|
+
// requirements are named as not attempted, with the same reason.
|
|
1746
|
+
if (stoppedBy) {
|
|
1747
|
+
for (const level of levels) {
|
|
1748
|
+
for (const id of level) {
|
|
1749
|
+
if (results.some((r) => r.requirement === id))
|
|
1750
|
+
continue;
|
|
1751
|
+
results.push(notAttempted(byId.get(id), id, stoppedBy.reason));
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
// Requirements caught in a cycle never reach a level. Report them rather
|
|
1756
|
+
// than dropping them from the run.
|
|
1757
|
+
for (const cycle of cycles) {
|
|
1758
|
+
for (const id of cycle) {
|
|
1759
|
+
if (results.some((r) => r.requirement === id))
|
|
1760
|
+
continue;
|
|
1761
|
+
const req = byId.get(id);
|
|
1762
|
+
results.push({
|
|
1763
|
+
requirement: id,
|
|
1764
|
+
category: req ? req.category : "",
|
|
1765
|
+
result: "blocked",
|
|
1766
|
+
attempts: 0,
|
|
1767
|
+
branch: `harness/${id}`,
|
|
1768
|
+
error: `Not attempted: caught in a dependency cycle (${cycle.join(" → ")}). Run \`specgate validate\` for the fix.`,
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
return results;
|
|
1773
|
+
}
|
|
1774
|
+
class RunCommand extends command_1.BaseCommand {
|
|
1775
|
+
async execute() {
|
|
1776
|
+
try {
|
|
1777
|
+
const args = parseArgs(this.args);
|
|
1778
|
+
setJsonMode(args.format === "json");
|
|
1779
|
+
const projectDir = (0, project_root_1.resolveProjectDir)(args.projectDir, { requireSentinel: true });
|
|
1780
|
+
const fileConfig = (0, HarnessConfigFile_1.readHarnessConfig)(projectDir);
|
|
1781
|
+
const settings = (0, HarnessConfigFile_1.resolveHarnessSettings)(fileConfig, args);
|
|
1782
|
+
// D1: profile match rules live in `.harness/profiles.yaml`, and a project
|
|
1783
|
+
// may declare profiles without ever writing a `harness.config.yaml`.
|
|
1784
|
+
// Reaching them through the config reader made them silently absent
|
|
1785
|
+
// there — the same gap the cost hints had, found the same way.
|
|
1786
|
+
if (settings.profileRules.length === 0) {
|
|
1787
|
+
const rules = (0, HarnessConfigFile_1.readProfileRules)(projectDir);
|
|
1788
|
+
if (rules.length > 0) {
|
|
1789
|
+
settings.profileRules = rules;
|
|
1790
|
+
settings.profileAgents = { ...settings.profileAgents };
|
|
1791
|
+
for (const rule of rules) {
|
|
1792
|
+
if (!settings.profileAgents[rule.name]) {
|
|
1793
|
+
settings.profileAgents[rule.name] = (0, HarnessConfigFile_1.resolveProfileAgent)(projectDir, rule.name);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
// A ladder of per-attempt profiles configures agents just as much as a
|
|
1799
|
+
// single `agent:` does, so a project that declares one is configured.
|
|
1800
|
+
const hasAgent = Boolean(settings.agent) || settings.attemptProfiles.length > 0;
|
|
1801
|
+
if (!args.dryRun && !hasAgent) {
|
|
1802
|
+
throw new Error('No agent configured. Pass --agent "<cmd with {prompt_file}>", set `agent:` ' +
|
|
1803
|
+
"in harness.config.yaml, or declare `attempt_profiles:`.");
|
|
1804
|
+
}
|
|
1805
|
+
const plan = runPlan(projectDir);
|
|
1806
|
+
let pending = (plan.requirements || []).filter((r) => r.category !== "DONE");
|
|
1807
|
+
if (args.reqs.length > 0) {
|
|
1808
|
+
const wanted = new Set(args.reqs);
|
|
1809
|
+
pending = pending.filter((r) => wanted.has(r.requirement));
|
|
1810
|
+
const found = new Set(pending.map((r) => r.requirement));
|
|
1811
|
+
for (const want of args.reqs) {
|
|
1812
|
+
if (!found.has(want))
|
|
1813
|
+
warn(`${want} is not a pending requirement — skipped.`);
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
if (pending.length === 0) {
|
|
1817
|
+
info("No pending requirements. Nothing to do.");
|
|
1818
|
+
process.exit(0);
|
|
1819
|
+
}
|
|
1820
|
+
const hintByReq = new Map((plan.next_steps || []).map((s) => [s.requirement, s.hint]));
|
|
1821
|
+
if (args.dryRun) {
|
|
1822
|
+
info(`Dry run — ${pending.length} requirement(s) would be processed:`);
|
|
1823
|
+
for (const req of pending) {
|
|
1824
|
+
const prompt = (0, prompt_1.buildPrompt)(req, projectDir, {
|
|
1825
|
+
promptPrefix: settings.promptPrefix,
|
|
1826
|
+
hint: hintByReq.get(req.requirement),
|
|
1827
|
+
});
|
|
1828
|
+
process.stdout.write(`\n${"═".repeat(72)}\n${req.requirement} (${req.category}) → branch harness/${req.requirement}\n${"═".repeat(72)}\n`);
|
|
1829
|
+
process.stdout.write(prompt + "\n");
|
|
1830
|
+
}
|
|
1831
|
+
process.exit(0);
|
|
1832
|
+
}
|
|
1833
|
+
if (!isGitClean(projectDir)) {
|
|
1834
|
+
throw new Error("Working tree is not clean. Commit or stash your changes before running the harness.");
|
|
1835
|
+
}
|
|
1836
|
+
// Git needs to know who is committing, and the harness commits — once per
|
|
1837
|
+
// green requirement, and again to preserve a failed attempt. Without an
|
|
1838
|
+
// identity it discovers this *after* the agent has run and the gate has
|
|
1839
|
+
// passed, so a full attempt is paid for and thrown away by a one-line
|
|
1840
|
+
// config. Found by CI, which has no global git identity and produced
|
|
1841
|
+
// `Author identity unknown` at the commit step of every requirement.
|
|
1842
|
+
const identity = gitIdentityProblem(projectDir);
|
|
1843
|
+
if (identity)
|
|
1844
|
+
throw new Error(identity);
|
|
1845
|
+
const baseRef = args.baseBranch || "HEAD";
|
|
1846
|
+
const concurrencyNote = settings.concurrency > 1 ? `, up to ${settings.concurrency} at a time` : "";
|
|
1847
|
+
info(`Processing ${pending.length} requirement(s) from base ${baseRef}${concurrencyNote}.`);
|
|
1848
|
+
// One prune, in the parent, before anything is created. It used to run per
|
|
1849
|
+
// requirement, which is harmless in series and a race in parallel:
|
|
1850
|
+
// `git worktree prune` running while a sibling is inside
|
|
1851
|
+
// `git worktree add` can remove the record of the worktree being created.
|
|
1852
|
+
if (!process.env.CSDA_HARNESS_WORKER) {
|
|
1853
|
+
git(projectDir, ["worktree", "prune"]);
|
|
1854
|
+
}
|
|
1855
|
+
const ctx = {
|
|
1856
|
+
projectDir,
|
|
1857
|
+
baseRef,
|
|
1858
|
+
settings,
|
|
1859
|
+
timeoutMs: args.timeout * 1000,
|
|
1860
|
+
timeoutSeconds: args.timeout,
|
|
1861
|
+
keepWorktrees: args.keepWorktrees,
|
|
1862
|
+
force: args.force,
|
|
1863
|
+
resume: args.resume,
|
|
1864
|
+
skipNotReady: args.skipNotReady,
|
|
1865
|
+
budgetSeconds: args.budgetSeconds,
|
|
1866
|
+
maxRequirements: args.maxRequirements,
|
|
1867
|
+
strictArtifacts: args.strictArtifacts,
|
|
1868
|
+
};
|
|
1869
|
+
const startedAt = new Date().toISOString();
|
|
1870
|
+
const results = await runLevels(pending, ctx, {
|
|
1871
|
+
concurrency: settings.concurrency,
|
|
1872
|
+
hintByReq,
|
|
1873
|
+
runOne: dispatchLevel,
|
|
1874
|
+
});
|
|
1875
|
+
// Only the run that a person started writes a record. A worker is one
|
|
1876
|
+
// slice of that run, and its own record would be a second copy of the
|
|
1877
|
+
// same requirement — `harness report` reads every file in the directory,
|
|
1878
|
+
// so duplicates land straight in "cost per delivered REQ".
|
|
1879
|
+
const recordPath = process.env.CSDA_HARNESS_WORKER
|
|
1880
|
+
? null
|
|
1881
|
+
: writeRunRecord(projectDir, {
|
|
1882
|
+
schemaVersion: 1,
|
|
1883
|
+
startedAt,
|
|
1884
|
+
finishedAt: new Date().toISOString(),
|
|
1885
|
+
baseRef,
|
|
1886
|
+
concurrency: settings.concurrency,
|
|
1887
|
+
maxAttempts: settings.maxAttempts,
|
|
1888
|
+
// Whether this run had a ceiling at all. Reading the ledger later,
|
|
1889
|
+
// a short run is a different thing depending on the answer.
|
|
1890
|
+
bounded: (0, RunBudget_1.hasBudget)({
|
|
1891
|
+
budgetSeconds: args.budgetSeconds,
|
|
1892
|
+
maxRequirements: args.maxRequirements,
|
|
1893
|
+
}),
|
|
1894
|
+
results,
|
|
1895
|
+
});
|
|
1896
|
+
printReport(results, args.format);
|
|
1897
|
+
if (recordPath && args.format !== "json") {
|
|
1898
|
+
info(`Run recorded in ${path.relative(projectDir, recordPath)} — \`specgate harness report\``);
|
|
1899
|
+
}
|
|
1900
|
+
// A blocked requirement was never attempted, so it is not a pass — but it
|
|
1901
|
+
// is also not evidence that anything is broken beyond the failure that
|
|
1902
|
+
// caused it. It still fails the run, because work was left undone.
|
|
1903
|
+
const failed = results.filter((r) => r.result !== "pass").length;
|
|
1904
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
1905
|
+
}
|
|
1906
|
+
catch (err) {
|
|
1907
|
+
let isJson = false;
|
|
1908
|
+
try {
|
|
1909
|
+
isJson = parseArgs(this.args).format === "json";
|
|
1910
|
+
}
|
|
1911
|
+
catch {
|
|
1912
|
+
/* ignore */
|
|
1913
|
+
}
|
|
1914
|
+
const io = (0, agent_1.agentIo)(isJson);
|
|
1915
|
+
return io.fail({ results: null }, [
|
|
1916
|
+
{ severity: "error", code: "harness_run_error", message: err.message || String(err) },
|
|
1917
|
+
]);
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
exports.RunCommand = RunCommand;
|
|
1922
|
+
//# sourceMappingURL=RunCommand.js.map
|