@khoavu882/doflow 2.0.0-beta.5 → 2.0.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/README.md +16 -16
- package/bin/doflow.js +22 -12
- package/core/.antigravity-plugin/plugin.json +1 -2
- package/core/.claude-plugin/plugin.json +1 -1
- package/core/.codex-plugin/plugin.json +1 -1
- package/core/.plugin/plugin.json +1 -1
- package/core/harnesses/claude/hooks/lib.sh +43 -24
- package/core/harnesses/claude/hooks/mcp-tool-guard.sh +4 -1
- package/core/harnesses/claude/hooks/permission-audit.sh +3 -0
- package/core/harnesses/claude/hooks/post-compact.sh +35 -32
- package/core/harnesses/claude/hooks/post-edit-lint.sh +2 -0
- package/core/harnesses/claude/hooks/post-shell-observe.sh +48 -0
- package/core/harnesses/claude/hooks/post-tool-failure.sh +3 -0
- package/core/harnesses/claude/hooks/pre-compact.sh +23 -27
- package/core/harnesses/claude/hooks/pre-implement-gate.sh +4 -1
- package/core/harnesses/claude/hooks/session-end.sh +23 -10
- package/core/harnesses/claude/hooks/skill-config-audit.sh +3 -0
- package/core/harnesses/claude/settings/settings.json +26 -9
- package/core/harnesses/claude/statusline/doflow-statusline.sh +1 -0
- package/core/harnesses/codex/agents/core-implementer.toml +1 -0
- package/core/harnesses/codex/agents/delivery-lead.toml +10 -0
- package/core/harnesses/codex/agents/quality-guardian.toml +1 -1
- package/core/harnesses/codex/agents/research-writer.toml +1 -1
- package/core/harnesses/codex/agents/reviewer.toml +10 -0
- package/core/harnesses/codex/agents/spec-analyst.toml +1 -1
- package/core/harnesses/codex/agents/system-architect.toml +1 -1
- package/core/harnesses/codex/hooks/hooks.json +0 -10
- package/core/harnesses/codex/hooks/lib.sh +44 -25
- package/core/harnesses/codex/hooks/mcp-tool-guard.sh +4 -1
- package/core/harnesses/codex/hooks/post-edit-lint.impl.sh +3 -1
- package/core/harnesses/codex/hooks/pre-compact.impl.sh +29 -26
- package/core/harnesses/codex/hooks/pre-implement-gate.sh +4 -1
- package/core/harnesses/codex/hooks/session-end.impl.sh +23 -10
- package/core/harnesses/codex/hooks/subagent-audit.sh +1 -0
- package/core/harnesses/copilot/hooks/doflow-lib.sh +94 -0
- package/core/harnesses/copilot/hooks/doflow-mcp-tool-guard.sh +9 -0
- package/core/harnesses/copilot/hooks/doflow-pre-implement-gate.sh +9 -0
- package/core/harnesses/copilot/hooks/doflow-session-start.sh +9 -0
- package/core/harnesses/copilot/hooks/doflow-stop-check.sh +9 -0
- package/core/harnesses/copilot/hooks/doflow-subagent-start.sh +9 -0
- package/core/harnesses/copilot/hooks/doflow-subagent-stop.sh +9 -0
- package/core/harnesses/copilot/hooks/doflow.json +51 -0
- package/core/harnesses/kiro/agents/doflow.json +1 -11
- package/core/harnesses/kiro/hooks/doflow.json +0 -10
- package/core/harnesses/kiro/hooks/lib.sh +26 -6
- package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +8 -3
- package/core/harnesses/kiro/hooks/pre-implement-gate.sh +8 -3
- package/core/harnesses/kiro/hooks/session-start.sh +1 -0
- package/core/harnesses/kiro/hooks/stop-check.sh +1 -0
- package/core/harnesses/pi/extensions/doflow-guard.js +11 -2
- package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +1 -2
- package/core/harnesses/shared/hooks/adapters/antigravity.js +1 -1
- package/core/harnesses/shared/hooks/bridge/bridge.sh +126 -23
- package/core/harnesses/shared/hooks/bridge/envelope.jq +87 -18
- package/core/harnesses/shared/hooks/bridge/observe-model.sh +75 -0
- package/core/harnesses/shared/hooks/bridge/respond.sh +16 -2
- package/core/harnesses/shared/hooks/policies/capture-failure.sh +49 -16
- package/core/harnesses/shared/hooks/policies/compact-text.sh +46 -0
- package/core/harnesses/shared/hooks/policies/contract.sh +3 -0
- package/core/harnesses/shared/hooks/policies/deny-json.sh +20 -7
- package/core/harnesses/shared/hooks/policies/emission-rules.sh +37 -0
- package/core/harnesses/shared/hooks/policies/emission.jq +209 -0
- package/core/harnesses/shared/hooks/policies/emission.sh +383 -0
- package/core/harnesses/shared/hooks/policies/identity.sh +177 -0
- package/core/harnesses/shared/hooks/policies/lib.sh +63 -40
- package/core/harnesses/shared/hooks/policies/mcp-policy.conf +1 -1
- package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +7 -8
- package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +132 -57
- package/core/harnesses/shared/hooks/policies/session-context.sh +30 -1
- package/core/harnesses/shared/hooks/policies/stop-check.sh +27 -15
- package/core/harnesses/shared/hooks/policies/subagent-audit.sh +6 -3
- package/core/harnesses/shared/hooks/policies/summary-store.sh +347 -0
- package/core/harnesses/shared/hooks/policies/user-prompt-submit.sh +60 -155
- package/core/harnesses/shared/hooks/stream-hook-runner.js +58 -20
- package/core/harnesses/shared/locator/doflow-run +2 -3
- package/core/registry/advice.json +3 -3
- package/core/registry/assets.json +55 -82
- package/core/registry/capabilities.json +12 -0
- package/core/registry/contracts.json +58 -31
- package/core/registry/contribution-points.json +35 -0
- package/core/registry/gates.json +32 -0
- package/core/registry/guidance-caps.json +40 -0
- package/core/registry/harnesses.json +1445 -366
- package/core/registry/intake.json +53 -0
- package/core/registry/lifecycle.json +599 -996
- package/core/registry/mcp.json +5 -1
- package/core/registry/models.json +7 -1
- package/core/registry/package-templates/README.md +19 -0
- package/core/registry/package-templates/agent/files/agent.md +9 -0
- package/core/registry/package-templates/agent/template.json +4 -0
- package/core/registry/package-templates/capability-provider/files/provider.json +9 -0
- package/core/registry/package-templates/capability-provider/template.json +11 -0
- package/core/registry/package-templates/debate-preset/files/preset.json +26 -0
- package/core/registry/package-templates/debate-preset/template.json +4 -0
- package/core/registry/package-templates/intake-category/files/category.json +8 -0
- package/core/registry/package-templates/intake-category/template.json +4 -0
- package/core/registry/package-templates/review-preset/files/preset.json +30 -0
- package/core/registry/package-templates/review-preset/template.json +4 -0
- package/core/registry/package-templates/skill/files/SKILL.md +8 -0
- package/core/registry/package-templates/skill/template.json +4 -0
- package/core/registry/package-templates/worker/files/worker.json +92 -0
- package/core/registry/package-templates/worker/template.json +11 -0
- package/core/registry/packages/doflow/doflow-package.json +15 -0
- package/core/registry/packages/doflow/presets/debate-3.json +26 -0
- package/core/registry/protocols.json +61 -0
- package/core/registry/routes.json +17 -0
- package/core/registry/rules.json +431 -0
- package/core/registry/settings.json +272 -0
- package/core/registry/storage.json +687 -31
- package/core/registry/telemetry.json +236 -0
- package/core/registry/transcripts.json +142 -0
- package/core/registry/verification.json +76 -6
- package/core/registry/workflows.json +55 -0
- package/core/shared/agent-specs/core-implementer.md +3 -0
- package/core/shared/agent-specs/delivery-lead.md +68 -0
- package/core/shared/agent-specs/quality-guardian.md +5 -1
- package/core/shared/agent-specs/research-writer.md +10 -3
- package/core/shared/agent-specs/reviewer.md +53 -0
- package/core/shared/agent-specs/spec-analyst.md +5 -6
- package/core/shared/agent-specs/system-architect.md +5 -1
- package/core/shared/guidance/DOFLOW_CORE.md +1 -5
- package/core/shared/guidance/modes/MODE_DeepResearch.md +1 -1
- package/core/shared/guidance/modes/MODE_Orchestration.md +4 -3
- package/core/shared/guidance/modes/MODE_Task_Management.md +14 -14
- package/core/shared/guidance/pointers/codex.md +1 -4
- package/core/shared/guidance/references/ARTIFACT_FORMAT.md +30 -16
- package/core/shared/guidance/references/ARTIFACT_VOICE.md +3 -2
- package/core/shared/guidance/references/CONSTITUTION_BASE.md +17 -15
- package/core/shared/guidance/references/DIAGRAM_GUIDE.md +1 -1
- package/core/shared/guidance/references/DOFLOW_CHAIN.md +17 -19
- package/core/shared/guidance/references/EVIDENCE_LEDGER.md +7 -8
- package/core/shared/guidance/references/MODEL_SELECTION.md +5 -3
- package/core/shared/guidance/references/ORCHESTRATION.md +3 -2
- package/core/shared/guidance/references/WORKFLOW_HANDOFF.md +19 -1
- package/core/shared/guidance/rules/RULES.md +45 -0
- package/core/shared/scripts/doflow/bash/do-args.sh +37 -0
- package/core/shared/scripts/doflow/bash/do-exec-paths.sh +4 -0
- package/core/shared/scripts/doflow/bash/do-git-state.sh +15 -4
- package/core/shared/scripts/doflow/bash/do-parallel-check.sh +4 -0
- package/core/shared/scripts/doflow/bash/do-paths.sh +40 -2
- package/core/shared/scripts/doflow/bash/do-prereqs.sh +2 -1
- package/core/shared/scripts/doflow/bash/do-review-package.sh +4 -0
- package/core/shared/scripts/doflow/bash/do-task-brief.sh +58 -16
- package/core/shared/scripts/doflow/bash/render-audit.sh +26 -2
- package/core/shared/scripts/doflow/bash/render-diagrams.sh +1 -0
- package/core/shared/scripts/doflow/bash/render-puml.sh +2 -1
- package/core/shared/scripts/doflow/bash/validate-artifacts.sh +49 -5
- package/core/shared/scripts/doflow/bin/doflow-run +152 -34
- package/core/shared/skills/do/SKILL.md +14 -12
- package/core/shared/skills/do/references/advice_calls.md +26 -0
- package/core/shared/skills/do/references/pm_routing.md +1 -2
- package/core/shared/skills/do/references/program_mode.md +31 -0
- package/core/shared/skills/do-brainstorm/SKILL.md +45 -97
- package/core/shared/skills/do-brainstorm/references/handoff_branches.md +36 -0
- package/core/shared/skills/do-brainstorm/references/intent_seed.md +21 -0
- package/core/shared/skills/do-brainstorm/references/round_log.md +17 -0
- package/core/shared/skills/do-code-review/SKILL.md +48 -148
- package/core/shared/skills/do-code-review/content-types/markdown.md +24 -2
- package/core/shared/skills/do-code-review/references/tools.md +90 -0
- package/core/shared/skills/do-code-review/scripts/doc_quality_checker.py +549 -7
- package/core/shared/skills/do-design/SKILL.md +23 -20
- package/core/shared/skills/do-diagnose/SKILL.md +18 -6
- package/core/shared/skills/do-document/SKILL.md +12 -1
- package/core/shared/skills/do-document/references/deep_research.md +1 -2
- package/core/shared/skills/do-document/references/embedded-research.md +19 -7
- package/core/shared/skills/do-document/references/implementation-flow.md +5 -6
- package/core/shared/skills/do-execute-plan/SKILL.md +19 -60
- package/core/shared/skills/do-execute-plan/references/handoff_record.md +7 -2
- package/core/shared/skills/do-execute-plan/references/parallel_dispatch.md +13 -0
- package/core/shared/skills/do-execute-plan/references/readiness_gate.md +26 -1
- package/core/shared/skills/do-execute-plan/references/run_position.md +16 -2
- package/core/shared/skills/do-execute-plan/references/scaffold.md +17 -9
- package/core/shared/skills/do-execute-plan/references/state_checkpoint.md +11 -0
- package/core/shared/skills/do-execute-plan/references/task_attempts.md +78 -24
- package/core/shared/skills/do-flow/SKILL.md +10 -11
- package/core/shared/skills/do-flow/references/task_classes.md +5 -5
- package/core/shared/skills/do-git/SKILL.md +4 -0
- package/core/shared/skills/do-implement/SKILL.md +13 -14
- package/core/shared/skills/do-plan/SKILL.md +34 -20
- package/core/shared/skills/do-test/SKILL.md +7 -4
- package/core/shared/templates/doflow/constitution-template.md +9 -3
- package/core/shared/templates/doflow/data-model-template.md +1 -1
- package/core/shared/templates/doflow/design-template.md +113 -16
- package/core/shared/templates/doflow/plan-template.md +11 -10
- package/core/shared/templates/doflow/question-log-template.md +14 -2
- package/core/shared/templates/doflow/requirement-template.md +1 -1
- package/core/shared/workflows/do-flow-chain.md +2 -0
- package/core/worker-evidence/antigravity/checks.json +14 -0
- package/core/worker-evidence/codex/2026-10-18-cli-check.json +107 -0
- package/core/worker-evidence/codex/2026-10-18-live-run-edit.json +73 -0
- package/core/worker-evidence/codex/2026-10-18-live-run-read-only.json +62 -0
- package/core/worker-evidence/codex/2026-10-24-cli-check.json +126 -0
- package/core/worker-evidence/codex/checks.json +38 -0
- package/package.json +10 -6
- package/src/adapters/antigravity/index.js +143 -66
- package/src/adapters/claude/index.js +46 -5
- package/src/adapters/codex/agents.js +25 -15
- package/src/adapters/codex/hooks.js +39 -3
- package/src/adapters/codex/index.js +61 -13
- package/src/adapters/codex/native-projection.js +6 -2
- package/src/adapters/copilot/index.js +58 -23
- package/src/adapters/copy-tree.js +291 -46
- package/src/adapters/guidance-manifest.js +71 -0
- package/src/adapters/guidance-pointer.js +19 -0
- package/src/adapters/hook-commands.js +6 -10
- package/src/adapters/index.js +1 -0
- package/src/adapters/kiro/index.js +23 -12
- package/src/adapters/opencode/index.js +8 -8
- package/src/adapters/pi/hooks.js +2 -1
- package/src/adapters/pi/index.js +14 -7
- package/src/adapters/resolve-source.js +26 -2
- package/src/cli/commands/install.js +2 -1
- package/src/cli/commands/reconcile.js +53 -9
- package/src/cli/commands/remove.js +2 -2
- package/src/cli/commands/rollback.js +6 -3
- package/src/cli/commands/self-update.js +18 -9
- package/src/cli/commands/status.js +22 -3
- package/src/cli/commands/ui.js +99 -0
- package/src/cli/commands/update.js +2 -1
- package/src/cli/index.js +337 -32
- package/src/cli/runtime-commands.js +220 -60
- package/src/cli/shared.js +0 -2
- package/src/helper/git.js +1 -1
- package/src/helper/guardrails.js +1 -1
- package/src/helper/marker-merge.js +31 -13
- package/src/helper/package-ids.js +37 -0
- package/src/helper/semver.js +94 -0
- package/src/install/backup.js +105 -15
- package/src/install/context.js +1 -1
- package/src/install/targets.js +31 -16
- package/src/lifecycle/coverage.js +4 -16
- package/src/lifecycle/hook-tier.js +160 -0
- package/src/lifecycle/index.js +412 -78
- package/src/lifecycle/retired-harnesses.js +318 -0
- package/src/lifecycle/view.js +35 -15
- package/src/registry/index.js +551 -26
- package/src/registry/worker-ids.js +45 -0
- package/src/release/build-package.js +0 -3
- package/src/release/docs-blocks.js +472 -0
- package/src/release/preflight.js +425 -0
- package/src/release/rehearse.js +401 -0
- package/src/release/release-workflow.js +733 -0
- package/src/release/sync-plugin-versions.js +43 -7
- package/src/runtime/advice/bench-gate.js +25 -0
- package/src/runtime/advice/cli.js +59 -16
- package/src/runtime/advice/client.js +32 -9
- package/src/runtime/advice/config.js +98 -133
- package/src/runtime/advice/doctor.js +1 -1
- package/src/runtime/advice/elevation.js +97 -0
- package/src/runtime/advice/endpoint-guard.js +9 -3
- package/src/runtime/advice/mode.js +37 -0
- package/src/runtime/advice/record.js +18 -5
- package/src/runtime/advice/registry.js +4 -1
- package/src/runtime/advice/shadow-config.js +229 -0
- package/src/runtime/advice/shadow-dispatcher.js +276 -0
- package/src/runtime/brief-rules.js +81 -0
- package/src/runtime/brief-tools.js +111 -34
- package/src/runtime/candidate.js +20 -11
- package/src/runtime/capabilities/executor.js +450 -0
- package/src/runtime/capabilities/loopback.js +41 -0
- package/src/runtime/capabilities/sanitizer.js +88 -0
- package/src/runtime/capability-router.js +135 -3
- package/src/runtime/checkouts.js +73 -11
- package/src/runtime/claims.js +13 -7
- package/src/runtime/cli.js +196 -19
- package/src/runtime/config-home.js +42 -0
- package/src/runtime/context/arbitrate.js +767 -0
- package/src/runtime/context/delivery-store.js +4 -6
- package/src/runtime/context/emission-rules.js +118 -0
- package/src/runtime/context/identity.js +227 -0
- package/src/runtime/context/pointer.js +67 -12
- package/src/runtime/context/primary-command.js +117 -0
- package/src/runtime/context/primary.js +344 -0
- package/src/runtime/context/summary-store.js +655 -0
- package/src/runtime/context-pack.js +158 -79
- package/src/runtime/decision-register.js +13 -4
- package/src/runtime/dispatch/admission.js +392 -0
- package/src/runtime/dispatch/attempt-end.js +274 -0
- package/src/runtime/dispatch/command.js +628 -0
- package/src/runtime/dispatch/controls.js +904 -0
- package/src/runtime/dispatch/envelope.js +276 -0
- package/src/runtime/dispatch/grants.js +310 -0
- package/src/runtime/dispatch/integrate.js +617 -0
- package/src/runtime/dispatch/loop.js +1835 -0
- package/src/runtime/dispatch/store.js +352 -0
- package/src/runtime/dispatch/verify-run.js +201 -0
- package/src/runtime/evidence-ledger.js +52 -7
- package/src/runtime/failure/capture.js +3 -1
- package/src/runtime/failure/cli.js +25 -9
- package/src/runtime/failure/home.js +57 -30
- package/src/runtime/failure/store.js +7 -2
- package/src/runtime/feature-pointer.js +174 -0
- package/src/runtime/feature-resolve.js +13 -2
- package/src/runtime/fleet/command.js +77 -0
- package/src/runtime/fleet/projects.js +178 -0
- package/src/runtime/fleet/tree.js +383 -0
- package/src/runtime/freshness.js +33 -3
- package/src/runtime/git-memo.js +46 -0
- package/src/runtime/guidance/ablation.js +427 -0
- package/src/runtime/health.js +624 -18
- package/src/runtime/history-compactor.js +13 -3
- package/src/runtime/implementation-gate.js +281 -26
- package/src/runtime/intake/engine.js +677 -0
- package/src/runtime/intake/index.js +136 -0
- package/src/runtime/intake/manifest.js +140 -0
- package/src/runtime/intake/taxonomy.js +206 -0
- package/src/runtime/inventory/coverage.js +1 -1
- package/src/runtime/inventory/index.js +1 -1
- package/src/runtime/inventory/precedence.js +2 -2
- package/src/runtime/inventory/siblings.js +6 -4
- package/src/runtime/knowledge/code-corpus.js +277 -0
- package/src/runtime/knowledge/code-lexical.js +130 -0
- package/src/runtime/knowledge/code-retrieval.js +307 -0
- package/src/runtime/knowledge/code-validation.js +106 -0
- package/src/runtime/knowledge/gp3-gate.js +74 -0
- package/src/runtime/knowledge/graph-adapter.js +288 -0
- package/src/runtime/knowledge/graph-fallback.js +394 -0
- package/src/runtime/knowledge/hybrid.js +136 -8
- package/src/runtime/knowledge/retrieval.js +34 -1
- package/src/runtime/knowledge/rtk-compress.js +77 -0
- package/src/runtime/knowledge/semble-bridge.py +106 -0
- package/src/runtime/knowledge/semble-lifecycle.js +114 -0
- package/src/runtime/knowledge/semble-provider.js +365 -0
- package/src/runtime/lifecycle/cli.js +88 -10
- package/src/runtime/lifecycle/event-store.js +8 -6
- package/src/runtime/lifecycle/followup.js +38 -7
- package/src/runtime/lifecycle/overview.js +26 -2
- package/src/runtime/lifecycle/report-store.js +8 -10
- package/src/runtime/lifecycle/retention.js +95 -31
- package/src/runtime/lifecycle/root.js +9 -1
- package/src/runtime/lifecycle/status.js +34 -17
- package/src/runtime/lifecycle/store-upkeep.js +56 -7
- package/src/runtime/mask.js +134 -7
- package/src/runtime/mcp-consultations.js +40 -6
- package/src/runtime/model-router.js +79 -7
- package/src/runtime/models/catalogue.js +191 -0
- package/src/runtime/models/command.js +153 -0
- package/src/runtime/off-words.js +12 -0
- package/src/runtime/outcome.js +2 -2
- package/src/runtime/packages/command.js +1009 -0
- package/src/runtime/packages/contributions.js +116 -0
- package/src/runtime/packages/digest.js +132 -0
- package/src/runtime/packages/fetch.js +182 -0
- package/src/runtime/packages/grants.js +192 -0
- package/src/runtime/packages/index-file.js +134 -0
- package/src/runtime/packages/instruction.js +90 -0
- package/src/runtime/packages/manifest.js +331 -0
- package/src/runtime/packages/points.js +487 -0
- package/src/runtime/packages/programs.js +216 -0
- package/src/runtime/packages/scaffold.js +277 -0
- package/src/runtime/packages/source.js +90 -0
- package/src/runtime/packages/state.js +164 -0
- package/src/runtime/packages/store.js +449 -0
- package/src/runtime/packages/tar.js +106 -0
- package/src/runtime/packages/view.js +406 -0
- package/src/runtime/plan-check.js +59 -0
- package/src/runtime/plan-content.js +324 -0
- package/src/runtime/policy-init.js +118 -0
- package/src/runtime/profiles/doctor.js +96 -0
- package/src/runtime/profiles/resolve.js +244 -0
- package/src/runtime/profiles/rollback.js +254 -0
- package/src/runtime/profiles/sampler.js +41 -0
- package/src/runtime/readiness-record.js +16 -11
- package/src/runtime/readiness.js +46 -5
- package/src/runtime/research-request.js +50 -25
- package/src/runtime/retrieval-plan.js +10 -30
- package/src/runtime/retrieval-record.js +75 -5
- package/src/runtime/review/command.js +246 -0
- package/src/runtime/review/dominance.js +68 -0
- package/src/runtime/review/findings.js +434 -0
- package/src/runtime/review/identity.js +255 -0
- package/src/runtime/review/protocol-command.js +109 -0
- package/src/runtime/review/protocols.js +405 -0
- package/src/runtime/review/resolve-protocol.js +302 -0
- package/src/runtime/review/start.js +530 -0
- package/src/runtime/roadmap/attendance.js +137 -0
- package/src/runtime/roadmap/facts.js +479 -0
- package/src/runtime/roadmap/gates.js +273 -0
- package/src/runtime/roadmap/loop.js +1719 -0
- package/src/runtime/roadmap/merge.js +390 -0
- package/src/runtime/roadmap/rows.js +1122 -0
- package/src/runtime/roadmap/seed.js +1063 -0
- package/src/runtime/roadmap/worktree-cleanup.js +147 -0
- package/src/runtime/rules-report.js +336 -0
- package/src/runtime/scaffold/artifacts.js +60 -9
- package/src/runtime/scaffold/generate.js +21 -9
- package/src/runtime/settings/autopilot.js +106 -0
- package/src/runtime/settings/cli.js +280 -0
- package/src/runtime/settings/config-write.js +314 -0
- package/src/runtime/settings/files.js +271 -0
- package/src/runtime/settings/legacy.js +154 -0
- package/src/runtime/settings/lexical.js +56 -0
- package/src/runtime/settings/prepared-run.js +269 -0
- package/src/runtime/settings/project-layers.js +118 -0
- package/src/runtime/settings/refs.js +288 -0
- package/src/runtime/settings/registry.js +553 -0
- package/src/runtime/settings/resolve.js +713 -0
- package/src/runtime/settings/shared.js +37 -0
- package/src/runtime/settings/worker-billing.js +107 -0
- package/src/runtime/settings/write.js +269 -0
- package/src/runtime/slot-command.js +78 -0
- package/src/runtime/slots.js +671 -0
- package/src/runtime/source-version.js +1 -0
- package/src/runtime/state-root.js +217 -0
- package/src/runtime/storage/cli.js +12 -5
- package/src/runtime/storage/engine.js +9 -2
- package/src/runtime/storage/registry.js +14 -1
- package/src/runtime/storage/roots.js +66 -16
- package/src/runtime/storage/rules.js +82 -10
- package/src/runtime/storage/upkeep.js +10 -2
- package/src/runtime/storage/work-status.js +245 -26
- package/src/runtime/task-attempts.js +589 -69
- package/src/runtime/task-classifier.js +85 -14
- package/src/runtime/task-graph.js +373 -0
- package/src/runtime/task-run.js +778 -0
- package/src/runtime/task-scope.js +176 -55
- package/src/runtime/task-sources.js +147 -0
- package/src/runtime/task-state.js +373 -64
- package/src/runtime/team/admission.js +301 -0
- package/src/runtime/team/envelope.js +324 -0
- package/src/runtime/team/review-guard.js +156 -0
- package/src/runtime/trace/analyze.js +427 -0
- package/src/runtime/trace/baseline.js +770 -0
- package/src/runtime/trace/ledger.js +63 -140
- package/src/runtime/trace/otlp-export.js +568 -0
- package/src/runtime/trace/otlp-map.js +481 -0
- package/src/runtime/trace/spans.js +745 -0
- package/src/runtime/trace/telemetry.js +225 -0
- package/src/runtime/trace/views.js +1 -0
- package/src/runtime/transcripts/command.js +98 -0
- package/src/runtime/transcripts/cursor.js +326 -0
- package/src/runtime/transcripts/read.js +126 -0
- package/src/runtime/transcripts/readers/claude-jsonl.js +74 -0
- package/src/runtime/transcripts/readers/codex-rollout.js +70 -0
- package/src/runtime/transcripts/readers/copilot-events.js +90 -0
- package/src/runtime/transcripts/readers/kiro-jsonl.js +77 -0
- package/src/runtime/transcripts/readers/pi-jsonl.js +59 -0
- package/src/runtime/transcripts/registry.js +161 -0
- package/src/runtime/transcripts/stats.js +161 -0
- package/src/runtime/verb-view.js +267 -0
- package/src/runtime/verification/check-key.js +628 -0
- package/src/runtime/verification/contract-runner.js +546 -11
- package/src/runtime/verification/engine.js +499 -54
- package/src/runtime/verification/impact.js +617 -0
- package/src/runtime/verification/inflight.js +205 -0
- package/src/runtime/verification/registry.js +176 -0
- package/src/runtime/verification/reuse.js +184 -0
- package/src/runtime/verification/telemetry-emit.js +254 -0
- package/src/runtime/verification/test-selection.js +67 -7
- package/src/runtime/worker/active.js +90 -0
- package/src/runtime/worker/cancel.js +222 -0
- package/src/runtime/worker/catalogue.js +122 -0
- package/src/runtime/worker/command.js +677 -0
- package/src/runtime/worker/evidence.js +288 -0
- package/src/runtime/worker/launch.js +256 -0
- package/src/runtime/worker/models.js +137 -0
- package/src/runtime/worker/parsers.js +218 -0
- package/src/runtime/worker/probes/index.js +706 -0
- package/src/runtime/worker/process-tree.js +415 -0
- package/src/runtime/worker/quota.js +43 -0
- package/src/runtime/worker/readiness.js +713 -0
- package/src/runtime/worker/run-store.js +166 -0
- package/src/runtime/worker/supervisor.js +541 -0
- package/src/runtime/worker/worktree-check.js +252 -0
- package/src/runtime/workflow-engine.js +5 -1
- package/src/runtime/workflow-orchestrator.js +610 -67
- package/src/runtime/worktree.js +149 -22
- package/src/state/index.js +1 -1
- package/src/state/lockfile.js +64 -10
- package/src/state/run-lock.js +1 -1
- package/types/node-commonjs.d.ts +106 -7
- package/core/harnesses/claude/hooks/pre-bash-guard.sh +0 -10
- package/core/harnesses/codex/hooks/pre-bash-guard.sh +0 -10
- package/core/harnesses/gemini/hooks/hooks.json +0 -77
- package/core/harnesses/gemini/hooks/lib.sh +0 -302
- package/core/harnesses/gemini/hooks/mcp-tool-guard.sh +0 -10
- package/core/harnesses/gemini/hooks/post-edit-lint.sh +0 -39
- package/core/harnesses/gemini/hooks/pre-bash-guard.sh +0 -10
- package/core/harnesses/gemini/hooks/pre-compact.sh +0 -38
- package/core/harnesses/gemini/hooks/pre-implement-gate.sh +0 -10
- package/core/harnesses/gemini/hooks/session-end.impl.sh +0 -89
- package/core/harnesses/gemini/hooks/session-end.sh +0 -5
- package/core/harnesses/gemini/hooks/session-start.sh +0 -7
- package/core/harnesses/gemini/settings/adapter-defaults.json +0 -4
- package/core/harnesses/kiro/hooks/pre-bash-guard.sh +0 -24
- package/core/harnesses/shared/hooks/policies/blocked-patterns.conf +0 -91
- package/core/harnesses/shared/hooks/policies/pre-bash-guard.sh +0 -378
- package/core/shared/guidance/FLAGS.md +0 -25
- package/core/shared/guidance/rules/RULE_01_SAFETY.md +0 -25
- package/core/shared/guidance/rules/RULE_02_WORKFLOW.md +0 -23
- package/core/shared/guidance/rules/RULE_03_QUALITY.md +0 -14
- package/core/shared/guidance/rules/RULE_04_QUESTIONS.md +0 -46
- package/src/adapters/gemini/hooks.js +0 -251
- package/src/adapters/gemini/index.js +0 -429
|
@@ -0,0 +1,1719 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Roadmap loop verb and CLI handler (feature 108, IC-001; FR-001..FR-003, FR-008, FR-014, FR-016,
|
|
5
|
+
* FR-017, FR-019..FR-024; IC-008, IC-010, IC-011, IC-013). Actions: render [--check], status, next
|
|
6
|
+
* and audit. `next` is one deterministic step: kill switch, lease, run record and budget, open merge
|
|
7
|
+
* intent, in-flight rows (perform check, gate, perform envelope, dispatch outcome, merge), pick.
|
|
8
|
+
* Mechanical work spawns only git; skill stages and the implementation dispatch are yielded as
|
|
9
|
+
* `perform` envelopes for the session. Text read from rows, plans and review files is data only.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const crypto = require('node:crypto');
|
|
16
|
+
const { execFileSync: defaultExecFileSync } = require('node:child_process');
|
|
17
|
+
const { listCheckouts } = require('../checkouts.js');
|
|
18
|
+
const { withGitMemo } = require('../git-memo.js');
|
|
19
|
+
const {
|
|
20
|
+
parseSeed,
|
|
21
|
+
deriveStatus,
|
|
22
|
+
pick,
|
|
23
|
+
render,
|
|
24
|
+
renderRoadmapFile,
|
|
25
|
+
check,
|
|
26
|
+
LOOP_CLASSES,
|
|
27
|
+
} = require('./rows.js');
|
|
28
|
+
const { gatherGitFacts, sha256Digest } = require('./facts.js');
|
|
29
|
+
const { loadPolicy, verifyRecordReasons } = require('./gates.js');
|
|
30
|
+
const { mergeRoadmapRow, readOpenIntents, cleanupFeature } = require('./merge.js');
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
// The kill switch (IC-011, FR-016, decisions C9, C4) is settings/autopilot.ts: the env
|
|
48
|
+
// DOFLOW_AUTOPILOT_ENABLED, the `autopilot/off` sentinel and the `autopilot.enabled` setting. It is
|
|
49
|
+
// loaded on use, so commands that never step the loop (an install dry run) load no settings module.
|
|
50
|
+
|
|
51
|
+
function resolveMainRoot(cwd = process.cwd()) {
|
|
52
|
+
try {
|
|
53
|
+
const info = listCheckouts({ cwd });
|
|
54
|
+
if (info && info.ok && info.main) return info.main;
|
|
55
|
+
} catch {
|
|
56
|
+
// fallback
|
|
57
|
+
}
|
|
58
|
+
return path.resolve(cwd);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function usageError(message , json = false) {
|
|
62
|
+
if (json) {
|
|
63
|
+
console.log(JSON.stringify({ error: 'usage', detail: message }));
|
|
64
|
+
} else {
|
|
65
|
+
console.error(`doflow roadmap: error: ${message}`);
|
|
66
|
+
}
|
|
67
|
+
process.exitCode = 2;
|
|
68
|
+
return 2;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* `init-policy`: copies the installed DoFlow's gates.json to `<project>/core/registry/gates.json` for a
|
|
73
|
+
* person to review and commit on develop. It refuses an existing file and runs no command; the loop
|
|
74
|
+
* still reads the policy only from develop's committed head, never from this copy.
|
|
75
|
+
*/
|
|
76
|
+
function initPolicyCommand(options ) {
|
|
77
|
+
const { json = false, fsImpl = fs } = options;
|
|
78
|
+
const root = options.root || resolveMainRoot(options.cwd || process.cwd());
|
|
79
|
+
const rel = 'core/registry/gates.json';
|
|
80
|
+
const target = path.join(root, rel);
|
|
81
|
+
const fail = (message ) => {
|
|
82
|
+
if (json) console.log(JSON.stringify({ schema: 1, action: 'init-policy', error: 'not-written', detail: message }));
|
|
83
|
+
else console.error(`doflow roadmap: ${message}`);
|
|
84
|
+
process.exitCode = 1;
|
|
85
|
+
return 1;
|
|
86
|
+
};
|
|
87
|
+
const bytes = fsImpl.readFileSync(path.join(options.repoRoot, rel));
|
|
88
|
+
try {
|
|
89
|
+
fsImpl.mkdirSync(path.dirname(target), { recursive: true });
|
|
90
|
+
fsImpl.writeFileSync(target, bytes, { flag: 'wx' });
|
|
91
|
+
} catch (error ) {
|
|
92
|
+
return fail(error.code === 'EEXIST' ? `${target} already exists; it was not overwritten` : `cannot write ${target}: ${error.message}`);
|
|
93
|
+
}
|
|
94
|
+
if (json) console.log(JSON.stringify({ schema: 1, action: 'init-policy', written: rel, root }));
|
|
95
|
+
else console.log(`wrote ${target}\nReview it, then commit this on develop. The loop reads the policy only from develop's committed head.`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Dispatches roadmap CLI actions: render [--check], status, next, audit and init-policy. `next` resolves to a
|
|
101
|
+
* promise of the exit code (the merge step awaits its fast-forward child).
|
|
102
|
+
*/
|
|
103
|
+
function handleRoadmapCommand(options = {}) {
|
|
104
|
+
const {
|
|
105
|
+
action,
|
|
106
|
+
check: isCheck = false,
|
|
107
|
+
json = false,
|
|
108
|
+
row: filterRow,
|
|
109
|
+
cwd = process.cwd(),
|
|
110
|
+
fsImpl = fs,
|
|
111
|
+
} = options;
|
|
112
|
+
|
|
113
|
+
// `init-policy --commit` is a person-run command that lives outside this tree (src/runtime/policy-init.js); the loop never commits.
|
|
114
|
+
if (options.commit) return usageError('--commit belongs to --action init-policy', json);
|
|
115
|
+
if (action === 'next') return nextCommand(options);
|
|
116
|
+
if (action === 'audit') return auditCommand(options);
|
|
117
|
+
if (action === 'init-policy') return initPolicyCommand(options);
|
|
118
|
+
if (!action || (action !== 'render' && action !== 'status')) {
|
|
119
|
+
return usageError("--action must be 'render', 'status', 'next', 'audit' or 'init-policy'", json);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const root = options.root || resolveMainRoot(cwd);
|
|
123
|
+
const seedFile = options.seedPath || path.join(root, 'agent-docs', 'roadmap', 'rows.json');
|
|
124
|
+
const roadmapFile = options.roadmapPath || path.join(root, 'agent-docs', 'roadmap', 'ROADMAP.md');
|
|
125
|
+
|
|
126
|
+
let seedRaw = null;
|
|
127
|
+
let parsedSeed = null;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
seedRaw = fsImpl.readFileSync(seedFile, 'utf8');
|
|
131
|
+
} catch (err ) {
|
|
132
|
+
return usageError(`failed to read seed file at ${seedFile}: ${err.message}`, json);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let seedParseError = null;
|
|
136
|
+
try {
|
|
137
|
+
parsedSeed = parseSeed(seedRaw);
|
|
138
|
+
} catch (err ) {
|
|
139
|
+
seedParseError = err;
|
|
140
|
+
// In render --check, a seed parse error (e.g. typed-status, duplicate-id, needs-unresolved)
|
|
141
|
+
// is recorded as a check finding rather than crashing usage.
|
|
142
|
+
if (!isCheck) {
|
|
143
|
+
return usageError(`invalid seed: ${err.message}`, json);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const rows = parsedSeed?.rows || [];
|
|
148
|
+
const factsSnapshot = options.gitFacts || gatherGitFacts({ root });
|
|
149
|
+
|
|
150
|
+
if (factsSnapshot.unreadable && factsSnapshot.unreadable.length > 0) {
|
|
151
|
+
const detail = factsSnapshot.unreadable.join(', ');
|
|
152
|
+
if (json) {
|
|
153
|
+
console.log(
|
|
154
|
+
JSON.stringify({
|
|
155
|
+
schema: 1,
|
|
156
|
+
action,
|
|
157
|
+
error: 'cannot-read-facts',
|
|
158
|
+
sources: factsSnapshot.unreadable,
|
|
159
|
+
message: `doflow roadmap: cannot read facts: ${detail}`,
|
|
160
|
+
})
|
|
161
|
+
);
|
|
162
|
+
} else {
|
|
163
|
+
console.error(`doflow roadmap: cannot read facts: ${detail}`);
|
|
164
|
+
}
|
|
165
|
+
process.exitCode = 1;
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const { statuses, rowStatus } = deriveStatus(rows, factsSnapshot);
|
|
170
|
+
|
|
171
|
+
if (action === 'status') {
|
|
172
|
+
let openIntents ;
|
|
173
|
+
let operational ;
|
|
174
|
+
try {
|
|
175
|
+
try { openIntents = readOpenIntents({ root, fsImpl }); }
|
|
176
|
+
catch (error ) { throw new Unreadable(['merge-intents'], error.message); }
|
|
177
|
+
operational = operationalState(path.join(root, '.doflow', 'state', 'roadmap'), fsImpl);
|
|
178
|
+
} catch (error ) {
|
|
179
|
+
const sources = error instanceof Unreadable ? error.sources : ['run-records'];
|
|
180
|
+
if (json) console.log(JSON.stringify({ schema: 1, action, error: 'cannot-read-facts', sources, message: error.message }));
|
|
181
|
+
else console.error(`doflow roadmap: cannot read ${sources.join(', ')}: ${error.message}`);
|
|
182
|
+
process.exitCode = 1;
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
const holdsOf = (id ) => operational.holds.filter((hold ) => !id || hold.row === id);
|
|
186
|
+
const printOperational = (id ) => {
|
|
187
|
+
for (const hold of holdsOf(id)) console.log(` Run hold ${hold.row}: ${hold.state}${hold.kind ? ` (${hold.kind})` : ''}: ${hold.reason}`);
|
|
188
|
+
const lease = operational.lease;
|
|
189
|
+
console.log(lease ? ` Lease: pid ${lease.pid} on ${lease.host}${lease.runId ? ` for ${lease.runId}` : ''}` : ' Lease: none');
|
|
190
|
+
};
|
|
191
|
+
const displayStatus = (info ) => openIntents.some((intent) => intent.row === info.rowId && intent.status === 'merged-unverified')
|
|
192
|
+
? 'merged-unverified' : info.status;
|
|
193
|
+
const displayRows = Array.from(statuses.values() ).map((info) => ({ ...info, status: displayStatus(info) }));
|
|
194
|
+
const displayRowStatus = { ...rowStatus };
|
|
195
|
+
for (const info of displayRows) displayRowStatus[info.rowId] = info.status;
|
|
196
|
+
if (filterRow) {
|
|
197
|
+
const singleStatus = statuses.get(filterRow);
|
|
198
|
+
if (!singleStatus) {
|
|
199
|
+
if (json) {
|
|
200
|
+
console.log(
|
|
201
|
+
JSON.stringify({
|
|
202
|
+
schema: 1,
|
|
203
|
+
action: 'status',
|
|
204
|
+
error: 'row-not-found',
|
|
205
|
+
row: filterRow,
|
|
206
|
+
})
|
|
207
|
+
);
|
|
208
|
+
} else {
|
|
209
|
+
console.error(`doflow roadmap: row '${filterRow}' not found`);
|
|
210
|
+
}
|
|
211
|
+
process.exitCode = 1;
|
|
212
|
+
return 1;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (json) {
|
|
216
|
+
console.log(
|
|
217
|
+
JSON.stringify({
|
|
218
|
+
schema: 1,
|
|
219
|
+
action: 'status',
|
|
220
|
+
row: filterRow,
|
|
221
|
+
status: displayStatus(singleStatus),
|
|
222
|
+
info: singleStatus,
|
|
223
|
+
intent: openIntents.find((intent) => intent.row === filterRow) || null,
|
|
224
|
+
run: operational.run,
|
|
225
|
+
holds: holdsOf(filterRow),
|
|
226
|
+
lease: operational.lease,
|
|
227
|
+
})
|
|
228
|
+
);
|
|
229
|
+
} else {
|
|
230
|
+
console.log(
|
|
231
|
+
`Row ${filterRow}: ${displayStatus(singleStatus)} (evidence: ${
|
|
232
|
+
singleStatus.evidence ? singleStatus.evidence.shortSha : 'none'
|
|
233
|
+
})`
|
|
234
|
+
);
|
|
235
|
+
const intent = openIntents.find((entry) => entry.row === filterRow);
|
|
236
|
+
if (intent) console.log(` Open merge intent: ${intent.phase}${intent.status ? ` (${intent.status})` : ''}`);
|
|
237
|
+
printOperational(filterRow);
|
|
238
|
+
}
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (json) {
|
|
243
|
+
console.log(
|
|
244
|
+
JSON.stringify({
|
|
245
|
+
schema: 1,
|
|
246
|
+
action: 'status',
|
|
247
|
+
rowStatus: displayRowStatus,
|
|
248
|
+
rows: displayRows,
|
|
249
|
+
openIntents,
|
|
250
|
+
run: operational.run,
|
|
251
|
+
holds: holdsOf(null),
|
|
252
|
+
lease: operational.lease,
|
|
253
|
+
})
|
|
254
|
+
);
|
|
255
|
+
} else {
|
|
256
|
+
console.log(`Roadmap status (${rows.length} rows):`);
|
|
257
|
+
for (const [id, info] of statuses.entries()) {
|
|
258
|
+
const ev = info.evidence ? `${info.evidence.shortSha} (${info.evidence.kind})` : '-';
|
|
259
|
+
console.log(` ${id.padEnd(6)} ${displayStatus(info).padEnd(12)} evidence: ${ev}`);
|
|
260
|
+
}
|
|
261
|
+
for (const intent of openIntents) console.log(` Open merge intent ${intent.row}: ${intent.phase}${intent.status ? ` (${intent.status})` : ''}`);
|
|
262
|
+
printOperational(null);
|
|
263
|
+
}
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (action === 'render') {
|
|
268
|
+
if (isCheck) {
|
|
269
|
+
let existingContent = null;
|
|
270
|
+
try {
|
|
271
|
+
existingContent = fsImpl.readFileSync(roadmapFile, 'utf8');
|
|
272
|
+
} catch {
|
|
273
|
+
existingContent = null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const checkResult = check({
|
|
277
|
+
rows,
|
|
278
|
+
gitFacts: factsSnapshot,
|
|
279
|
+
rendered: existingContent,
|
|
280
|
+
seedRaw,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// If parseSeed threw a specific validation error, ensure it is represented
|
|
284
|
+
if (seedParseError && seedParseError.code) {
|
|
285
|
+
if (!checkResult.findings.some((f ) => f.rule === seedParseError.code)) {
|
|
286
|
+
checkResult.findings.push({
|
|
287
|
+
rule: seedParseError.code ,
|
|
288
|
+
message: seedParseError.message,
|
|
289
|
+
rowId: seedParseError.rowId,
|
|
290
|
+
});
|
|
291
|
+
if (seedParseError.rowId && !checkResult.offendingRowIds.includes(seedParseError.rowId)) {
|
|
292
|
+
checkResult.offendingRowIds.push(seedParseError.rowId);
|
|
293
|
+
}
|
|
294
|
+
checkResult.ok = false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (checkResult.ok) {
|
|
299
|
+
if (json) {
|
|
300
|
+
console.log(
|
|
301
|
+
JSON.stringify({
|
|
302
|
+
schema: 1,
|
|
303
|
+
action: 'render',
|
|
304
|
+
check: true,
|
|
305
|
+
exit: 0,
|
|
306
|
+
findings: [],
|
|
307
|
+
offendingRowIds: [],
|
|
308
|
+
})
|
|
309
|
+
);
|
|
310
|
+
} else {
|
|
311
|
+
console.log('doflow roadmap: render --check passed (0 findings)');
|
|
312
|
+
}
|
|
313
|
+
return 0;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (json) {
|
|
317
|
+
console.log(
|
|
318
|
+
JSON.stringify({
|
|
319
|
+
schema: 1,
|
|
320
|
+
action: 'render',
|
|
321
|
+
check: true,
|
|
322
|
+
exit: 1,
|
|
323
|
+
findings: checkResult.findings,
|
|
324
|
+
offendingRowIds: checkResult.offendingRowIds,
|
|
325
|
+
})
|
|
326
|
+
);
|
|
327
|
+
} else {
|
|
328
|
+
console.error(`doflow roadmap: render --check found ${checkResult.findings.length} issue(s):`);
|
|
329
|
+
for (const f of checkResult.findings) {
|
|
330
|
+
console.error(` [${f.rule}] ${f.message}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
process.exitCode = 1;
|
|
334
|
+
return 1;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Normal render: write ROADMAP.md if bytes differ
|
|
338
|
+
const renderedText = render(rows, statuses);
|
|
339
|
+
const { written } = renderRoadmapFile({
|
|
340
|
+
roadmapPath: roadmapFile,
|
|
341
|
+
content: renderedText,
|
|
342
|
+
fsImpl,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
if (json) {
|
|
346
|
+
console.log(
|
|
347
|
+
JSON.stringify({
|
|
348
|
+
schema: 1,
|
|
349
|
+
action: 'render',
|
|
350
|
+
fileWrites: written ? 1 : 0,
|
|
351
|
+
path: path.relative(root, roadmapFile) || roadmapFile,
|
|
352
|
+
})
|
|
353
|
+
);
|
|
354
|
+
} else {
|
|
355
|
+
console.log(
|
|
356
|
+
`doflow roadmap: rendered ${path.relative(root, roadmapFile) || roadmapFile} (${
|
|
357
|
+
written ? '1 file written' : '0 file writes, up to date'
|
|
358
|
+
})`
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
return 0;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return usageError(`unhandled action '${action}'`, json);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ── state, lease and run record (IC-010, IC-013) ──────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
const RUN_ID = /^rl-[0-9]{8}-[0-9a-hjkmnp-tv-z]{4}$/;
|
|
370
|
+
const CROCKFORD = '0123456789abcdefghjkmnpqrstvwxyz';
|
|
371
|
+
const LOG_LIMIT = 500;
|
|
372
|
+
/** C17: gate and merge entries, with their input hashes, outlive the run record's log in audit.jsonl. */
|
|
373
|
+
const AUDIT_LIMIT = 10000;
|
|
374
|
+
const AUDIT_STEPS = ['gate', 'merge', 'render', 'cleanup'];
|
|
375
|
+
const PLAN_FILES = ['plan.md', 'planning/plan.md'];
|
|
376
|
+
/** `LOOP_BY` of dispatch/loop.ts, the dispatch loop's own actor; G54 keeps that module out of here. */
|
|
377
|
+
const DISPATCH_ACTOR = 'doflow-dispatch';
|
|
378
|
+
/** The actor this loop names in the dispatch commands it yields. */
|
|
379
|
+
const LOOP_ACTOR = 'doflow-roadmap';
|
|
380
|
+
/** The envelope's attendance field: the literal the stage skill reads. attendance.ts (P3) owns the verb and the rule. */
|
|
381
|
+
const attendanceOf = (runId ) => ({ mode: 'unattended', run: runId });
|
|
382
|
+
const QUOTA = /^quota\b/i;
|
|
383
|
+
/** Stage artifacts, two layouts each (the decision-register.ts artifact list). */
|
|
384
|
+
const DOC_ARTIFACTS = {
|
|
385
|
+
discovery: ['intention/requirement.md', 'requirement.md'],
|
|
386
|
+
design: ['design/design.md', 'design.md'],
|
|
387
|
+
planning: PLAN_FILES,
|
|
388
|
+
review: ['review/findings.json'],
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
/** A fact the loop could not read. It is named in the output and the audit, never read as empty. */
|
|
392
|
+
class Unreadable extends Error {
|
|
393
|
+
|
|
394
|
+
constructor(sources , message ) {
|
|
395
|
+
super(message);
|
|
396
|
+
this.sources = sources;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const relCheckpoint = (runId ) => `.doflow/state/roadmap/runs/${runId}.json`;
|
|
401
|
+
|
|
402
|
+
function writeAtomic(file , text , fsImpl ) {
|
|
403
|
+
fsImpl.mkdirSync(path.dirname(file), { recursive: true });
|
|
404
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
405
|
+
try {
|
|
406
|
+
fsImpl.writeFileSync(temp, text, { mode: 0o600 });
|
|
407
|
+
fsImpl.renameSync(temp, file);
|
|
408
|
+
} finally {
|
|
409
|
+
try { fsImpl.unlinkSync(temp); } catch (error ) { if (error.code !== 'ENOENT') throw error; }
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function writeJsonAtomic(file , value , fsImpl ) {
|
|
414
|
+
writeAtomic(file, `${JSON.stringify(value)}\n`, fsImpl);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const auditFile = (dir ) => path.join(dir, 'audit.jsonl');
|
|
418
|
+
|
|
419
|
+
/** The durable gate and merge audit (C17): one JSON line each, the last AUDIT_LIMIT kept. */
|
|
420
|
+
function readAudit(dir , fsImpl ) {
|
|
421
|
+
let text ;
|
|
422
|
+
try { text = fsImpl.readFileSync(auditFile(dir), 'utf8'); }
|
|
423
|
+
catch (error ) { if (error.code === 'ENOENT') return []; throw new Unreadable(['roadmap-audit'], error.message); }
|
|
424
|
+
try { return text.split('\n').filter((line) => line.trim()).map((line) => JSON.parse(line)); }
|
|
425
|
+
catch (error ) { throw new Unreadable(['roadmap-audit'], error.message); }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function appendAudit(dir , entry , fsImpl ) {
|
|
429
|
+
const lines = readAudit(dir, fsImpl).map((value) => JSON.stringify(value));
|
|
430
|
+
lines.push(JSON.stringify(entry));
|
|
431
|
+
writeAtomic(auditFile(dir), `${lines.slice(-AUDIT_LIMIT).join('\n')}\n`, fsImpl);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function newRunId(now ) {
|
|
435
|
+
let suffix = '';
|
|
436
|
+
for (let i = 0; i < 4; i++) suffix += CROCKFORD[crypto.randomInt(CROCKFORD.length)];
|
|
437
|
+
return `rl-${now.toISOString().slice(0, 10).replace(/-/g, '')}-${suffix}`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function readRunFile(file , fsImpl ) {
|
|
441
|
+
const name = `run-record:${path.basename(file)}`;
|
|
442
|
+
let raw ;
|
|
443
|
+
try { raw = fsImpl.readFileSync(file, 'utf8'); }
|
|
444
|
+
catch (error ) { if (error.code === 'ENOENT') return null; throw new Unreadable([name], error.message); }
|
|
445
|
+
let value ;
|
|
446
|
+
try { value = JSON.parse(raw); } catch (error ) { throw new Unreadable([name], error.message); }
|
|
447
|
+
if (!value || value.schema !== 1 || !RUN_ID.test(value.runId) || !Number.isSafeInteger(value.steps)
|
|
448
|
+
|| !Number.isSafeInteger(value.n) || !value.rows || typeof value.rows !== 'object' || !Array.isArray(value.log)) {
|
|
449
|
+
throw new Unreadable([name], 'not a schema 1 run record');
|
|
450
|
+
}
|
|
451
|
+
return value;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function readRunRecords(dir , fsImpl ) {
|
|
455
|
+
let names ;
|
|
456
|
+
try { names = fsImpl.readdirSync(dir); }
|
|
457
|
+
catch (error ) { if (error.code === 'ENOENT') return []; throw new Unreadable(['run-records'], error.message); }
|
|
458
|
+
return names.filter((name) => /^rl-.*\.json$/.test(name)).sort()
|
|
459
|
+
.map((name) => readRunFile(path.join(dir, name), fsImpl)).filter(Boolean)
|
|
460
|
+
.sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : a.runId < b.runId ? -1 : 1));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* What `status` shows beside the three-valued git status (C18): the lease holder, and the holds and
|
|
465
|
+
* pauses the newest open run record keeps per row.
|
|
466
|
+
*/
|
|
467
|
+
function operationalState(dir , fsImpl ) {
|
|
468
|
+
let lease = null;
|
|
469
|
+
try {
|
|
470
|
+
const raw = JSON.parse(fsImpl.readFileSync(path.join(dir, 'lease.json'), 'utf8'));
|
|
471
|
+
lease = { pid: raw?.pid ?? null, host: raw?.host ?? null, runId: raw?.runId ?? null, acquiredAt: raw?.acquiredAt ?? null };
|
|
472
|
+
} catch (error ) { if (error.code !== 'ENOENT') throw new Unreadable(['lease'], error.message); }
|
|
473
|
+
const run = readRunRecords(path.join(dir, 'runs'), fsImpl).filter((record) => !record.closedAt).at(-1) || null;
|
|
474
|
+
const holds = [];
|
|
475
|
+
for (const [row, slot] of Object.entries(run?.rows || {}) ) {
|
|
476
|
+
if (slot?.held) holds.push({ row, state: 'held', kind: slot.held.kind ?? null, reason: slot.held.reason ?? null });
|
|
477
|
+
if (slot?.paused) holds.push({ row, state: 'paused', kind: null, reason: slot.paused.reason ?? null });
|
|
478
|
+
}
|
|
479
|
+
return { run: run?.runId ?? null, lease, holds };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** The named run, else the newest open run, else a new one (not yet written). */
|
|
483
|
+
function loadRunRecord(dir , requested , now , fsImpl ) {
|
|
484
|
+
const blank = (runId ) => ({ schema: 1, runId, createdAt: now.toISOString(), closedAt: null, maxSteps: null, steps: 0, n: 0, rows: {}, log: [] });
|
|
485
|
+
if (requested) {
|
|
486
|
+
const existing = readRunFile(path.join(dir, `${requested}.json`), fsImpl);
|
|
487
|
+
return existing ? { record: existing, fresh: false } : { record: blank(requested), fresh: true };
|
|
488
|
+
}
|
|
489
|
+
const open = readRunRecords(dir, fsImpl).filter((record) => !record.closedAt).at(-1);
|
|
490
|
+
return open ? { record: open, fresh: false } : { record: blank(newRunId(now)), fresh: true };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Liveness without spawning `ps` (NFR-004): a live pid can never be proven to be the recorded
|
|
495
|
+
* process, so it answers `unknown`, which refuses exactly as `true` does (IC-010). Only ESRCH is dead.
|
|
496
|
+
*/
|
|
497
|
+
function noPsAlive(proc ) {
|
|
498
|
+
const { alive } = require('../worker/process-tree.js');
|
|
499
|
+
return alive(proc, { run: () => null });
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* IC-010: `wx` create. A dead same-host holder is reclaimed only by the one caller holding
|
|
504
|
+
* `lease.reclaim` (itself created with `wx`), which re-reads the stale lease and unlinks it while it
|
|
505
|
+
* still has the bytes judged dead; nothing is ever moved aside and put back (C8). Anything else
|
|
506
|
+
* refuses. A caller re-reads its lease at every boundary (ownsLease), so one that lost it stops.
|
|
507
|
+
*/
|
|
508
|
+
function acquireLease(dir , me , alive , fsImpl ) {
|
|
509
|
+
const file = path.join(dir, 'lease.json');
|
|
510
|
+
const reclaim = path.join(dir, 'lease.reclaim');
|
|
511
|
+
const text = `${JSON.stringify(me)}\n`;
|
|
512
|
+
const parse = (raw ) => { try { return JSON.parse(raw); } catch { return null; } };
|
|
513
|
+
const holderOf = () => { try { return parse(fsImpl.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
514
|
+
// Only a dead holder on this host with a known pid can be proven gone; an unreadable one is unknown.
|
|
515
|
+
const dead = (holder ) => Boolean(holder) && typeof holder === 'object' && holder.host === me.host
|
|
516
|
+
&& Number.isInteger(holder.pid) && alive({ pid: holder.pid, start: holder.start }) === false;
|
|
517
|
+
fsImpl.mkdirSync(dir, { recursive: true });
|
|
518
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
519
|
+
try {
|
|
520
|
+
fsImpl.writeFileSync(file, text, { flag: 'wx', mode: 0o600 });
|
|
521
|
+
return { ok: true, text };
|
|
522
|
+
} catch (error ) { if (error.code !== 'EEXIST') throw error; }
|
|
523
|
+
let raw ;
|
|
524
|
+
try { raw = fsImpl.readFileSync(file, 'utf8'); }
|
|
525
|
+
catch (error ) { if (error.code === 'ENOENT') continue; throw error; }
|
|
526
|
+
const holder = parse(raw);
|
|
527
|
+
if (!dead(holder)) return { ok: false, holder };
|
|
528
|
+
try { fsImpl.writeFileSync(reclaim, text, { flag: 'wx', mode: 0o600 }); }
|
|
529
|
+
catch (error ) {
|
|
530
|
+
if (error.code !== 'EEXIST') throw error;
|
|
531
|
+
// Another reclaimer is at work, unless it died holding the reclaim file.
|
|
532
|
+
let other ;
|
|
533
|
+
try { other = fsImpl.readFileSync(reclaim, 'utf8'); } catch (readError ) { if (readError.code === 'ENOENT') continue; throw readError; }
|
|
534
|
+
if (!dead(parse(other))) return { ok: false, holder };
|
|
535
|
+
try { if (fsImpl.readFileSync(reclaim, 'utf8') === other) fsImpl.unlinkSync(reclaim); } catch { /* gone */ }
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
let current = null;
|
|
540
|
+
try { current = fsImpl.readFileSync(file, 'utf8'); } catch (error ) { if (error.code !== 'ENOENT') throw error; }
|
|
541
|
+
if (current === raw) fsImpl.unlinkSync(file);
|
|
542
|
+
} finally {
|
|
543
|
+
try { fsImpl.unlinkSync(reclaim); } catch { /* already gone */ }
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return { ok: false, holder: holderOf() };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** C8: the lease file still holds this call's own bytes. Gone, unreadable or another's is lost. */
|
|
550
|
+
function ownsLease(ctx ) {
|
|
551
|
+
if (!ctx.leaseText) return true;
|
|
552
|
+
try { return ctx.fsImpl.readFileSync(path.join(ctx.dir, 'lease.json'), 'utf8') === ctx.leaseText; }
|
|
553
|
+
catch { return false; }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** A caller that lost its lease writes nothing more, not even its checkpoint: another caller owns it. */
|
|
557
|
+
function leaseLost(ctx ) {
|
|
558
|
+
let holder = null;
|
|
559
|
+
try {
|
|
560
|
+
const raw = JSON.parse(ctx.fsImpl.readFileSync(path.join(ctx.dir, 'lease.json'), 'utf8'));
|
|
561
|
+
holder = { pid: raw?.pid ?? null, host: raw?.host ?? null, runId: raw?.runId ?? null };
|
|
562
|
+
} catch { /* no holder, or an unreadable one */ }
|
|
563
|
+
return { code: 1, result: bare({ outcome: 'denied', reason: 'lease-lost', rule: 'loop/lease', runId: ctx.record?.runId,
|
|
564
|
+
checkpoint: ctx.record && !ctx.fresh ? relCheckpoint(ctx.record.runId) : null, annotations: { holder } }) };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function bare(fields ) {
|
|
568
|
+
return { schema: 1, runId: fields.runId ?? null, n: null, outcome: fields.outcome, step: null, row: null,
|
|
569
|
+
reason: fields.reason ?? null, reasons: fields.reason ? [fields.reason] : [], rule: fields.rule ?? null, evidence: [], checkpoint: fields.checkpoint ?? null,
|
|
570
|
+
...(fields.annotations ? { annotations: fields.annotations } : {}) };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Unlinks the lease only when it is this call's own. */
|
|
574
|
+
function releaseLease(dir , me , fsImpl ) {
|
|
575
|
+
const file = path.join(dir, 'lease.json');
|
|
576
|
+
try {
|
|
577
|
+
const held = JSON.parse(fsImpl.readFileSync(file, 'utf8'));
|
|
578
|
+
if (held && held.pid === me.pid && held.host === me.host && held.runId === me.runId && held.acquiredAt === me.acquiredAt) fsImpl.unlinkSync(file);
|
|
579
|
+
} catch { /* gone, unreadable or another holder's: not ours to remove */ }
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ── facts ─────────────────────────────────────────────────────────────────────────────────────────
|
|
583
|
+
|
|
584
|
+
/** Every git command addresses the named checkout; inherited routing variables are dropped. */
|
|
585
|
+
function gitOptionsFor(root , env ) {
|
|
586
|
+
const environment = { ...env };
|
|
587
|
+
for (const key of Object.keys(environment)) if (/^GIT_(?:DIR|WORK_TREE|COMMON_DIR|INDEX_FILE|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|CONFIG_COUNT|CONFIG_KEY_\d+|CONFIG_VALUE_\d+)$/.test(key)) delete environment[key];
|
|
588
|
+
return { cwd: root, encoding: 'utf8', env: environment, stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Develop's committed gates.json, narrowed by the main checkout's override (IC-004). */
|
|
592
|
+
function loopPolicy(ctx ) {
|
|
593
|
+
const { execFileSync } = ctx;
|
|
594
|
+
let base = null;
|
|
595
|
+
try {
|
|
596
|
+
const head = String(execFileSync('git', ['-C', ctx.root, 'rev-parse', '--verify', 'refs/heads/develop'], ctx.gitOptions)).trim();
|
|
597
|
+
// Read again by gatherGitFacts in the same step: nothing between the two moves develop (file reads under the lease).
|
|
598
|
+
ctx.policyDevelopHead = head;
|
|
599
|
+
if (/^[0-9a-f]{40}$/.test(head)) base = String(execFileSync('git', ['-C', ctx.root, 'show', '--no-textconv', `${head}:core/registry/gates.json`], ctx.gitOptions));
|
|
600
|
+
} catch { /* no-policy */ }
|
|
601
|
+
const { projectLayerSource } = require('../settings/project-layers.js');
|
|
602
|
+
const layers = projectLayerSource(ctx.root, ctx.fsImpl);
|
|
603
|
+
let override = null;
|
|
604
|
+
if (layers.layersFrom) {
|
|
605
|
+
try { override = ctx.fsImpl.readFileSync(path.join(layers.layersFrom, 'gates.override.json'), 'utf8'); }
|
|
606
|
+
catch (error ) { if (error.code !== 'ENOENT') override = ''; }
|
|
607
|
+
}
|
|
608
|
+
return loadPolicy(base, override);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function listWorktrees(ctx ) {
|
|
612
|
+
const { execFileSync } = ctx;
|
|
613
|
+
const out = String(execFileSync('git', ['-C', ctx.root, 'worktree', 'list', '--porcelain', '-z'], ctx.gitOptions));
|
|
614
|
+
const map = new Map ();
|
|
615
|
+
for (const block of out.split('\0\0').filter(Boolean)) {
|
|
616
|
+
const fields = block.split('\0');
|
|
617
|
+
const listed = fields.find((s) => s.startsWith('worktree '))?.slice(9);
|
|
618
|
+
const worktree = listed === undefined ? listed : path.resolve(listed);
|
|
619
|
+
const branch = fields.find((s) => s.startsWith('branch '))?.slice(7);
|
|
620
|
+
if (worktree && branch) map.set(branch, worktree);
|
|
621
|
+
}
|
|
622
|
+
return map;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function readHeads(ctx , row ) {
|
|
626
|
+
const { execFileSync } = ctx;
|
|
627
|
+
try {
|
|
628
|
+
const developHead = String(execFileSync('git', ['-C', ctx.root, 'rev-parse', '--verify', 'refs/heads/develop'], ctx.gitOptions)).trim();
|
|
629
|
+
const featureHead = String(execFileSync('git', ['-C', ctx.root, 'rev-parse', '--verify', `refs/heads/feat/${row.slug}`], ctx.gitOptions)).trim();
|
|
630
|
+
return { developHead, featureHead };
|
|
631
|
+
} catch (error ) { throw new Unreadable([`heads:${row.slug}`], error.message); }
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Tripwire input: first-parent commits on develop that revert a merge carrying DoFlow-Policy. */
|
|
635
|
+
function autopilotReverts(ctx ) {
|
|
636
|
+
const { execFileSync } = ctx;
|
|
637
|
+
const merges = new Set ();
|
|
638
|
+
const listed = String(execFileSync('git', ['-C', ctx.root, 'rev-list', '--no-commit-header', '--first-parent', '--merges',
|
|
639
|
+
'--format=%H%x00%(trailers:key=DoFlow-Policy,valueonly)%x1e', 'refs/heads/develop'], ctx.gitOptions));
|
|
640
|
+
for (const record of listed.split('\x1e')) {
|
|
641
|
+
const [sha, policy] = record.trim().split('\0');
|
|
642
|
+
if (/^[0-9a-f]{40}$/.test(sha || '') && (policy || '').trim()) merges.add(sha);
|
|
643
|
+
}
|
|
644
|
+
if (merges.size === 0) return [];
|
|
645
|
+
const log = String(execFileSync('git', ['-C', ctx.root, 'log', '--first-parent', '--fixed-strings', '--grep=This reverts commit',
|
|
646
|
+
'--format=%H%x00%B%x1e', 'refs/heads/develop'], ctx.gitOptions));
|
|
647
|
+
const found = [];
|
|
648
|
+
for (const record of log.split('\x1e')) {
|
|
649
|
+
const [commit, body] = record.trim().split('\0');
|
|
650
|
+
if (!/^[0-9a-f]{40}$/.test(commit || '')) continue;
|
|
651
|
+
for (const match of String(body || '').matchAll(/This reverts commit ([0-9a-f]{40})/g)) {
|
|
652
|
+
if (merges.has(match[1])) found.push({ commit, merge: match[1] });
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return found;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** In its own namespace, so retention reads it as undecidable and keeps it (prune-by-done). */
|
|
659
|
+
const tripwireFile = (ctx ) => path.join(ctx.dir, 'tripwire', 'events.json');
|
|
660
|
+
|
|
661
|
+
function readTripwire(ctx ) {
|
|
662
|
+
const file = tripwireFile(ctx);
|
|
663
|
+
try {
|
|
664
|
+
const value = JSON.parse(ctx.fsImpl.readFileSync(file, 'utf8'));
|
|
665
|
+
if (!value || value.schema !== 1 || !Array.isArray(value.reverts) || !Array.isArray(value.unverified)) throw new Error('not a schema 1 tripwire record');
|
|
666
|
+
return value;
|
|
667
|
+
} catch (error ) {
|
|
668
|
+
if (error.code === 'ENOENT') return { schema: 1, reverts: [], unverified: [] };
|
|
669
|
+
throw new Unreadable(['tripwire'], error.message);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** The feature folder: the worktree's own, else the main checkout's (the feature-store rule). */
|
|
674
|
+
function artifactRootOf(ctx , row , worktree ) {
|
|
675
|
+
const own = path.join(worktree, 'agent-docs', 'doflow', row.slug);
|
|
676
|
+
const main = path.join(ctx.root, 'agent-docs', 'doflow', row.slug);
|
|
677
|
+
return !ctx.fsImpl.existsSync(own) && ctx.fsImpl.existsSync(main) ? main : own;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** The verify run-record file of a checkout, resolved by the writer's own rule (engine.ts). */
|
|
681
|
+
function runRecordFileOf(checkout , row ) {
|
|
682
|
+
const { verificationRunsRoot, verificationRunRecordFile } = require('../verification/engine.js');
|
|
683
|
+
return verificationRunRecordFile(verificationRunsRoot(checkout), row.slug);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Stage kinds with no file of their own: performed = a new, non-backfilled handoff of the stage (AU-P1). */
|
|
687
|
+
const HANDOFF_ONLY_KINDS = ['reproduction', 'analysis', 'retrieval'];
|
|
688
|
+
|
|
689
|
+
/** The stage kind of a program node, else the stage id (the feature chain's ids are its kinds). */
|
|
690
|
+
const kindOf = (run , stage ) => (run?.program || []).find((n ) => n?.type === 'stage' && n.id === stage)?.kind ?? stage;
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* IC-008: the bytes a stage must change, keyed by the stage's KIND so a class's own stage ids
|
|
694
|
+
* (regression-verification, authoring, ...) resolve. implementation and synthesis: the feature
|
|
695
|
+
* branch head; verification: the verify run record; reproduction, analysis, retrieval: no file,
|
|
696
|
+
* the count of the stage's handoffs; the rest: the stage's document.
|
|
697
|
+
*/
|
|
698
|
+
function stageArtifact(ctx , row , worktree , stage , kind = stage, run = null) {
|
|
699
|
+
if (kind === 'implementation' || kind === 'synthesis') {
|
|
700
|
+
const { featureHead } = readHeads(ctx, row);
|
|
701
|
+
return { artifact: `refs/heads/feat/${row.slug}`, sha256: sha256Digest(featureHead) };
|
|
702
|
+
}
|
|
703
|
+
if (HANDOFF_ONLY_KINDS.includes(kind)) {
|
|
704
|
+
const handoffs = (run?.history || []).filter((entry ) => entry && entry.node === stage
|
|
705
|
+
&& ((entry.action === 'complete-stage' && !entry.backfilled) || entry.action === 'annotate')).length;
|
|
706
|
+
return { artifact: `handoff:${stage}`, sha256: sha256Digest(String(handoffs)) };
|
|
707
|
+
}
|
|
708
|
+
if (kind === 'verification') {
|
|
709
|
+
// Where `verify --action report` appends (IC-007): the lifecycle root, not the feature checkout.
|
|
710
|
+
const file = runRecordFileOf(worktree, row);
|
|
711
|
+
try { return { artifact: file, sha256: sha256Digest(ctx.fsImpl.readFileSync(file)) }; }
|
|
712
|
+
catch (error ) { if (error.code === 'ENOENT') return { artifact: file, sha256: null }; throw new Unreadable([`artifact:${file}`], error.message); }
|
|
713
|
+
}
|
|
714
|
+
const base = artifactRootOf(ctx, row, worktree);
|
|
715
|
+
const candidates = DOC_ARTIFACTS[kind];
|
|
716
|
+
if (!candidates) throw new Unreadable([`stage:${stage}`], `no artifact is declared for stage '${stage}' (kind '${kind}')`);
|
|
717
|
+
for (const rel of candidates) {
|
|
718
|
+
try { return { artifact: rel, sha256: sha256Digest(ctx.fsImpl.readFileSync(path.join(base, rel))) }; }
|
|
719
|
+
catch (error ) { if (error.code !== 'ENOENT') throw new Unreadable([`artifact:${path.join(base, rel)}`], error.message); }
|
|
720
|
+
}
|
|
721
|
+
return { artifact: candidates[0], sha256: null };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** The feature's change is measured from where it forked from local develop, never origin (G.3 B3). */
|
|
725
|
+
function mergeBaseOf(ctx , row ) {
|
|
726
|
+
const { execFileSync } = ctx;
|
|
727
|
+
try {
|
|
728
|
+
const base = String(execFileSync('git', ['-C', ctx.root, 'merge-base', 'refs/heads/develop', `refs/heads/feat/${row.slug}`], ctx.gitOptions)).trim();
|
|
729
|
+
if (/^[0-9a-f]{40}$/.test(base)) return base;
|
|
730
|
+
} catch { /* named below */ }
|
|
731
|
+
throw new Unreadable([`merge-base:${row.slug}`], 'no merge base of develop and the feature branch');
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* The argv `dispatch start --verify-argv` takes (G.3 B2): the seed's `verifyArgv`, else `npm test`
|
|
736
|
+
* when the feature checkout's package.json declares a test script, else null (never guessed).
|
|
737
|
+
*/
|
|
738
|
+
function verifyArgvOf(ctx , row , worktree ) {
|
|
739
|
+
if (Array.isArray(row.verifyArgv)) return row.verifyArgv;
|
|
740
|
+
const file = path.join(worktree, 'package.json');
|
|
741
|
+
let manifest ;
|
|
742
|
+
try { manifest = JSON.parse(ctx.fsImpl.readFileSync(file, 'utf8')); }
|
|
743
|
+
catch (error ) { if (error.code === 'ENOENT') return null; throw new Unreadable([`package-json:${file}`], error.message); }
|
|
744
|
+
const script = manifest?.scripts?.test;
|
|
745
|
+
return typeof script === 'string' && script.trim() ? ['npm', 'test'] : null;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* `verify --scope`: the change-scope tier is required at every risk level and resolves from a plan or
|
|
750
|
+
* a declared scope. Four loop classes declare theirs in the readiness record (verify refuses a flag
|
|
751
|
+
* that differs from it); documentation has no readiness stage and no plan, so the row's own write set
|
|
752
|
+
* is its declared scope.
|
|
753
|
+
*/
|
|
754
|
+
const scopeFlagsOf = (row ) => (row.class === 'documentation' && Array.isArray(row.writeSet) && row.writeSet.length
|
|
755
|
+
? ['--scope', row.writeSet.join(',')] : []);
|
|
756
|
+
|
|
757
|
+
/** IC-008 envelope. Argv arrays only: the session runs them, nothing here is a shell string. */
|
|
758
|
+
function envelopeFor(ctx , row , worktree , stage , skill , before , verifyCommand = null,
|
|
759
|
+
kind = stage, dispatches = stage === 'implementation') {
|
|
760
|
+
const handoff = ['orchestrate', '--action', 'handoff', '--task-id', row.slug, '--calling-skill', skill, '--note', '<text>'];
|
|
761
|
+
const base = { outcome: 'perform', runId: ctx.record.runId, row: row.id, slug: row.slug, stage, skill, worktree,
|
|
762
|
+
artifactRoot: artifactRootOf(ctx, row, worktree), before, issuedAt: ctx.now().toISOString(), attendance: attendanceOf(ctx.record.runId) };
|
|
763
|
+
const next = ['roadmap', '--action', 'next', '--run', ctx.record.runId];
|
|
764
|
+
if (kind === 'verification') {
|
|
765
|
+
return { ...base, after: [['verify', '--action', 'report', '--task-id', row.slug, '--baseline-ref', mergeBaseOf(ctx, row), ...scopeFlagsOf(row)], handoff], next,
|
|
766
|
+
afterNote: 'verify exits non-zero for a FAIL or INCONCLUSIVE result and still records it: run the handoff next whatever it exits' };
|
|
767
|
+
}
|
|
768
|
+
// Only the feature chain dispatches workers (plan tasks); the other classes' implementation is a plain stage.
|
|
769
|
+
// Nothing but the session commits its edits here, and a stage whose branch head did not move is not performed.
|
|
770
|
+
if (!dispatches) return { ...base, ...(kind === 'implementation' || kind === 'synthesis' ? { commit: { branch: `feat/${row.slug}` } } : {}), after: handoff, next };
|
|
771
|
+
return { ...base, after: [
|
|
772
|
+
['dispatch', '--action', 'start', '--slug', row.slug, '--by', LOOP_ACTOR, '--billing-cap', 'free', '--fallback', 'auto', ...(verifyCommand ? ['--verify-argv', JSON.stringify(verifyCommand)] : [])],
|
|
773
|
+
['dispatch', '--action', 'run', '--slug', row.slug, '--by', LOOP_ACTOR],
|
|
774
|
+
handoff,
|
|
775
|
+
], next };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** IC-008: performed = the artifact exists, its bytes changed, and the stage's own handoff is newer. */
|
|
779
|
+
function performedCheck(ctx , row , worktree , run , perform ) {
|
|
780
|
+
const current = stageArtifact(ctx, row, worktree, perform.stage, kindOf(run, perform.stage), run);
|
|
781
|
+
if (current.sha256 === null) return { ok: false, current };
|
|
782
|
+
|
|
783
|
+
// FU-rcshhx: allow adopting rows whose design/plan artifacts were already completed and recorded
|
|
784
|
+
// in the orchestration run history, even if current.sha256 === perform.before?.sha256. A completion that a
|
|
785
|
+
// later skill's catch-up only backfilled is not such a record (AU-P1).
|
|
786
|
+
const isDesignOrPlan = perform.stage === 'design' || perform.stage === 'planning';
|
|
787
|
+
const alreadyCompleted = isDesignOrPlan && (run.history || []).some((entry ) =>
|
|
788
|
+
entry && entry.node === perform.stage && ((entry.action === 'complete-stage' && !entry.backfilled) || entry.action === 'annotate')
|
|
789
|
+
);
|
|
790
|
+
if (alreadyCompleted) return { ok: true, current };
|
|
791
|
+
|
|
792
|
+
if (current.sha256 === perform.before?.sha256) return { ok: false, current };
|
|
793
|
+
const issued = Date.parse(perform.issuedAt);
|
|
794
|
+
const handedOff = (run.history || []).some((entry ) => entry && entry.node === perform.stage
|
|
795
|
+
&& ((entry.action === 'complete-stage' && !entry.backfilled) || entry.action === 'annotate')
|
|
796
|
+
&& Date.parse(entry.at) > issued);
|
|
797
|
+
return { ok: handedOff, current, handedOff };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function orchestrationFor(ctx , row , worktree ) {
|
|
801
|
+
const { WorkflowOrchestrator } = require('../workflow-orchestrator.js');
|
|
802
|
+
const { findRunFile } = require('../implementation-gate.js');
|
|
803
|
+
const source = `orchestration:${row.slug}`;
|
|
804
|
+
const found = findRunFile({ stateRoot: worktree, taskId: row.slug, slug: row.slug });
|
|
805
|
+
if (found.status === 'ambiguous') throw new Unreadable([source], `run '${row.slug}' exists in more than one checkout`);
|
|
806
|
+
const runRoot = found.status === 'found' ? found.root : worktree;
|
|
807
|
+
// Row 134: the same planning-exit check the orchestrate verb wires, the plan read where
|
|
808
|
+
// artifactRootOf finds it (the feature checkout, else the main one).
|
|
809
|
+
const planExitCheck = () => require('../plan-content.js').planStageExit({ roots: [...new Set([worktree, runRoot, ctx.root])], slug: row.slug });
|
|
810
|
+
const orch = new WorkflowOrchestrator({ repoRoot: ctx.repoRoot, projectRoot: runRoot, sourceRoot: worktree,
|
|
811
|
+
stateDir: path.join(runRoot, '.doflow', 'state', 'orchestration'), featureSlug: row.slug, fsImpl: ctx.fsImpl, env: ctx.env, cwd: worktree, planExitCheck });
|
|
812
|
+
let run ;
|
|
813
|
+
try { run = orch.readRun(row.slug); } catch (error ) { throw new Unreadable([source], error.message); }
|
|
814
|
+
if (run && (!Array.isArray(run.program) || !Number.isInteger(run.cursor) || typeof run.state !== 'string' || !Array.isArray(run.history))) {
|
|
815
|
+
throw new Unreadable([source], 'not a workflow run journal');
|
|
816
|
+
}
|
|
817
|
+
return { orch, run };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** The dispatch store and the 080 ledger, read without a lock or a process (IC-037 shapes). */
|
|
821
|
+
function defaultDispatchFacts(ctx , slug ) {
|
|
822
|
+
const { readDispatch } = require('../dispatch/store.js');
|
|
823
|
+
const { readTaskState } = require('../task-state.js');
|
|
824
|
+
const { storeFile } = require('../task-attempts.js');
|
|
825
|
+
const store = readDispatch(path.join(ctx.root, '.doflow', 'state', 'dispatch', `${slug}.json`), ctx.fsImpl);
|
|
826
|
+
if (!store.run) return { state: 'none', pause: null };
|
|
827
|
+
const run = store.run;
|
|
828
|
+
const gaps = Array.isArray(run.blockedReport?.gaps) ? run.blockedReport.gaps : [];
|
|
829
|
+
const facts = { state: run.state, cause: run.blockedReport?.cause ?? null, resumeReason: run.resumeReason ?? null,
|
|
830
|
+
repeated: gaps.some((gap ) => typeof gap?.reason === 'string' && gap.reason.endsWith(': repeated')), pause: null,
|
|
831
|
+
integration: run.integration ? { branch: run.integration.branch ?? null, head: run.integration.head ?? null } : null };
|
|
832
|
+
const ledger = readTaskState(ctx.fsImpl, storeFile(ctx.root, slug));
|
|
833
|
+
const own = ledger?.runs && typeof run.runKey === 'string' && Object.hasOwn(ledger.runs, run.runKey) ? ledger.runs[run.runKey] : null;
|
|
834
|
+
if (own?.control === 'paused') {
|
|
835
|
+
const entry = [...(Array.isArray(own.history) ? own.history : [])].reverse().find((h ) => h?.action === 'pause') || {};
|
|
836
|
+
facts.pause = { by: entry.by ?? null, reason: entry.reason ?? null };
|
|
837
|
+
}
|
|
838
|
+
return facts;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** The 080 resume report digest, read-only (no ack): what `dispatch --action resume --ack` checks. */
|
|
842
|
+
function defaultResumeDigest(ctx , slug , cwd ) {
|
|
843
|
+
const attempts = require('../task-attempts.js');
|
|
844
|
+
const res = attempts.resume({ file: attempts.storeFile(ctx.root, slug), fsImpl: ctx.fsImpl,
|
|
845
|
+
roots: [...new Set([path.resolve(cwd), ctx.root])], slug, env: ctx.env, cwd });
|
|
846
|
+
return res.disposition === 'ack-required' && /^sha256:[0-9a-f]{64}$/.test(res.digest) ? { digest: res.digest } : { error: String(res.reason || res.disposition) };
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* The IC-007 verify lines of the row on `head`, judged by gate-b's own rule (C20, gates.ts
|
|
851
|
+
* verifyRecordReasons): [] passes, `verify-missing` means none was recorded yet.
|
|
852
|
+
*/
|
|
853
|
+
function postMergeReasons(row , cwd , head , fsImpl ) {
|
|
854
|
+
const source = `verification-runs:${row.slug}`;
|
|
855
|
+
let text = '';
|
|
856
|
+
try { text = fsImpl.readFileSync(runRecordFileOf(cwd, row), 'utf8'); }
|
|
857
|
+
catch (error ) { if (error.code !== 'ENOENT') throw new Unreadable([source], error.message); }
|
|
858
|
+
const records = [];
|
|
859
|
+
for (const line of text.split('\n')) {
|
|
860
|
+
if (!line.trim()) continue;
|
|
861
|
+
try { records.push(JSON.parse(line)); } catch (error ) { throw new Unreadable([source], error.message); }
|
|
862
|
+
}
|
|
863
|
+
return verifyRecordReasons(records, head, row.slug);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const verifyPending = (reasons ) => reasons.includes('verify-missing');
|
|
867
|
+
|
|
868
|
+
/** The feature plan file the post-merge verify reads (plan.md, else planning/plan.md) and its bytes' digest. */
|
|
869
|
+
function featurePlan(ctx , row , worktree ) {
|
|
870
|
+
const root = artifactRootOf(ctx, row, worktree);
|
|
871
|
+
for (const rel of PLAN_FILES) {
|
|
872
|
+
const file = path.join(root, rel);
|
|
873
|
+
try { return { locator: file, sha256: sha256Digest(ctx.fsImpl.readFileSync(file)), exists: true }; }
|
|
874
|
+
catch (error ) { if (error.code !== 'ENOENT') throw new Unreadable([`plan:${file}`], error.message); }
|
|
875
|
+
}
|
|
876
|
+
return { locator: path.join(root, PLAN_FILES[0]), sha256: sha256Digest(''), exists: false };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* C6: the plan the post-merge verify reads (its `doflow-verification` block replaces the verify
|
|
881
|
+
* commands) must be the bytes gate-b approved. The baseline is the plan digest this run recorded at
|
|
882
|
+
* gate-b, else the gate-b journal entry's own input for that plan path (the shipped gate-b reads the
|
|
883
|
+
* plan through plan-verification-absent). A gate-b whose policy never read the plan, approved in an
|
|
884
|
+
* earlier run, has no baseline: the plan was not part of that approval, and it is not compared.
|
|
885
|
+
*/
|
|
886
|
+
function planChanged(ctx , row , intent ) {
|
|
887
|
+
if (!intent.featureWorktree) return false;
|
|
888
|
+
const current = featurePlan(ctx, row, intent.featureWorktree);
|
|
889
|
+
const recorded = ctx.record.rows[row.id]?.gateBPlan;
|
|
890
|
+
if (recorded) return recorded.locator !== current.locator || recorded.sha256 !== current.sha256;
|
|
891
|
+
let inputs = [];
|
|
892
|
+
try {
|
|
893
|
+
const { run } = orchestrationFor(ctx, row, intent.featureWorktree);
|
|
894
|
+
const entry = [...(run?.history || [])].reverse().find((h ) => h?.action === 'decide-gate' && h.node === 'gate-b' && h.detail === 'approve');
|
|
895
|
+
inputs = Array.isArray(entry?.inputs) ? entry.inputs : [];
|
|
896
|
+
} catch { /* no readable journal: no baseline */ }
|
|
897
|
+
const root = artifactRootOf(ctx, row, intent.featureWorktree);
|
|
898
|
+
const planInputs = inputs.filter((input ) => PLAN_FILES.some((rel) => input?.locator === path.join(root, rel)));
|
|
899
|
+
return planInputs.length > 0 && !planInputs.some((input ) => input.locator === current.locator && input.sha256 === current.sha256);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// ── next (IC-001, design §4 step order) ───────────────────────────────────────────────────────────
|
|
903
|
+
|
|
904
|
+
function auditLine(fields ) {
|
|
905
|
+
const subject = fields.slug || fields.row || '-';
|
|
906
|
+
if (fields.outcome === 'stopped') return `stopped by the ${fields.annotations?.switch || 'setting'} switch${fields.annotations?.tripwire ? ` (tripwire: ${fields.annotations.tripwire})` : ''}`;
|
|
907
|
+
if (fields.reason === 'facts-unreadable') return `${subject} cannot read ${(fields.annotations?.unreadable || []).join(', ')}; nothing is assumed unchanged`;
|
|
908
|
+
if (fields.outcome === 'perform') return `perform ${fields.perform?.stage} ${subject} (${fields.perform?.skill})`;
|
|
909
|
+
if (fields.outcome === 'blocked') return `${subject} blocked: ${fields.reason}`;
|
|
910
|
+
if (fields.outcome === 'denied') return `${subject} denied: ${(fields.reasons || [fields.reason]).join(', ')}${fields.rule ? ` (rule ${fields.rule})` : ''}`;
|
|
911
|
+
return `${fields.step || fields.outcome} ${subject}`;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* One output line, one log entry, one checkpoint write (FR-008). Idle is not a step and logs nothing.
|
|
916
|
+
* `step` names pick|gate|merge|render|cleanup whenever the output concerns one: every `acted`, a gate
|
|
917
|
+
* or merge deny, a stop inside S8. It is null for perform, idle, blocked, lease-held, a stop at a step
|
|
918
|
+
* boundary and loop-level denies.
|
|
919
|
+
*/
|
|
920
|
+
function finish(ctx , fields ) {
|
|
921
|
+
// C8: a call that lost its lease mid-step writes nothing more.
|
|
922
|
+
if (!ownsLease(ctx)) return leaseLost(ctx);
|
|
923
|
+
const { record } = ctx;
|
|
924
|
+
const outcome = fields.outcome;
|
|
925
|
+
const at = ctx.now().toISOString();
|
|
926
|
+
const logged = outcome !== 'idle';
|
|
927
|
+
const step = fields.step ?? null;
|
|
928
|
+
if (logged && AUDIT_STEPS.includes(step)) {
|
|
929
|
+
// Written before the checkpoint, so a failed audit write leaves the run record as it was.
|
|
930
|
+
appendAudit(ctx.dir, { schema: 1, runId: record.runId, n: record.n + 1, at, row: fields.row ?? null, step, outcome,
|
|
931
|
+
reason: fields.reason ?? null, rule: fields.rule ?? null, inputs: fields.evidence ?? [], annotations: fields.annotations ?? null,
|
|
932
|
+
audit: fields.audit || auditLine(fields) }, ctx.fsImpl);
|
|
933
|
+
}
|
|
934
|
+
if (logged) {
|
|
935
|
+
record.n += 1;
|
|
936
|
+
if (['acted', 'perform', 'denied'].includes(outcome)) record.steps += 1;
|
|
937
|
+
if (fields.row) (record.rows[fields.row] ||= {}).lastStep = record.n;
|
|
938
|
+
}
|
|
939
|
+
if (outcome === 'idle' && fields.close && !ctx.fresh) record.closedAt = at;
|
|
940
|
+
const persisted = logged || !ctx.fresh;
|
|
941
|
+
const result = {
|
|
942
|
+
schema: 1, runId: persisted ? record.runId : null, n: persisted ? record.n : null, outcome,
|
|
943
|
+
step: fields.step ?? null, row: fields.row ?? null, reason: fields.reason ?? null,
|
|
944
|
+
reasons: fields.reasons ?? (fields.reason ? [fields.reason] : []), rule: fields.rule ?? null,
|
|
945
|
+
evidence: fields.evidence ?? [], checkpoint: persisted ? relCheckpoint(record.runId) : null,
|
|
946
|
+
};
|
|
947
|
+
if (fields.annotations) result.annotations = fields.annotations;
|
|
948
|
+
if (fields.perform) result.perform = fields.perform;
|
|
949
|
+
if (fields.skips) result.skips = fields.skips;
|
|
950
|
+
if (logged) {
|
|
951
|
+
record.log.push({ n: record.n, at, row: result.row, step: result.step, outcome, reason: result.reason, audit: fields.audit || auditLine(fields) });
|
|
952
|
+
if (record.log.length > LOG_LIMIT) record.log = record.log.slice(-LOG_LIMIT);
|
|
953
|
+
}
|
|
954
|
+
if (persisted) writeJsonAtomic(path.join(ctx.runsDir, `${record.runId}.json`), record, ctx.fsImpl);
|
|
955
|
+
return { code: ['acted', 'perform', 'idle'].includes(outcome) ? 0 : 1, result };
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function unreadableStep(ctx , error , row = null) {
|
|
959
|
+
return finish(ctx, { outcome: 'denied', reason: 'facts-unreadable', row: row?.id ?? null, slug: row?.slug,
|
|
960
|
+
rule: 'loop/facts', annotations: { unreadable: error.sources } });
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Read immediately before every mutating act: the lease must still be this call's (C8), and an off
|
|
965
|
+
* switch stops here, after the lease, with a checkpoint (C10).
|
|
966
|
+
*/
|
|
967
|
+
function boundary(ctx , row = null) {
|
|
968
|
+
if (!ownsLease(ctx)) return leaseLost(ctx);
|
|
969
|
+
const state = ctx.readSwitch();
|
|
970
|
+
return state.enabled ? null : finish(ctx, { outcome: 'stopped', row: row?.id ?? null, annotations: { switch: state.switch } });
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function tripwireNote(ctx , kind , id , line ) {
|
|
974
|
+
const tripwire = readTripwire(ctx);
|
|
975
|
+
if (!tripwire[kind].includes(id)) tripwire[kind].push(id);
|
|
976
|
+
// C9: the sentinel first, then the event. A crash between the two writes leaves the stop set and
|
|
977
|
+
// the event unrecorded, so the next scan can only trip again (safe); never an event remembered
|
|
978
|
+
// with no stop.
|
|
979
|
+
require('../settings/autopilot.js').tripSentinel(ctx.env, line, ctx.fsImpl);
|
|
980
|
+
writeJsonAtomic(tripwireFile(ctx), tripwire, ctx.fsImpl);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/** The develop checkout holds no feature folder: point verify at the feature plan so scope resolves. */
|
|
984
|
+
function postMergePlan(ctx , row , intent ) {
|
|
985
|
+
if (!intent.featureWorktree) return [];
|
|
986
|
+
const plan = featurePlan(ctx, row, intent.featureWorktree);
|
|
987
|
+
return plan.exists ? ['--plan-path', plan.locator] : [];
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/** C16: the archived merge intent of a row (done/<row>.json), or null. */
|
|
991
|
+
function archivedIntent(ctx , row ) {
|
|
992
|
+
const file = path.join(ctx.dir, 'done', `${row.id}.json`);
|
|
993
|
+
try { return JSON.parse(ctx.fsImpl.readFileSync(file, 'utf8')); }
|
|
994
|
+
catch (error ) { if (error.code === 'ENOENT') return null; throw new Unreadable(['merge-intents'], error.message); }
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** Deny reasons of an open intent that only a person resolves: held per row by digest (C16, C6). */
|
|
998
|
+
const INTENT_HOLDS = ['develop-moved', 'plan-changed'];
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* A deny that repeats on unchanged inputs is logged once; while the inputs digest is the same the row
|
|
1002
|
+
* is skipped, so other rows advance and no budget is spent.
|
|
1003
|
+
*/
|
|
1004
|
+
function heldDeny(ctx , row , kind , reason , inputsDigest , fields = {}) {
|
|
1005
|
+
const slot = (ctx.record.rows[row.id] ||= {});
|
|
1006
|
+
if (slot.held && slot.held.kind === kind && slot.held.inputsDigest === inputsDigest) return { skip: `held:${slot.held.reason}` };
|
|
1007
|
+
slot.held = { kind, reason, inputsDigest };
|
|
1008
|
+
const step = fields.step ?? 'merge';
|
|
1009
|
+
return finish(ctx, { outcome: 'denied', step, reason, row: row.id, slug: row.slug, rule: `${step}/${reason}`, ...fields });
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
async function mergeStep(ctx , row , source ) {
|
|
1013
|
+
const slot = (ctx.record.rows[row.id] ||= {});
|
|
1014
|
+
const intent = source.intent || null;
|
|
1015
|
+
let gate = null;
|
|
1016
|
+
let approvedHead = null;
|
|
1017
|
+
// The attendance marker of an earlier post-merge perform is consumed by this call (re-set below when the
|
|
1018
|
+
// verification is still pending), so it never outlives the step it names. It is not a `perform`: the
|
|
1019
|
+
// performed checks and the merge step read only slot.perform and the merge intent.
|
|
1020
|
+
delete slot.postMerge;
|
|
1021
|
+
if (intent && slot.held?.kind === 'intent') delete slot.held;
|
|
1022
|
+
if (!intent) {
|
|
1023
|
+
const entry = [...source.run.history].reverse().find((h ) => h?.action === 'decide-gate' && h.node === 'gate-b' && h.detail === 'approve');
|
|
1024
|
+
if (!entry || entry.by !== 'autopilot' || typeof entry.policy?.digest !== 'string') return { skip: 'gate-b-not-by-policy' };
|
|
1025
|
+
// C16: an intent a person archived as `abandoned` is neither resumed nor replaced by the loop
|
|
1026
|
+
// (merge.ts would resume an archived intent); the row waits for that person.
|
|
1027
|
+
if (archivedIntent(ctx, row)?.status === 'abandoned') return { skip: 'intent-abandoned' };
|
|
1028
|
+
gate = { id: 'gate-b', digest: entry.policy.digest };
|
|
1029
|
+
// C15: the feature head the loop bound at its own gate-b approval, kept in the journal entry, so
|
|
1030
|
+
// it holds across runs while develop moves (merge-tree runs on the current develop).
|
|
1031
|
+
if (typeof entry.approvedHead === 'string' && /^[0-9a-f]{40}$/.test(entry.approvedHead)) approvedHead = entry.approvedHead;
|
|
1032
|
+
else {
|
|
1033
|
+
// A gate-b decided outside this loop: bind to the heads its inputs digest names, else head-moved.
|
|
1034
|
+
const heads = readHeads(ctx, row);
|
|
1035
|
+
const input = (entry.inputs || []).find((i ) => typeof i?.locator === 'string' && i.locator.endsWith('#heads'));
|
|
1036
|
+
if (input && input.sha256 === sha256Digest(JSON.stringify(heads))) approvedHead = heads.featureHead;
|
|
1037
|
+
else return finish(ctx, { outcome: 'denied', step: 'merge', reason: 'head-moved', row: row.id, slug: row.slug, rule: 'merge/head-moved' });
|
|
1038
|
+
}
|
|
1039
|
+
// C7: approval is not retroactive, except that develop's current policy is reloaded before the
|
|
1040
|
+
// first intent is written: a gate-b now `never` for this class denies, and nothing is written.
|
|
1041
|
+
const current = loopPolicy(ctx);
|
|
1042
|
+
const gateB = current.ok ? current.policy.classes?.[row.class]?.['gate-b'] : null;
|
|
1043
|
+
const refusal = !current.ok ? current.reason : !gateB ? 'class-unsupported' : gateB.autopilot === 'never' ? 'policy-never' : null;
|
|
1044
|
+
if (refusal) return heldDeny(ctx, row, 'merge-policy', refusal, sha256Digest(JSON.stringify([refusal, current.digest ?? null])), { annotations: current.annotations });
|
|
1045
|
+
if (slot.held?.kind === 'merge-policy') delete slot.held;
|
|
1046
|
+
}
|
|
1047
|
+
if (intent && intent.phase === 'ff-done' && intent.verifiedHead !== intent.commit && !intent.status) {
|
|
1048
|
+
// A held row is not advanced: no post-merge perform, no check, no render or cleanup (C2).
|
|
1049
|
+
if (source.held) return { skip: 'row-held' };
|
|
1050
|
+
// C6: the post-merge verify reads the feature plan, so it must be the plan gate-b approved.
|
|
1051
|
+
if (planChanged(ctx, row, intent)) {
|
|
1052
|
+
slot.held = { kind: 'intent', reason: 'plan-changed', inputsDigest: source.holdDigest ?? null };
|
|
1053
|
+
return finish(ctx, { outcome: 'denied', step: 'merge', reason: 'plan-changed', row: row.id, slug: row.slug, rule: 'merge/plan-changed' });
|
|
1054
|
+
}
|
|
1055
|
+
if (verifyPending(postMergeReasons(row, intent.developWorktree, intent.commit, ctx.fsImpl))) {
|
|
1056
|
+
// The lean post-merge gate is performed by the session on the new develop head (no non-git spawn).
|
|
1057
|
+
const before = { artifact: runRecordFileOf(intent.developWorktree, row), sha256: null };
|
|
1058
|
+
const perform = { outcome: 'perform', runId: ctx.record.runId, row: row.id, slug: row.slug, stage: 'verification', skill: 'do-test',
|
|
1059
|
+
purpose: 'post-merge', worktree: intent.developWorktree, artifactRoot: null, head: intent.commit, before, issuedAt: ctx.now().toISOString(),
|
|
1060
|
+
attendance: attendanceOf(ctx.record.runId),
|
|
1061
|
+
after: ['verify', '--action', 'report', '--task-id', row.slug, '--baseline-ref', intent.developHead, ...postMergePlan(ctx, row, intent), ...scopeFlagsOf(row)],
|
|
1062
|
+
next: ['roadmap', '--action', 'next', '--run', ctx.record.runId] };
|
|
1063
|
+
// AU-P10: the one fact attendance.ts reads to answer `unattended` for this stage (the envelope above says so too).
|
|
1064
|
+
slot.postMerge = { stage: 'verification', purpose: 'post-merge', head: intent.commit, issuedAt: perform.issuedAt };
|
|
1065
|
+
return finish(ctx, { outcome: 'perform', row: row.id, slug: row.slug, perform });
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
const startPhase = intent?.phase || null;
|
|
1069
|
+
let pending = false;
|
|
1070
|
+
const isEnabled = () => {
|
|
1071
|
+
if (!ownsLease(ctx)) return { enabled: false, switch: 'lease-lost' };
|
|
1072
|
+
const state = ctx.readSwitch();
|
|
1073
|
+
if (!state.enabled) return state;
|
|
1074
|
+
// A seed hold set after the fast-forward started settles like an off switch (C2, C3).
|
|
1075
|
+
if (source.held) return { enabled: false, switch: 'row-held' };
|
|
1076
|
+
let open ;
|
|
1077
|
+
try { open = readOpenIntents({ root: ctx.root, fsImpl: ctx.fsImpl }).find((entry ) => entry.row === row.id); }
|
|
1078
|
+
catch (error ) { throw new Unreadable(['merge-intents'], error.message); }
|
|
1079
|
+
if (open && open.phase === 'ff-done' && open.verifiedHead !== open.commit && !open.status
|
|
1080
|
+
&& verifyPending(postMergeReasons(row, open.developWorktree, open.commit, ctx.fsImpl))) {
|
|
1081
|
+
pending = true;
|
|
1082
|
+
return { enabled: false, switch: 'verify-pending' };
|
|
1083
|
+
}
|
|
1084
|
+
return { enabled: true };
|
|
1085
|
+
};
|
|
1086
|
+
const postMergeCheck = ({ cwd, head } ) => ({ ok: postMergeReasons(row, cwd, head, ctx.fsImpl).length === 0 });
|
|
1087
|
+
const result = await ctx.mergeImpl({ root: ctx.root, row, runId: ctx.record.runId, gate, approvedHead,
|
|
1088
|
+
featureWorktree: source.worktree, env: ctx.env, fsImpl: ctx.fsImpl, now: () => ctx.now().getTime(), isEnabled, postMergeCheck,
|
|
1089
|
+
seedPath: ctx.seedPath, roadmapPath: ctx.roadmapPath, staleLockSeconds: ctx.policy.merge.staleLockSeconds });
|
|
1090
|
+
const step = !startPhase || ['intent', 'built', 'ff-started'].includes(startPhase) ? 'merge' : startPhase === 'ff-done' ? 'render' : 'cleanup';
|
|
1091
|
+
const base = { row: row.id, slug: row.slug };
|
|
1092
|
+
if (result.outcome === 'stopped') {
|
|
1093
|
+
if (result.annotations?.switch === 'lease-lost') return leaseLost(ctx);
|
|
1094
|
+
if (result.annotations?.switch === 'row-held') return { skip: 'row-held' };
|
|
1095
|
+
if (pending && result.annotations?.switch === 'verify-pending') {
|
|
1096
|
+
return finish(ctx, { ...base, outcome: 'acted', step, annotations: { commit: result.commit, pending: 'post-merge-verification' }, audit: `merge ${row.slug} ${result.commit}; post-merge verification pending` });
|
|
1097
|
+
}
|
|
1098
|
+
// A stop before any intent is a step-boundary stop; inside S8 it names the phase it stopped after.
|
|
1099
|
+
const at = !result.phase ? null : result.phase === 'rendered' ? 'render' : result.phase === 'cleaned' ? 'cleanup' : 'merge';
|
|
1100
|
+
return finish(ctx, { ...base, outcome: 'stopped', step: at, annotations: { switch: result.annotations?.switch || 'setting', phase: result.phase } });
|
|
1101
|
+
}
|
|
1102
|
+
if (result.outcome === 'denied') {
|
|
1103
|
+
if (result.error === 'cannot-read-facts') return unreadableStep(ctx, new Unreadable(result.sources || ['merge-facts'], 'merge facts'), row);
|
|
1104
|
+
// C16: an open intent denied develop-moved (a person committed on develop) is held per row; the
|
|
1105
|
+
// way out is a person's: move .doflow/state/roadmap/intents/<row>.json to done/ with
|
|
1106
|
+
// "status": "abandoned" (the loop then skips the row with intent-abandoned), or restore develop.
|
|
1107
|
+
if (intent && INTENT_HOLDS.includes(result.reason)) slot.held = { kind: 'intent', reason: result.reason, inputsDigest: source.holdDigest ?? null };
|
|
1108
|
+
return finish(ctx, { ...base, outcome: 'denied', step: 'merge', reason: result.reason, rule: `merge/${result.reason}` });
|
|
1109
|
+
}
|
|
1110
|
+
if (result.outcome === 'merged-unverified') {
|
|
1111
|
+
tripwireNote(ctx, 'unverified', result.commit, `merged-unverified ${row.id} ${result.commit}`);
|
|
1112
|
+
// The post-merge check is part of the merge step, whichever phase this call resumed from.
|
|
1113
|
+
return finish(ctx, { ...base, outcome: 'acted', step: 'merge', annotations: { commit: result.commit, status: 'merged-unverified', tripwire: 'merged-unverified', switch: 'sentinel' },
|
|
1114
|
+
audit: `merge ${row.slug} ${result.commit} merged-unverified: tripwire set the sentinel` });
|
|
1115
|
+
}
|
|
1116
|
+
slot.done = true;
|
|
1117
|
+
if (result.cleanup === 'cleanup-kept') {
|
|
1118
|
+
// A dirty feature worktree is kept for a person (FR-015); retrying it is not another step.
|
|
1119
|
+
if (slot.held?.kind === 'cleanup') return { skip: 'cleanup-kept' };
|
|
1120
|
+
slot.held = { kind: 'cleanup', reason: 'cleanup-kept' };
|
|
1121
|
+
} else delete slot.held;
|
|
1122
|
+
// Decision C12: the S8 tail is one cleanup step; `rendered` says whether this call wrote the table.
|
|
1123
|
+
const rendered = result.fileWrites > 0;
|
|
1124
|
+
return finish(ctx, { ...base, outcome: 'acted', step: 'cleanup', annotations: { commit: result.commit, cleanup: result.cleanup, rendered, artifacts: result.artifacts },
|
|
1125
|
+
audit: `cleanup ${row.slug} ${result.commit} (${result.cleanup}${rendered ? ', table rendered' : ''})${keptReason(result.artifacts)}` });
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
async function gateStep(ctx , row , orch , gateId , worktree ) {
|
|
1129
|
+
const slot = (ctx.record.rows[row.id] ||= {});
|
|
1130
|
+
const stop = boundary(ctx, row);
|
|
1131
|
+
if (stop) return stop;
|
|
1132
|
+
// C15: gate-b binds the feature head read here; the journal entry keeps it only when the gate's own
|
|
1133
|
+
// heads fact evaluated the same head, so the binding outlives this run.
|
|
1134
|
+
const bindHead = gateId === 'gate-b' ? readHeads(ctx, row).featureHead : undefined;
|
|
1135
|
+
let result ;
|
|
1136
|
+
try { result = orch.decideGate({ taskId: row.slug, gateId, policy: gateId, now: ctx.now(), ...(bindHead ? { bindHead } : {}) }); }
|
|
1137
|
+
catch (error ) { throw new Unreadable([`gate:${gateId}:${row.slug}`], error.message); }
|
|
1138
|
+
const base = { row: row.id, slug: row.slug, rule: result.rule ?? null, evidence: result.inputs || [] };
|
|
1139
|
+
if (result.decision === 'pass') {
|
|
1140
|
+
delete slot.held;
|
|
1141
|
+
if (gateId === 'gate-b') {
|
|
1142
|
+
// C6: the plan bytes gate-b approved: its own plan input, else this loop's read right after it.
|
|
1143
|
+
const plan = featurePlan(ctx, row, worktree);
|
|
1144
|
+
const approved = (result.inputs || []).find((i ) => i?.locator === plan.locator);
|
|
1145
|
+
slot.gateBPlan = { locator: plan.locator, sha256: approved ? approved.sha256 : plan.sha256 };
|
|
1146
|
+
}
|
|
1147
|
+
return finish(ctx, { ...base, outcome: 'acted', step: 'gate',
|
|
1148
|
+
audit: `${gateId} ${row.slug} approved by policy ${result.policy.id}@${result.policy.digest} (${(result.inputs || []).length} inputs)` });
|
|
1149
|
+
}
|
|
1150
|
+
if (result.reason === 'autopilot-disabled') return finish(ctx, { row: row.id, outcome: 'stopped', annotations: { switch: result.annotations?.switch || 'setting' } });
|
|
1151
|
+
const digest = result.inputsDigest || sha256Digest(JSON.stringify([result.reasons, result.policy || null]));
|
|
1152
|
+
if (slot.held && slot.held.kind === 'gate' && slot.held.gate === gateId && slot.held.inputsDigest === digest) return { skip: `held:${slot.held.reason}` };
|
|
1153
|
+
slot.held = { kind: 'gate', gate: gateId, reason: result.reason, rule: result.rule ?? null, inputsDigest: digest };
|
|
1154
|
+
return finish(ctx, { ...base, outcome: 'denied', step: 'gate', reason: result.reason, reasons: result.reasons,
|
|
1155
|
+
audit: `${gateId} ${row.slug} denied by policy: ${result.reasons.join(', ')}${result.rule ? ` (rule ${result.rule})` : ''}` });
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function performStep(ctx , row , worktree , node , run ) {
|
|
1159
|
+
const slot = (ctx.record.rows[row.id] ||= {});
|
|
1160
|
+
const kind = node.kind ?? node.id;
|
|
1161
|
+
if (kind === 'review') {
|
|
1162
|
+
// IC-007: gate-b's own verify rule, applied before the review envelope is spent. No question, no prompt.
|
|
1163
|
+
const head = readHeads(ctx, row).featureHead;
|
|
1164
|
+
const reasons = postMergeReasons(row, worktree, head, ctx.fsImpl);
|
|
1165
|
+
if (reasons.length) {
|
|
1166
|
+
return heldDeny(ctx, row, 'review', reasons[0],
|
|
1167
|
+
sha256Digest(JSON.stringify({ reasons, head, record: stageArtifact(ctx, row, worktree, 'verification', 'verification').sha256 })), { step: 'review', reasons });
|
|
1168
|
+
}
|
|
1169
|
+
if (slot.held?.kind === 'review') delete slot.held;
|
|
1170
|
+
}
|
|
1171
|
+
slot.perform = envelopeFor(ctx, row, worktree, node.id, node.skill, stageArtifact(ctx, row, worktree, node.id, kind, run), null, kind, false);
|
|
1172
|
+
return finish(ctx, { outcome: 'perform', row: row.id, slug: row.slug, perform: slot.perform });
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/** FR-020: only the dispatch's own pause is resumed, and only while its report is unchanged. */
|
|
1176
|
+
function resumeStep(ctx , row , worktree ) {
|
|
1177
|
+
const slot = (ctx.record.rows[row.id] ||= {});
|
|
1178
|
+
let got ;
|
|
1179
|
+
try { got = ctx.resumeDigest(row.slug, worktree); } catch (error ) { got = { error: error.message }; }
|
|
1180
|
+
if (!got || got.error || !/^sha256:[0-9a-f]{64}$/.test(got.digest || '')) throw new Unreadable([`resume-report:${row.slug}`], got?.error || 'no digest');
|
|
1181
|
+
slot.dispatch ||= {};
|
|
1182
|
+
if (!slot.dispatch.resumeAck) slot.dispatch.resumeAck = got.digest;
|
|
1183
|
+
else if (slot.dispatch.resumeAck !== got.digest) {
|
|
1184
|
+
return finish(ctx, { outcome: 'denied', reason: 'report-changed', row: row.id, slug: row.slug, rule: 'dispatch/resume',
|
|
1185
|
+
annotations: { recorded: slot.dispatch.resumeAck, current: got.digest } });
|
|
1186
|
+
}
|
|
1187
|
+
const perform = envelopeFor(ctx, row, worktree, 'implementation', 'do-execute-plan', slot.perform?.before || stageArtifact(ctx, row, worktree, 'implementation'));
|
|
1188
|
+
perform.after = [
|
|
1189
|
+
['dispatch', '--action', 'resume', '--slug', row.slug, '--ack', slot.dispatch.resumeAck, '--by', LOOP_ACTOR],
|
|
1190
|
+
['dispatch', '--action', 'run', '--slug', row.slug, '--by', LOOP_ACTOR],
|
|
1191
|
+
perform.after[2],
|
|
1192
|
+
];
|
|
1193
|
+
slot.perform = perform;
|
|
1194
|
+
return finish(ctx, { outcome: 'perform', row: row.id, slug: row.slug, perform });
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function dispatchFactsOf(ctx , row ) {
|
|
1198
|
+
try { return ctx.dispatchFacts(row.slug); } catch (error ) { throw new Unreadable([`dispatch:${row.slug}`], error.message); }
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* G.3 B2: a completed dispatch leaves its work on `doflow/<slug>/p-*`. Land it on the feature branch
|
|
1203
|
+
* by `merge --ff-only` in the feature checkout (switch read first); anything but a fast-forward is
|
|
1204
|
+
* `blocked: stuck` with nothing changed. Returns null when there is nothing (more) to land.
|
|
1205
|
+
*/
|
|
1206
|
+
function landDispatch(ctx , row , worktree , facts ) {
|
|
1207
|
+
const { execFileSync } = ctx;
|
|
1208
|
+
const slot = (ctx.record.rows[row.id] ||= {});
|
|
1209
|
+
const integration = facts?.integration;
|
|
1210
|
+
if (facts?.state !== 'completed' || !integration) return null;
|
|
1211
|
+
if (typeof integration.branch !== 'string' || !/^doflow\/[A-Za-z0-9][A-Za-z0-9._-]*\/p-[0-9a-f]{12}$/.test(integration.branch)
|
|
1212
|
+
|| !/^[0-9a-f]{40}$/.test(integration.head || '')) throw new Unreadable([`dispatch:${row.slug}`], 'integration branch or head is not recorded');
|
|
1213
|
+
const { featureHead } = readHeads(ctx, row);
|
|
1214
|
+
if (featureHead === integration.head) return null;
|
|
1215
|
+
let fastForward = false;
|
|
1216
|
+
try {
|
|
1217
|
+
const branchHead = String(execFileSync('git', ['-C', ctx.root, 'rev-parse', '--verify', `refs/heads/${integration.branch}`], ctx.gitOptions)).trim();
|
|
1218
|
+
execFileSync('git', ['-C', ctx.root, 'merge-base', '--is-ancestor', featureHead, integration.head], ctx.gitOptions);
|
|
1219
|
+
fastForward = branchHead === integration.head;
|
|
1220
|
+
} catch (error ) { if (error.status !== 1) throw new Unreadable([`integration:${row.slug}`], error.message); }
|
|
1221
|
+
const blocked = () => {
|
|
1222
|
+
const digest = sha256Digest(JSON.stringify({ facts, featureHead }));
|
|
1223
|
+
if (slot.held && slot.held.kind === 'dispatch' && slot.held.inputsDigest === digest) return { skip: `held:${slot.held.reason}` };
|
|
1224
|
+
slot.held = { kind: 'dispatch', reason: 'stuck', inputsDigest: digest };
|
|
1225
|
+
return finish(ctx, { outcome: 'blocked', reason: 'stuck', row: row.id, slug: row.slug, rule: 'dispatch/not-fast-forward',
|
|
1226
|
+
annotations: { dispatch: 'not-fast-forward', branch: integration.branch } });
|
|
1227
|
+
};
|
|
1228
|
+
if (!fastForward) return blocked();
|
|
1229
|
+
const stop = boundary(ctx, row);
|
|
1230
|
+
if (stop) return stop;
|
|
1231
|
+
// Hooks are disabled: the landing launches only git.
|
|
1232
|
+
try { execFileSync('git', ['-C', worktree, '-c', 'core.hooksPath=/dev/null', 'merge', '--ff-only', '-q', integration.branch], ctx.gitOptions); }
|
|
1233
|
+
catch { return blocked(); }
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/** Design §4 dispatch map (FR-019, FR-020, FR-022, FR-023; rulings C2, C6). */
|
|
1238
|
+
function implementationStep(ctx , row , worktree ) {
|
|
1239
|
+
const slot = (ctx.record.rows[row.id] ||= {});
|
|
1240
|
+
const facts = dispatchFactsOf(ctx, row);
|
|
1241
|
+
const verifyCommand = verifyArgvOf(ctx, row, worktree);
|
|
1242
|
+
const digest = sha256Digest(JSON.stringify({ facts, verifyCommand }));
|
|
1243
|
+
const base = { row: row.id, slug: row.slug };
|
|
1244
|
+
const hold = (reason , fields ) => {
|
|
1245
|
+
if (slot.held && slot.held.kind === 'dispatch' && slot.held.inputsDigest === digest) return { skip: `held:${slot.held.reason}` };
|
|
1246
|
+
slot.held = { kind: 'dispatch', reason, inputsDigest: digest };
|
|
1247
|
+
return finish(ctx, { ...base, ...fields });
|
|
1248
|
+
};
|
|
1249
|
+
const pause = facts?.pause;
|
|
1250
|
+
if (pause && ['none', 'running'].includes(facts.state)) {
|
|
1251
|
+
slot.paused = { reason: pause.reason };
|
|
1252
|
+
if (QUOTA.test(String(pause.reason || ''))) {
|
|
1253
|
+
return hold('quota', { outcome: 'blocked', reason: 'quota', rule: 'dispatch/paused', annotations: { pause: pause.reason } });
|
|
1254
|
+
}
|
|
1255
|
+
if (pause.by !== DISPATCH_ACTOR) {
|
|
1256
|
+
return hold('pause-not-machine', { outcome: 'denied', reason: 'pause-not-machine', rule: 'dispatch/paused', annotations: { pause: pause.reason, by: pause.by } });
|
|
1257
|
+
}
|
|
1258
|
+
return resumeStep(ctx, row, worktree);
|
|
1259
|
+
}
|
|
1260
|
+
switch (facts?.state) {
|
|
1261
|
+
case 'none':
|
|
1262
|
+
case 'running': {
|
|
1263
|
+
if (slot.held?.kind === 'dispatch' && verifyCommand) delete slot.held;
|
|
1264
|
+
delete slot.paused;
|
|
1265
|
+
delete slot.dispatch;
|
|
1266
|
+
if (!slot.perform || slot.perform.stage !== 'implementation') {
|
|
1267
|
+
if (!verifyCommand) {
|
|
1268
|
+
return hold('stuck', { outcome: 'blocked', reason: 'stuck', rule: 'dispatch/verify-argv', annotations: { dispatch: 'no-verify-argv' } });
|
|
1269
|
+
}
|
|
1270
|
+
slot.perform = envelopeFor(ctx, row, worktree, 'implementation', 'do-execute-plan', stageArtifact(ctx, row, worktree, 'implementation'), verifyCommand);
|
|
1271
|
+
}
|
|
1272
|
+
return finish(ctx, { ...base, outcome: 'perform', perform: slot.perform });
|
|
1273
|
+
}
|
|
1274
|
+
case 'completed': {
|
|
1275
|
+
const landing = landDispatch(ctx, row, worktree, facts);
|
|
1276
|
+
if (landing) return landing;
|
|
1277
|
+
// The run is still on implementation, so the stage's own handoff has not been recorded.
|
|
1278
|
+
if (!slot.perform || slot.perform.stage !== 'implementation') {
|
|
1279
|
+
slot.perform = envelopeFor(ctx, row, worktree, 'implementation', 'do-execute-plan',
|
|
1280
|
+
{ artifact: `refs/heads/feat/${row.slug}`, sha256: slot.claimHead ? sha256Digest(slot.claimHead) : null }, verifyCommand);
|
|
1281
|
+
}
|
|
1282
|
+
return hold('stage-not-performed', { outcome: 'denied', reason: 'stage-not-performed', rule: 'perform/implementation', perform: slot.perform });
|
|
1283
|
+
}
|
|
1284
|
+
case 'blocked':
|
|
1285
|
+
return hold('stuck', { outcome: 'blocked', reason: 'stuck', rule: facts.repeated ? 'dispatch/repeated' : 'dispatch/blocked', annotations: { dispatch: 'blocked', cause: facts.cause } });
|
|
1286
|
+
case 'cancelling':
|
|
1287
|
+
case 'cancelled':
|
|
1288
|
+
return hold('stuck', { outcome: 'blocked', reason: 'stuck', rule: 'dispatch/cancelled', annotations: { dispatch: 'cancelled' } });
|
|
1289
|
+
case 'resume-required':
|
|
1290
|
+
return resumeStep(ctx, row, worktree);
|
|
1291
|
+
default:
|
|
1292
|
+
throw new Unreadable([`dispatch:${row.slug}`], `unknown dispatch run state '${facts?.state}'`);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* A feature run gets its decision register from do-brainstorm, which creates the folder. The other
|
|
1298
|
+
* classes have no such stage, and gate-b's policy approval is recorded in that register, so the loop
|
|
1299
|
+
* creates it once, before any artifact lands in the folder. A folder that already holds artifacts and
|
|
1300
|
+
* no register is left alone (initRegister's `predates-register`): the gate then names it unreadable.
|
|
1301
|
+
*/
|
|
1302
|
+
function ensureRegister(ctx , row , worktree ) {
|
|
1303
|
+
const { initRegister } = require('../decision-register.js');
|
|
1304
|
+
const featureDir = path.join(worktree, 'agent-docs', 'doflow', row.slug);
|
|
1305
|
+
if (ctx.fsImpl.existsSync(path.join(featureDir, 'decisions', 'register.json'))) {
|
|
1306
|
+
if (ctx.record.rows[row.id]?.held?.kind === 'register') delete ctx.record.rows[row.id].held;
|
|
1307
|
+
return null;
|
|
1308
|
+
}
|
|
1309
|
+
const stop = boundary(ctx, row);
|
|
1310
|
+
if (stop) return stop;
|
|
1311
|
+
const made = initRegister({ featureDir, slug: row.slug, fsImpl: ctx.fsImpl });
|
|
1312
|
+
if (!made.finding) return null;
|
|
1313
|
+
// The folder predates a register: gate-b could not record its approval. Denied once, then held by digest.
|
|
1314
|
+
return heldDeny(ctx, row, 'register', 'facts-unreadable', sha256Digest(JSON.stringify([made.finding, made.message])),
|
|
1315
|
+
{ step: 'pick', rule: 'loop/facts', annotations: { unreadable: ['decision-register'], finding: made.finding } });
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/** One in-flight row: pending perform check, then its orchestration state. Returns a result or a skip. */
|
|
1319
|
+
async function stepRow(ctx , row , worktrees ) {
|
|
1320
|
+
const slot = (ctx.record.rows[row.id] ||= {});
|
|
1321
|
+
if (!LOOP_CLASSES.includes(row.class)) return { skip: 'class-unsupported' };
|
|
1322
|
+
// A seed hold (decision C8) also stops autopilot from advancing a row already in flight.
|
|
1323
|
+
if (row.hold) return { skip: 'row-held' };
|
|
1324
|
+
// maint-thin: so does a proposed row (a branch a person started by hand); a person promotes it first.
|
|
1325
|
+
if (row.proposed === true) return { skip: 'row-proposed' };
|
|
1326
|
+
if (slot.paused?.reason === 'budget') delete slot.paused;
|
|
1327
|
+
const worktree = worktrees.get(`refs/heads/feat/${row.slug}`);
|
|
1328
|
+
if (!worktree) return { skip: 'no-worktree' };
|
|
1329
|
+
const { orch, run } = orchestrationFor(ctx, row, worktree);
|
|
1330
|
+
if (!run) {
|
|
1331
|
+
// A claim interrupted after `worktree add`: finish it.
|
|
1332
|
+
const stop = boundary(ctx, row);
|
|
1333
|
+
if (stop) return stop;
|
|
1334
|
+
orch.start({ taskId: row.slug, taskClass: row.class, now: ctx.now(), loopProgram: true });
|
|
1335
|
+
return finish(ctx, { outcome: 'acted', step: 'pick', row: row.id, slug: row.slug, annotations: { worktree }, audit: `pick ${row.slug}: run started in ${worktree}` });
|
|
1336
|
+
}
|
|
1337
|
+
if (row.class !== 'feature') {
|
|
1338
|
+
const refused = ensureRegister(ctx, row, worktree);
|
|
1339
|
+
if (refused) return refused;
|
|
1340
|
+
}
|
|
1341
|
+
const node = run.cursor < run.program.length ? run.program[run.cursor] : null;
|
|
1342
|
+
// Only the feature chain dispatches workers at implementation; any other class's implementation is a plain stage.
|
|
1343
|
+
const dispatches = row.class === 'feature';
|
|
1344
|
+
const atImplementation = dispatches && run.state === 'RUNNING' && node?.type === 'stage' && node.id === 'implementation';
|
|
1345
|
+
if (slot.perform && !(atImplementation && slot.perform.stage === 'implementation')) {
|
|
1346
|
+
if (dispatches && slot.perform.stage === 'implementation') {
|
|
1347
|
+
// The session handed off right after `dispatch run`: land the dispatch before checking the head.
|
|
1348
|
+
const landing = landDispatch(ctx, row, worktree, dispatchFactsOf(ctx, row));
|
|
1349
|
+
if (landing) return landing;
|
|
1350
|
+
}
|
|
1351
|
+
const checked = performedCheck(ctx, row, worktree, run, slot.perform);
|
|
1352
|
+
if (!checked.ok && slot.perform.stage === 'planning' && checked.current.sha256 !== null) {
|
|
1353
|
+
// Row 134: a written plan whose handoff did not land is the constitution exit refusing it; the
|
|
1354
|
+
// row stays at planning with that refusal recorded, and no one is asked.
|
|
1355
|
+
try { orch.assertPlanExit(run.program.find((n ) => n.id === 'planning'), run); } catch (error ) {
|
|
1356
|
+
return finish(ctx, { outcome: 'denied', reason: 'plan-exit-refused', row: row.id, slug: row.slug, rule: 'perform/planning',
|
|
1357
|
+
evidence: [{ locator: checked.current.artifact, sha256: checked.current.sha256 }], annotations: { refusal: error.message }, perform: slot.perform });
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
if (!checked.ok) {
|
|
1361
|
+
// K59: a stage that never hands off is denied once per input state, then held by digest, so it
|
|
1362
|
+
// neither spends a step per call nor keeps other rows from advancing. A changed artifact or a
|
|
1363
|
+
// new handoff changes the digest and the stage is judged again.
|
|
1364
|
+
const digest = sha256Digest(JSON.stringify({ stage: slot.perform.stage, sha256: checked.current.sha256, handedOff: Boolean(checked.handedOff) }));
|
|
1365
|
+
if (slot.held?.kind === 'stage' && slot.held.inputsDigest === digest) return { skip: `held:${slot.held.reason}` };
|
|
1366
|
+
slot.held = { kind: 'stage', reason: 'stage-not-performed', inputsDigest: digest };
|
|
1367
|
+
// A handoff-only stage whose only handoff was a catch-up backfill (another skill's handoff walked past it) can never
|
|
1368
|
+
// change its digest on its own: only an annotate of the stage, which counts as a handoff, releases the hold.
|
|
1369
|
+
const backfilled = (run.history || []).some((entry ) => entry && entry.node === slot.perform.stage && entry.backfilled === true);
|
|
1370
|
+
const release = HANDOFF_ONLY_KINDS.includes(kindOf(run, slot.perform.stage))
|
|
1371
|
+
? { release: ['orchestrate', '--action', 'annotate', '--task-id', row.slug, '--node', slot.perform.stage, '--note', '<what was done>'], backfilled }
|
|
1372
|
+
: undefined;
|
|
1373
|
+
return finish(ctx, { outcome: 'denied', reason: 'stage-not-performed', row: row.id, slug: row.slug, rule: `perform/${slot.perform.stage}`,
|
|
1374
|
+
evidence: [{ locator: checked.current.artifact, sha256: checked.current.sha256 }], annotations: release, perform: slot.perform });
|
|
1375
|
+
}
|
|
1376
|
+
if (slot.held?.kind === 'stage' || slot.held?.kind === 'dispatch') delete slot.held;
|
|
1377
|
+
delete slot.perform;
|
|
1378
|
+
}
|
|
1379
|
+
if (run.state === 'REJECTED') return { skip: 'run-rejected' };
|
|
1380
|
+
if (run.state === 'AWAITING_GATE' && node?.type === 'gate') return gateStep(ctx, row, orch, node.id, worktree);
|
|
1381
|
+
if (run.state === 'COMPLETED') return mergeStep(ctx, row, { run, worktree });
|
|
1382
|
+
if (run.state === 'RUNNING' && node?.type === 'stage') {
|
|
1383
|
+
return dispatches && node.id === 'implementation' ? implementationStep(ctx, row, worktree) : performStep(ctx, row, worktree, node, run);
|
|
1384
|
+
}
|
|
1385
|
+
throw new Unreadable([`orchestration:${row.slug}`], `run state '${run.state}' has no actionable node`);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/** The audit line names why a cleanup was kept (artifact conflicts), not only `--json`. */
|
|
1389
|
+
const keptReason = (artifacts ) => (artifacts?.conflicts?.length ? `: ${artifacts.failure}` : '');
|
|
1390
|
+
|
|
1391
|
+
/** Render if stale (at most one write), then merge.ts's non-force cleanup; a kept row is held. */
|
|
1392
|
+
function finishMergedRow(ctx , row , rows , statuses , worktree , worktrees ) {
|
|
1393
|
+
const slot = (ctx.record.rows[row.id] ||= {});
|
|
1394
|
+
const stop = boundary(ctx, row);
|
|
1395
|
+
if (stop) return stop;
|
|
1396
|
+
const develop = worktrees.get('refs/heads/develop');
|
|
1397
|
+
if (!develop) return finish(ctx, { outcome: 'denied', step: 'cleanup', reason: 'no-develop-worktree', row: row.id, slug: row.slug, rule: 'cleanup/no-develop-worktree' });
|
|
1398
|
+
const { written } = renderRoadmapFile({ roadmapPath: ctx.roadmapPath, content: render(rows, statuses), fsImpl: ctx.fsImpl });
|
|
1399
|
+
let featureHead = null;
|
|
1400
|
+
try { featureHead = String(ctx.execFileSync('git', ['-C', ctx.root, 'rev-parse', '--verify', `refs/heads/feat/${row.slug}`], ctx.gitOptions)).trim(); }
|
|
1401
|
+
catch { /* branch already gone: only a leftover worktree remains */ }
|
|
1402
|
+
// C10: the switch (and the lease) is read again after the render, right before the cleanup.
|
|
1403
|
+
const stopCleanup = boundary(ctx, row);
|
|
1404
|
+
if (stopCleanup) return stopCleanup;
|
|
1405
|
+
const artifacts = {};
|
|
1406
|
+
const cleaned = cleanupFeature({ intent: { featureWorktree: worktree, featureBranch: `feat/${row.slug}`, featureHead }, report: artifacts,
|
|
1407
|
+
root: ctx.root, develop, execFileSync: ctx.execFileSync, execOptions: ctx.gitOptions, fsImpl: ctx.fsImpl });
|
|
1408
|
+
slot.done = true;
|
|
1409
|
+
delete slot.postMerge; // a row merged without an open intent has no post-merge verification left to mark
|
|
1410
|
+
if (!cleaned) {
|
|
1411
|
+
if (slot.held?.kind === 'cleanup' && !written) return { skip: 'cleanup-kept' };
|
|
1412
|
+
slot.held = { kind: 'cleanup', reason: 'cleanup-kept' };
|
|
1413
|
+
} else delete slot.held;
|
|
1414
|
+
const cleanup = cleaned ? 'cleaned' : 'cleanup-kept';
|
|
1415
|
+
return finish(ctx, { outcome: 'acted', step: 'cleanup', row: row.id, slug: row.slug, annotations: { cleanup, rendered: written, artifacts },
|
|
1416
|
+
audit: `cleanup ${row.slug} merged without an intent: ${cleanup}${written ? ', table rendered' : ''}${keptReason(artifacts)}` });
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/** The seed layout, kept a sibling of the main checkout: seed text never chooses an arbitrary path. */
|
|
1420
|
+
function layoutPath(ctx , seed , row ) {
|
|
1421
|
+
const template = typeof seed.layout?.worktree === 'string' ? seed.layout.worktree : '{mainParent}/{mainName}-{id}';
|
|
1422
|
+
const values = { mainParent: path.dirname(ctx.root), mainName: path.basename(ctx.root), id: row.id, slug: row.slug };
|
|
1423
|
+
const resolved = path.resolve(template.replace(/\{(mainParent|mainName|id|slug)\}/g, (_ , key ) => values[key]));
|
|
1424
|
+
return path.dirname(resolved) === path.dirname(ctx.root) && resolved !== ctx.root ? resolved : null;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
async function claimStep(ctx , row , seed , developHead ) {
|
|
1428
|
+
const { execFileSync } = ctx;
|
|
1429
|
+
const worktree = layoutPath(ctx, seed, row);
|
|
1430
|
+
if (!worktree) throw new Unreadable(['seed-layout'], 'layout.worktree must name a sibling of the main checkout');
|
|
1431
|
+
const stop = boundary(ctx, row);
|
|
1432
|
+
if (stop) return stop;
|
|
1433
|
+
// Hooks are disabled: the claim launches only git.
|
|
1434
|
+
execFileSync('git', ['-C', ctx.root, '-c', 'core.hooksPath=/dev/null', 'worktree', 'add', '-b', `feat/${row.slug}`, worktree, developHead], ctx.gitOptions);
|
|
1435
|
+
ctx.record.rows[row.id] = { claimedAt: ctx.now().toISOString(), claimHead: developHead, worktree };
|
|
1436
|
+
// C10: read again before the next mutation; a stopped claim is finished by a later call (stepRow).
|
|
1437
|
+
const stopStart = boundary(ctx, row);
|
|
1438
|
+
if (stopStart) return stopStart;
|
|
1439
|
+
const { orch } = orchestrationFor(ctx, row, worktree);
|
|
1440
|
+
orch.start({ taskId: row.slug, taskClass: row.class, now: ctx.now(), loopProgram: true });
|
|
1441
|
+
return finish(ctx, { outcome: 'acted', step: 'pick', row: row.id, slug: row.slug, annotations: { worktree, developHead },
|
|
1442
|
+
audit: `pick ${row.slug} at ${developHead} into ${worktree}` });
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
async function underLease(ctx , options ) {
|
|
1446
|
+
const { fsImpl } = ctx;
|
|
1447
|
+
const loaded = loadRunRecord(ctx.runsDir, options.run, ctx.now(), fsImpl);
|
|
1448
|
+
ctx.record = loaded.record;
|
|
1449
|
+
ctx.fresh = loaded.fresh;
|
|
1450
|
+
|
|
1451
|
+
const policy = loopPolicy(ctx);
|
|
1452
|
+
if (!policy.ok) return finish(ctx, { outcome: 'denied', reason: policy.reason, reasons: policy.reasons, rule: 'loop/policy', annotations: policy.annotations });
|
|
1453
|
+
ctx.policy = policy.policy;
|
|
1454
|
+
const record = ctx.record;
|
|
1455
|
+
record.maxSteps = policy.policy.loop.maxSteps;
|
|
1456
|
+
|
|
1457
|
+
// Budget (FR-017): a cap pauses the next row with its checkpoint; it never fails anything.
|
|
1458
|
+
if (record.steps >= record.maxSteps) {
|
|
1459
|
+
const next = Object.entries(record.rows).filter(([, slot] ) => !slot.done)
|
|
1460
|
+
.sort((a , b ) => (a[1].lastStep ?? 0) - (b[1].lastStep ?? 0) || (a[0] < b[0] ? -1 : 1))[0];
|
|
1461
|
+
const rowId = next ? next[0] : null;
|
|
1462
|
+
if (rowId) record.rows[rowId].paused = { reason: 'budget', cap: record.maxSteps };
|
|
1463
|
+
return finish(ctx, { outcome: 'blocked', reason: 'budget', row: rowId, rule: 'loop/maxSteps',
|
|
1464
|
+
annotations: { cap: record.maxSteps, steps: record.steps },
|
|
1465
|
+
audit: `blocked: budget, maxSteps ${record.maxSteps} reached after ${record.steps} steps; row ${rowId || '-'} paused with its checkpoint` });
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// Facts. A source that cannot be read is named; it is never read as "nothing changed".
|
|
1469
|
+
let seed ;
|
|
1470
|
+
try { seed = parseSeed(fsImpl.readFileSync(ctx.seedPath, 'utf8')); }
|
|
1471
|
+
catch (error ) {
|
|
1472
|
+
// A typed status is a closed-vocabulary deny, not an unreadable seed (C4).
|
|
1473
|
+
if (error?.code === 'seed-typed-status') {
|
|
1474
|
+
return finish(ctx, { outcome: 'denied', reason: 'seed-typed-status', row: error.rowId ?? null, rule: 'loop/seed' });
|
|
1475
|
+
}
|
|
1476
|
+
throw new Unreadable(['seed'], error.message);
|
|
1477
|
+
}
|
|
1478
|
+
const facts = options.gitFacts || gatherGitFacts({ root: ctx.root, developHead: ctx.policyDevelopHead });
|
|
1479
|
+
if (facts.unreadable?.length) throw new Unreadable(facts.unreadable, 'git facts');
|
|
1480
|
+
let intents ;
|
|
1481
|
+
try { intents = readOpenIntents({ root: ctx.root, fsImpl }); } catch (error ) { throw new Unreadable(['merge-intents'], error.message); }
|
|
1482
|
+
let worktrees ;
|
|
1483
|
+
try { worktrees = listWorktrees(ctx); } catch (error ) { throw new Unreadable(['worktrees'], error.message); }
|
|
1484
|
+
let reverts ;
|
|
1485
|
+
try { reverts = autopilotReverts(ctx); } catch (error ) { throw new Unreadable(['develop-history'], error.message); }
|
|
1486
|
+
|
|
1487
|
+
// Tripwire (P2, narrowing only): each revert of an autopilot merge, and each merged-unverified
|
|
1488
|
+
// result, sets the sentinel once; a person clears it.
|
|
1489
|
+
const tripwire = readTripwire(ctx);
|
|
1490
|
+
const revert = reverts.find((entry) => !tripwire.reverts.includes(entry.commit));
|
|
1491
|
+
if (revert) {
|
|
1492
|
+
tripwireNote(ctx, 'reverts', revert.commit, `revert ${revert.commit} of autopilot merge ${revert.merge}`);
|
|
1493
|
+
return finish(ctx, { outcome: 'stopped', annotations: { switch: 'sentinel', tripwire: 'revert', commit: revert.commit, merge: revert.merge } });
|
|
1494
|
+
}
|
|
1495
|
+
const unverified = intents.find((intent) => intent.status === 'merged-unverified' && !tripwire.unverified.includes(intent.commit));
|
|
1496
|
+
if (unverified) {
|
|
1497
|
+
tripwireNote(ctx, 'unverified', unverified.commit, `merged-unverified ${unverified.row} ${unverified.commit}`);
|
|
1498
|
+
return finish(ctx, { outcome: 'stopped', row: unverified.row, annotations: { switch: 'sentinel', tripwire: 'merged-unverified', commit: unverified.commit } });
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
const rows = seed.rows;
|
|
1502
|
+
const byId = new Map (rows.map((row ) => [row.id, row]));
|
|
1503
|
+
const { statuses } = deriveStatus(rows, facts);
|
|
1504
|
+
const skips = {};
|
|
1505
|
+
|
|
1506
|
+
// An open merge intent resumes S8 first (design §5).
|
|
1507
|
+
for (const intent of intents) {
|
|
1508
|
+
if (options.row && intent.row !== options.row) continue;
|
|
1509
|
+
const row = byId.get(intent.row);
|
|
1510
|
+
if (!row) { skips[intent.row] = 'row-unknown'; continue; }
|
|
1511
|
+
// C2: a seed hold set after the intent stops the merge before the fast-forward starts; from
|
|
1512
|
+
// ff-started on, mergeStep settles the row as under an off switch.
|
|
1513
|
+
if (row.hold && ['intent', 'built'].includes(intent.phase)) { skips[row.id] = 'row-held'; continue; }
|
|
1514
|
+
if (row.proposed === true && ['intent', 'built'].includes(intent.phase)) { skips[row.id] = 'row-proposed'; continue; }
|
|
1515
|
+
// C16, C6: a hold on this intent (develop-moved, plan-changed) stands while the intent, develop's
|
|
1516
|
+
// head and the feature plan are unchanged.
|
|
1517
|
+
const slot = (record.rows[row.id] ||= {});
|
|
1518
|
+
const holdDigest = sha256Digest(JSON.stringify({ intent, developHead: facts.developHead,
|
|
1519
|
+
plan: intent.featureWorktree ? featurePlan(ctx, row, intent.featureWorktree).sha256 : null }));
|
|
1520
|
+
if (slot.held?.kind === 'intent' && slot.held.inputsDigest === holdDigest) { skips[row.id] = `held:${slot.held.reason}`; continue; }
|
|
1521
|
+
// A merged-unverified intent is retried once a person cleared the sentinel: merge.ts rechecks
|
|
1522
|
+
// the head before render and cleanup, and a fresh failure trips the sentinel again.
|
|
1523
|
+
const outcome = await mergeStep(ctx, row, { intent, held: Boolean(row.hold), holdDigest });
|
|
1524
|
+
if (outcome.skip) { skips[row.id] = outcome.skip; continue; }
|
|
1525
|
+
return outcome;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// In-flight rows, least recently stepped first, then seed order; one row per call (C3).
|
|
1529
|
+
const inFlight = rows.filter((row ) => statuses.get(row.id)?.status === 'in-flight' && !skips[row.id] && (!options.row || row.id === options.row));
|
|
1530
|
+
const order = (row ) => record.rows[row.id]?.lastStep ?? -1;
|
|
1531
|
+
inFlight.sort((a , b ) => order(a) - order(b) || rows.indexOf(a) - rows.indexOf(b));
|
|
1532
|
+
for (const row of inFlight) {
|
|
1533
|
+
let outcome ;
|
|
1534
|
+
try { outcome = await stepRow(ctx, row, worktrees); }
|
|
1535
|
+
catch (error ) { if (error instanceof Unreadable) return unreadableStep(ctx, error, row); throw error; }
|
|
1536
|
+
if (outcome.skip) { skips[row.id] = outcome.skip; continue; }
|
|
1537
|
+
return outcome;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// Merged rows with no intent (merged by a person, or archived before cleanup finished): render,
|
|
1541
|
+
// then non-force cleanup (design §4 `merged -> render, then cleanup`).
|
|
1542
|
+
for (const row of rows) {
|
|
1543
|
+
// Only trailer evidence: a row merged by a seeded pre-108 pointer belongs to a person.
|
|
1544
|
+
const info = statuses.get(row.id);
|
|
1545
|
+
if (info?.status !== 'merged' || info.evidence?.kind !== 'trailer' || skips[row.id] || (options.row && row.id !== options.row)) continue;
|
|
1546
|
+
const worktree = worktrees.get(`refs/heads/feat/${row.slug}`) || null;
|
|
1547
|
+
if (!worktree && !facts.branches.includes(`feat/${row.slug}`)) continue;
|
|
1548
|
+
const outcome = finishMergedRow(ctx, row, rows, statuses, worktree, worktrees);
|
|
1549
|
+
if (outcome.skip) { skips[row.id] = outcome.skip; continue; }
|
|
1550
|
+
return outcome;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
if (!options.row) {
|
|
1554
|
+
const picked = pick(rows, statuses, { wip: ctx.policy.loop.wip });
|
|
1555
|
+
if (picked.picked) return claimStep(ctx, picked.picked, seed, facts.developHead);
|
|
1556
|
+
for (const [id, reason] of Object.entries(picked.skips)) skips[id] ??= reason ;
|
|
1557
|
+
}
|
|
1558
|
+
for (const row of rows) {
|
|
1559
|
+
if (skips[row.id]) continue;
|
|
1560
|
+
const status = statuses.get(row.id)?.status;
|
|
1561
|
+
if (options.row && row.id !== options.row) continue;
|
|
1562
|
+
skips[row.id] = status === 'merged' ? 'merged' : status === 'in-flight' ? 'in-flight' : 'not-in-flight';
|
|
1563
|
+
}
|
|
1564
|
+
return finish(ctx, { outcome: 'idle', skips, close: !options.row && inFlight.length === 0 && intents.length === 0 });
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
/** One `next` (IC-001). Returns the exit code and the JSON line; never throws for a refusal. */
|
|
1568
|
+
async function next(options = {}) {
|
|
1569
|
+
// One call, one git memo scope (git-memo.ts): the project root and common directory are asked once.
|
|
1570
|
+
return withGitMemo(() => nextInScope(options));
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
async function nextInScope(options ) {
|
|
1574
|
+
const fsImpl = options.fsImpl || fs;
|
|
1575
|
+
const env = options.env || process.env;
|
|
1576
|
+
const root = options.root || resolveMainRoot(options.cwd || process.cwd());
|
|
1577
|
+
const dir = path.join(root, '.doflow', 'state', 'roadmap');
|
|
1578
|
+
const ctx = {
|
|
1579
|
+
root, env, fsImpl, dir, runsDir: path.join(dir, 'runs'),
|
|
1580
|
+
now: options.now || (() => new Date()),
|
|
1581
|
+
execFileSync: options.execFileSync || defaultExecFileSync,
|
|
1582
|
+
gitOptions: gitOptionsFor(root, env),
|
|
1583
|
+
repoRoot: options.repoRoot || require('../../helper/repo-root.js').REPO_ROOT,
|
|
1584
|
+
seedPath: options.seedPath || path.join(root, 'agent-docs', 'roadmap', 'rows.json'),
|
|
1585
|
+
roadmapPath: options.roadmapPath || path.join(root, 'agent-docs', 'roadmap', 'ROADMAP.md'),
|
|
1586
|
+
mergeImpl: options.mergeImpl || mergeRoadmapRow,
|
|
1587
|
+
readSwitch: () => require('../settings/autopilot.js').readKillSwitch(env),
|
|
1588
|
+
};
|
|
1589
|
+
ctx.dispatchFacts = options.dispatchFacts || ((slug ) => defaultDispatchFacts(ctx, slug));
|
|
1590
|
+
ctx.resumeDigest = options.resumeDigest || ((slug , cwd ) => defaultResumeDigest(ctx, slug, cwd));
|
|
1591
|
+
let known = null;
|
|
1592
|
+
try { known = options.run ? readRunFile(path.join(ctx.runsDir, `${options.run}.json`), fsImpl) : readRunRecords(ctx.runsDir, fsImpl).filter((r) => !r.closedAt).at(-1); }
|
|
1593
|
+
catch { /* named below, under the lease */ }
|
|
1594
|
+
|
|
1595
|
+
// 1. Kill switch, before anything is taken or written. An intent at `ff-started` is only settled
|
|
1596
|
+
// (merge.ts, C3): a develop already at the commit records `ff-done` and a proven-dead partial is
|
|
1597
|
+
// repaired, then the call stops; a clean develop still at its old head returns `stopped` with
|
|
1598
|
+
// phase `ff-started` and no new fast-forward.
|
|
1599
|
+
const first = ctx.readSwitch();
|
|
1600
|
+
let settle = false;
|
|
1601
|
+
if (!first.enabled) {
|
|
1602
|
+
try { settle = readOpenIntents({ root, fsImpl }).some((intent ) => intent.phase === 'ff-started'); } catch { settle = false; }
|
|
1603
|
+
if (!settle) {
|
|
1604
|
+
return { code: 1, result: bare({ outcome: 'stopped', runId: known?.runId, checkpoint: known ? relCheckpoint(known.runId) : null, annotations: { switch: first.switch } }) };
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// 2. Lease, held for this one call.
|
|
1609
|
+
const me = { schema: 1, pid: options.pid ?? process.pid, start: new Date(Date.now() - (process ).uptime() * 1000).toISOString(),
|
|
1610
|
+
host: options.host ?? os.hostname(), runId: options.run || known?.runId || null, acquiredAt: ctx.now().toISOString() };
|
|
1611
|
+
const lease = acquireLease(dir, me, options.alive || noPsAlive, fsImpl);
|
|
1612
|
+
if (!lease.ok) {
|
|
1613
|
+
const holder = lease.holder && typeof lease.holder === 'object' ? { pid: lease.holder.pid ?? null, host: lease.holder.host ?? null, runId: lease.holder.runId ?? null } : null;
|
|
1614
|
+
return { code: 1, result: bare({ outcome: 'denied', reason: 'lease-held', runId: known?.runId, annotations: { holder } }) };
|
|
1615
|
+
}
|
|
1616
|
+
ctx.leaseText = lease.text;
|
|
1617
|
+
try {
|
|
1618
|
+
if (settle) {
|
|
1619
|
+
const loaded = loadRunRecord(ctx.runsDir, options.run, ctx.now(), fsImpl);
|
|
1620
|
+
ctx.record = loaded.record;
|
|
1621
|
+
ctx.fresh = loaded.fresh;
|
|
1622
|
+
const policy = loopPolicy(ctx);
|
|
1623
|
+
ctx.policy = policy.ok ? policy.policy : { merge: { staleLockSeconds: 120 } };
|
|
1624
|
+
const intent = readOpenIntents({ root, fsImpl }).find((entry ) => entry.phase === 'ff-started');
|
|
1625
|
+
const row = parseSeed(fsImpl.readFileSync(ctx.seedPath, 'utf8')).rows.find((entry ) => entry.id === intent?.row);
|
|
1626
|
+
if (intent && row) return await mergeStep(ctx, row, { intent });
|
|
1627
|
+
return finish(ctx, { outcome: 'stopped', annotations: { switch: first.switch } });
|
|
1628
|
+
}
|
|
1629
|
+
return await underLease(ctx, options);
|
|
1630
|
+
} catch (error ) {
|
|
1631
|
+
if (error instanceof Unreadable) {
|
|
1632
|
+
if (!ctx.record) return { code: 1, result: bare({ outcome: 'denied', reason: 'facts-unreadable', rule: 'loop/facts', annotations: { unreadable: error.sources } }) };
|
|
1633
|
+
return unreadableStep(ctx, error);
|
|
1634
|
+
}
|
|
1635
|
+
throw error;
|
|
1636
|
+
} finally {
|
|
1637
|
+
releaseLease(dir, me, fsImpl);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
async function nextCommand(options ) {
|
|
1642
|
+
const json = Boolean(options.json);
|
|
1643
|
+
if (options.run !== undefined && !RUN_ID.test(String(options.run))) return usageError('--run must be rl-<yyyymmdd>-<4 crockford characters>', json);
|
|
1644
|
+
let out ;
|
|
1645
|
+
try { out = await next(options); }
|
|
1646
|
+
catch (error ) {
|
|
1647
|
+
if (json) console.log(JSON.stringify({ schema: 1, action: 'next', error: 'runtime', message: error.message }));
|
|
1648
|
+
else console.error(`doflow roadmap: error: ${error.message}`);
|
|
1649
|
+
process.exitCode = 2;
|
|
1650
|
+
return 2;
|
|
1651
|
+
}
|
|
1652
|
+
const r = out.result;
|
|
1653
|
+
if (json) console.log(JSON.stringify(r));
|
|
1654
|
+
else {
|
|
1655
|
+
console.log(`roadmap next: ${r.outcome}${r.step ? ` ${r.step}` : ''}${r.row ? ` row ${r.row}` : ''}${r.reason ? `: ${r.reasons.join(', ')}` : ''}${r.annotations?.switch ? ` (switch: ${r.annotations.switch})` : ''}${r.runId ? ` [${r.runId} #${r.n}]` : ''}`);
|
|
1656
|
+
if (r.annotations?.hint) console.log(` ${r.annotations.hint}`);
|
|
1657
|
+
if (r.perform) console.log(JSON.stringify(r.perform));
|
|
1658
|
+
if (r.skips) for (const [id, reason] of Object.entries(r.skips)) console.log(` ${id.padEnd(6)} ${reason}`);
|
|
1659
|
+
}
|
|
1660
|
+
process.exitCode = out.code;
|
|
1661
|
+
return out.code;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/**
|
|
1665
|
+
* `audit`: every logged step of every run (or `--run`), oldest run first. Gate and merge entries
|
|
1666
|
+
* the 500-entry log or retention dropped come from audit.jsonl, with their inputs (C17).
|
|
1667
|
+
*/
|
|
1668
|
+
function auditCommand(options ) {
|
|
1669
|
+
const json = Boolean(options.json);
|
|
1670
|
+
const fsImpl = options.fsImpl || fs;
|
|
1671
|
+
if (options.run !== undefined && !RUN_ID.test(String(options.run))) return usageError('--run must be rl-<yyyymmdd>-<4 crockford characters>', json);
|
|
1672
|
+
const root = options.root || resolveMainRoot(options.cwd || process.cwd());
|
|
1673
|
+
const dir = path.join(root, '.doflow', 'state', 'roadmap');
|
|
1674
|
+
let records ;
|
|
1675
|
+
let durable ;
|
|
1676
|
+
try {
|
|
1677
|
+
records = readRunRecords(path.join(dir, 'runs'), fsImpl);
|
|
1678
|
+
durable = readAudit(dir, fsImpl);
|
|
1679
|
+
} catch (error ) {
|
|
1680
|
+
const sources = error instanceof Unreadable ? error.sources : ['run-records'];
|
|
1681
|
+
if (json) console.log(JSON.stringify({ schema: 1, action: 'audit', error: 'cannot-read-facts', sources }));
|
|
1682
|
+
else console.error(`doflow roadmap: cannot read ${sources.join(', ')}`);
|
|
1683
|
+
process.exitCode = 1;
|
|
1684
|
+
return 1;
|
|
1685
|
+
}
|
|
1686
|
+
// A run sorts by its record's createdAt, or by its first audit entry once the record is pruned; an
|
|
1687
|
+
// entry in both places is shown once, with the audit file's inputs.
|
|
1688
|
+
const byRun = new Map ();
|
|
1689
|
+
const add = (runId , entry , key ) => {
|
|
1690
|
+
if (options.run && runId !== options.run) return;
|
|
1691
|
+
const run = byRun.get(runId) || { key, entries: new Map() };
|
|
1692
|
+
byRun.set(runId, run);
|
|
1693
|
+
if (key < run.key) run.key = key;
|
|
1694
|
+
if (!run.entries.has(entry.n)) run.entries.set(entry.n, { runId, ...entry });
|
|
1695
|
+
};
|
|
1696
|
+
for (const entry of durable) {
|
|
1697
|
+
if (entry && typeof entry.runId === 'string' && Number.isSafeInteger(entry.n)) {
|
|
1698
|
+
const rest = { ...entry };
|
|
1699
|
+
delete rest.schema;
|
|
1700
|
+
delete rest.runId;
|
|
1701
|
+
add(entry.runId, rest, String(entry.at));
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
for (const record of records) for (const entry of record.log) add(record.runId, entry, record.createdAt);
|
|
1705
|
+
const entries = [...byRun.entries()]
|
|
1706
|
+
.sort(([a, x], [b, y]) => (x.key < y.key ? -1 : x.key > y.key ? 1 : a < b ? -1 : 1))
|
|
1707
|
+
.flatMap(([, run]) => [...run.entries.values()].sort((a, b) => a.n - b.n));
|
|
1708
|
+
if (json) console.log(JSON.stringify({ schema: 1, action: 'audit', entries }));
|
|
1709
|
+
else for (const entry of entries) console.log(`${entry.at} ${entry.runId} #${entry.n} ${entry.audit}`);
|
|
1710
|
+
return 0;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
module.exports = {
|
|
1714
|
+
handleRoadmapCommand,
|
|
1715
|
+
next,
|
|
1716
|
+
// The lease primitive, exported for its interleaving test (C8).
|
|
1717
|
+
acquireLease,
|
|
1718
|
+
releaseLease, resolveMainRoot, noPsAlive,
|
|
1719
|
+
};
|