@khoavu882/doflow 0.0.0-stage → 2.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +132 -2
- package/bin/doflow.js +44 -0
- package/core/.antigravity-plugin/hooks.json +39 -0
- package/core/.antigravity-plugin/mcp_config.json +18 -0
- package/core/.antigravity-plugin/plugin.json +31 -0
- package/core/.claude-plugin/marketplace.json +20 -0
- package/core/.claude-plugin/plugin.json +21 -0
- package/core/.codex-plugin/marketplace.json +20 -0
- package/core/.codex-plugin/plugin.json +35 -0
- package/core/.plugin/plugin.json +19 -0
- package/core/harnesses/antigravity/hooks/pre-implementation-gate.sh +9 -0
- package/core/harnesses/antigravity/hooks/stop-check.sh +10 -0
- package/core/harnesses/claude/hooks/lib.sh +304 -0
- package/core/harnesses/claude/hooks/mcp-tool-guard.sh +20 -0
- package/core/harnesses/claude/hooks/permission-audit.sh +47 -0
- package/core/harnesses/claude/hooks/post-compact.sh +74 -0
- package/core/harnesses/claude/hooks/post-edit-lint.sh +36 -0
- package/core/harnesses/claude/hooks/post-tool-failure.sh +47 -0
- package/core/harnesses/claude/hooks/pre-bash-guard.sh +20 -0
- package/core/harnesses/claude/hooks/pre-compact.sh +43 -0
- package/core/harnesses/claude/hooks/pre-implement-gate.sh +21 -0
- package/core/harnesses/claude/hooks/session-end.sh +89 -0
- package/core/harnesses/claude/hooks/session-start.sh +5 -0
- package/core/harnesses/claude/hooks/skill-config-audit.sh +39 -0
- package/core/harnesses/claude/hooks/stop-check.sh +5 -0
- package/core/harnesses/claude/hooks/subagent-audit.sh +7 -0
- package/core/harnesses/claude/hooks/user-prompt-submit.sh +5 -0
- package/core/harnesses/claude/settings/keybindings.json +20 -0
- package/core/harnesses/claude/settings/settings.json +201 -0
- package/core/harnesses/claude/statusline/doflow-statusline.sh +49 -0
- package/core/harnesses/codex/agents/core-implementer.toml +7 -0
- package/core/harnesses/codex/agents/quality-guardian.toml +8 -0
- package/core/harnesses/codex/agents/research-writer.toml +8 -0
- package/core/harnesses/codex/agents/spec-analyst.toml +8 -0
- package/core/harnesses/codex/agents/system-architect.toml +9 -0
- package/core/harnesses/codex/config/config.toml +10 -0
- package/core/harnesses/codex/hooks/hooks.json +122 -0
- package/core/harnesses/codex/hooks/lib.sh +335 -0
- package/core/harnesses/codex/hooks/mcp-tool-guard.sh +18 -0
- package/core/harnesses/codex/hooks/post-edit-lint.impl.sh +52 -0
- package/core/harnesses/codex/hooks/post-edit-lint.sh +6 -0
- package/core/harnesses/codex/hooks/pre-bash-guard.sh +18 -0
- package/core/harnesses/codex/hooks/pre-compact.impl.sh +46 -0
- package/core/harnesses/codex/hooks/pre-compact.sh +9 -0
- package/core/harnesses/codex/hooks/pre-implement-gate.sh +19 -0
- package/core/harnesses/codex/hooks/session-end.impl.sh +92 -0
- package/core/harnesses/codex/hooks/session-end.sh +5 -0
- package/core/harnesses/codex/hooks/session-start.sh +5 -0
- package/core/harnesses/codex/hooks/stop-check.sh +8 -0
- package/core/harnesses/codex/hooks/subagent-audit.sh +8 -0
- package/core/harnesses/codex/hooks/user-prompt-submit.sh +6 -0
- package/core/harnesses/gemini/hooks/hooks.json +77 -0
- package/core/harnesses/gemini/hooks/lib.sh +302 -0
- package/core/harnesses/gemini/hooks/mcp-tool-guard.sh +21 -0
- package/core/harnesses/gemini/hooks/post-edit-lint.sh +39 -0
- package/core/harnesses/gemini/hooks/pre-bash-guard.sh +21 -0
- package/core/harnesses/gemini/hooks/pre-compact.sh +38 -0
- package/core/harnesses/gemini/hooks/pre-implement-gate.sh +22 -0
- package/core/harnesses/gemini/hooks/session-end.impl.sh +89 -0
- package/core/harnesses/gemini/hooks/session-end.sh +5 -0
- package/core/harnesses/gemini/hooks/session-start.sh +5 -0
- package/core/harnesses/gemini/settings/adapter-defaults.json +4 -0
- package/core/harnesses/kiro/agents/doflow.json +58 -0
- package/core/harnesses/kiro/hooks/doflow.json +53 -0
- package/core/harnesses/kiro/hooks/lib.sh +324 -0
- package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +9 -0
- package/core/harnesses/kiro/hooks/pre-bash-guard.sh +9 -0
- package/core/harnesses/kiro/hooks/pre-implement-gate.sh +9 -0
- package/core/harnesses/kiro/hooks/session-start.sh +9 -0
- package/core/harnesses/kiro/hooks/stop-check.sh +43 -0
- package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +39 -0
- package/core/harnesses/shared/hooks/adapters/antigravity.js +111 -0
- package/core/harnesses/shared/hooks/adapters/arg-fields.js +19 -0
- package/core/harnesses/shared/hooks/adapters/gemini.js +114 -0
- package/core/harnesses/shared/hooks/hooks.json +47 -0
- package/core/harnesses/shared/hooks/policies/blocked-patterns.conf +91 -0
- package/core/harnesses/shared/hooks/policies/capture-failure.sh +97 -0
- package/core/harnesses/shared/hooks/policies/deny-json.sh +29 -0
- package/core/harnesses/shared/hooks/policies/lib.sh +501 -0
- package/core/harnesses/shared/hooks/policies/mcp-policy.conf +21 -0
- package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +91 -0
- package/core/harnesses/shared/hooks/policies/pre-bash-guard.sh +328 -0
- package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +496 -0
- package/core/harnesses/shared/hooks/policies/prompt-nudge.jq +131 -0
- package/core/harnesses/shared/hooks/policies/session-context.sh +141 -0
- package/core/harnesses/shared/hooks/policies/stop-check.sh +264 -0
- package/core/harnesses/shared/hooks/policies/subagent-audit.sh +46 -0
- package/core/harnesses/shared/hooks/policies/user-prompt-submit.sh +235 -0
- package/core/harnesses/shared/hooks/stream-hook-runner.js +590 -0
- package/core/harnesses/shared/locator/doflow-run +87 -0
- package/core/registry/advice.json +101 -0
- package/core/registry/assets.json +792 -0
- package/core/registry/capabilities.json +120 -0
- package/core/registry/contracts.json +189 -0
- package/core/registry/external-tools.json +191 -0
- package/core/registry/harnesses.json +2062 -0
- package/core/registry/lifecycle.json +231 -0
- package/core/registry/mcp.json +18 -0
- package/core/registry/models.json +79 -0
- package/core/registry/readiness-templates.json +118 -0
- package/core/registry/routes.json +47 -0
- package/core/registry/storage.json +1057 -0
- package/core/registry/verification.json +258 -0
- package/core/registry/workflows.json +790 -0
- package/core/shared/agent-specs/core-implementer.md +73 -0
- package/core/shared/agent-specs/quality-guardian.md +28 -0
- package/core/shared/agent-specs/research-writer.md +31 -0
- package/core/shared/agent-specs/spec-analyst.md +33 -0
- package/core/shared/agent-specs/system-architect.md +29 -0
- package/core/shared/guidance/DOFLOW_CORE.md +8 -0
- package/core/shared/guidance/FLAGS.md +25 -0
- package/core/shared/guidance/PRINCIPLES.md +61 -0
- package/core/shared/guidance/VERSION +1 -0
- package/core/shared/guidance/mcp/MCP_Context7.md +18 -0
- package/core/shared/guidance/mcp/MCP_Sequential.md +17 -0
- package/core/shared/guidance/modes/MODE_Brainstorming.md +46 -0
- package/core/shared/guidance/modes/MODE_DeepResearch.md +69 -0
- package/core/shared/guidance/modes/MODE_Introspection.md +33 -0
- package/core/shared/guidance/modes/MODE_Orchestration.md +57 -0
- package/core/shared/guidance/modes/MODE_Task_Management.md +74 -0
- package/core/shared/guidance/pointers/claude-gemini.md +2 -0
- package/core/shared/guidance/pointers/codex.md +6 -0
- package/core/shared/guidance/references/ARTIFACT_FORMAT.md +373 -0
- package/core/shared/guidance/references/ARTIFACT_VOICE.md +104 -0
- package/core/shared/guidance/references/CONSTITUTION_BASE.md +47 -0
- package/core/shared/guidance/references/DIAGRAM_GUIDE.md +161 -0
- package/core/shared/guidance/references/DOFLOW_CHAIN.md +87 -0
- package/core/shared/guidance/references/EVIDENCE_LEDGER.md +93 -0
- package/core/shared/guidance/references/MODEL_SELECTION.md +74 -0
- package/core/shared/guidance/references/ORCHESTRATION.md +36 -0
- package/core/shared/guidance/references/RESEARCH_CONFIG.md +21 -0
- package/core/shared/guidance/references/WORKFLOW_HANDOFF.md +81 -0
- package/core/shared/guidance/rules/RULE_01_SAFETY.md +25 -0
- package/core/shared/guidance/rules/RULE_02_WORKFLOW.md +23 -0
- package/core/shared/guidance/rules/RULE_03_QUALITY.md +14 -0
- package/core/shared/guidance/rules/RULE_04_QUESTIONS.md +46 -0
- package/core/shared/scripts/doflow/assets/mermaid-theme.json +46 -0
- package/core/shared/scripts/doflow/bash/do-exec-paths.sh +167 -0
- package/core/shared/scripts/doflow/bash/do-git-state.sh +546 -0
- package/core/shared/scripts/doflow/bash/do-parallel-check.sh +208 -0
- package/core/shared/scripts/doflow/bash/do-paths.sh +347 -0
- package/core/shared/scripts/doflow/bash/do-prereqs.sh +66 -0
- package/core/shared/scripts/doflow/bash/do-review-package.sh +127 -0
- package/core/shared/scripts/doflow/bash/do-task-brief.sh +698 -0
- package/core/shared/scripts/doflow/bash/render-audit.sh +224 -0
- package/core/shared/scripts/doflow/bash/render-diagrams.sh +177 -0
- package/core/shared/scripts/doflow/bash/render-puml.sh +223 -0
- package/core/shared/scripts/doflow/bash/sync-context.sh +66 -0
- package/core/shared/scripts/doflow/bash/validate-artifacts.sh +526 -0
- package/core/shared/scripts/doflow/bin/doflow-run +592 -0
- package/core/shared/skills/do/SKILL.md +128 -0
- package/core/shared/skills/do/references/estimation.md +13 -0
- package/core/shared/skills/do/references/pm_routing.md +25 -0
- package/core/shared/skills/do/references/tool_matrix.md +52 -0
- package/core/shared/skills/do-brainstorm/SKILL.md +252 -0
- package/core/shared/skills/do-code-review/README.md +49 -0
- package/core/shared/skills/do-code-review/SKILL.md +333 -0
- package/core/shared/skills/do-code-review/assets/sample_c_clean.c +71 -0
- package/core/shared/skills/do-code-review/assets/sample_c_smells.c +66 -0
- package/core/shared/skills/do-code-review/assets/sample_cpp_shift.cpp +19 -0
- package/core/shared/skills/do-code-review/assets/sample_csharp_clean.cs +100 -0
- package/core/shared/skills/do-code-review/assets/sample_csharp_smells.cs +78 -0
- package/core/shared/skills/do-code-review/assets/sample_java_clean.java +55 -0
- package/core/shared/skills/do-code-review/assets/sample_java_smells.java +56 -0
- package/core/shared/skills/do-code-review/assets/sample_json_clean.json +7 -0
- package/core/shared/skills/do-code-review/assets/sample_markdown_clean.md +30 -0
- package/core/shared/skills/do-code-review/assets/sample_markdown_smells.md +29 -0
- package/core/shared/skills/do-code-review/assets/sample_shell_heredoc.sh +38 -0
- package/core/shared/skills/do-code-review/assets/sample_shell_smells.sh +15 -0
- package/core/shared/skills/do-code-review/assets/sample_typescript_strings.ts +36 -0
- package/core/shared/skills/do-code-review/assets/sample_yaml_smells.yaml +6 -0
- package/core/shared/skills/do-code-review/content-types/config.md +83 -0
- package/core/shared/skills/do-code-review/content-types/markdown.md +132 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_c_clean_quality.json +79 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_c_smells_quality.json +169 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_cpp_shift_quality.json +49 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_csharp_clean_quality.json +149 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_csharp_smells_quality.json +157 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_java_clean_quality.json +67 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_java_smells_quality.json +97 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_json_clean_quality.json +33 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_markdown_clean_quality.json +1 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_markdown_smells_quality.json +16 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_shell_heredoc_quality.json +54 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_shell_smells_quality.json +91 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_typescript_strings_quality.json +60 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_yaml_smells_quality.json +46 -0
- package/core/shared/skills/do-code-review/languages/c.md +103 -0
- package/core/shared/skills/do-code-review/languages/cpp.md +103 -0
- package/core/shared/skills/do-code-review/languages/csharp.md +97 -0
- package/core/shared/skills/do-code-review/languages/dart.md +108 -0
- package/core/shared/skills/do-code-review/languages/go.md +92 -0
- package/core/shared/skills/do-code-review/languages/java.md +103 -0
- package/core/shared/skills/do-code-review/languages/kotlin.md +88 -0
- package/core/shared/skills/do-code-review/languages/php.md +109 -0
- package/core/shared/skills/do-code-review/languages/python.md +94 -0
- package/core/shared/skills/do-code-review/languages/ruby.md +105 -0
- package/core/shared/skills/do-code-review/languages/rust.md +99 -0
- package/core/shared/skills/do-code-review/languages/shell.md +90 -0
- package/core/shared/skills/do-code-review/languages/swift.md +88 -0
- package/core/shared/skills/do-code-review/languages/typescript.md +97 -0
- package/core/shared/skills/do-code-review/review-policy.json +47 -0
- package/core/shared/skills/do-code-review/rules/universal.md +49 -0
- package/core/shared/skills/do-code-review/scripts/code_quality_checker.py +1696 -0
- package/core/shared/skills/do-code-review/scripts/doc_quality_checker.py +532 -0
- package/core/shared/skills/do-code-review/scripts/pr_analyzer.py +620 -0
- package/core/shared/skills/do-code-review/scripts/review_report_generator.py +505 -0
- package/core/shared/skills/do-constitution/SKILL.md +72 -0
- package/core/shared/skills/do-design/SKILL.md +212 -0
- package/core/shared/skills/do-diagnose/SKILL.md +156 -0
- package/core/shared/skills/do-diagnose/references/code_audit.md +15 -0
- package/core/shared/skills/do-diagnose/references/refactoring.md +12 -0
- package/core/shared/skills/do-diagnose/references/root_cause.md +17 -0
- package/core/shared/skills/do-document/SKILL.md +81 -0
- package/core/shared/skills/do-document/references/api-reference.md +42 -0
- package/core/shared/skills/do-document/references/deep_research.md +18 -0
- package/core/shared/skills/do-document/references/embedded-research.md +11 -0
- package/core/shared/skills/do-document/references/implementation-flow.md +105 -0
- package/core/shared/skills/do-document/references/index_generator.md +9 -0
- package/core/shared/skills/do-document/references/user-guide.md +22 -0
- package/core/shared/skills/do-execute-plan/SKILL.md +261 -0
- package/core/shared/skills/do-execute-plan/references/handoff_record.md +57 -0
- package/core/shared/skills/do-execute-plan/references/parallel_dispatch.md +92 -0
- package/core/shared/skills/do-execute-plan/references/readiness_gate.md +155 -0
- package/core/shared/skills/do-execute-plan/references/run_position.md +63 -0
- package/core/shared/skills/do-execute-plan/references/scaffold.md +441 -0
- package/core/shared/skills/do-execute-plan/references/task_attempts.md +41 -0
- package/core/shared/skills/do-flow/SKILL.md +186 -0
- package/core/shared/skills/do-flow/references/task_classes.md +134 -0
- package/core/shared/skills/do-git/SKILL.md +115 -0
- package/core/shared/skills/do-git/references/hotfix-ritual.md +44 -0
- package/core/shared/skills/do-git/references/lifecycle-policy.md +54 -0
- package/core/shared/skills/do-git/references/release-ritual.md +64 -0
- package/core/shared/skills/do-implement/SKILL.md +222 -0
- package/core/shared/skills/do-plan/SKILL.md +219 -0
- package/core/shared/skills/do-test/SKILL.md +113 -0
- package/core/shared/templates/doflow/constitution-template.md +28 -0
- package/core/shared/templates/doflow/data-model-template.md +169 -0
- package/core/shared/templates/doflow/design-template.md +193 -0
- package/core/shared/templates/doflow/external-contract-template.md +35 -0
- package/core/shared/templates/doflow/intent-template.md +45 -0
- package/core/shared/templates/doflow/plan-template.md +151 -0
- package/core/shared/templates/doflow/question-log-template.md +51 -0
- package/core/shared/templates/doflow/requirement-template.md +126 -0
- package/core/shared/templates/doflow/specs-template.md +55 -0
- package/core/shared/templates/doflow/state-template.md +59 -0
- package/core/shared/workflows/do-flow-chain.md +17 -0
- package/package.json +46 -4
- package/src/adapters/antigravity/index.ts +631 -0
- package/src/adapters/claude/index.ts +548 -0
- package/src/adapters/claude/mcp.ts +94 -0
- package/src/adapters/codex/agents.ts +208 -0
- package/src/adapters/codex/config.ts +272 -0
- package/src/adapters/codex/hooks.ts +203 -0
- package/src/adapters/codex/index.ts +707 -0
- package/src/adapters/codex/mcp.ts +271 -0
- package/src/adapters/codex/native-projection.ts +34 -0
- package/src/adapters/copilot/index.ts +413 -0
- package/src/adapters/copy-tree.ts +620 -0
- package/src/adapters/gemini/hooks.ts +210 -0
- package/src/adapters/gemini/index.ts +390 -0
- package/src/adapters/hook-commands.ts +60 -0
- package/src/adapters/index.ts +106 -0
- package/src/adapters/kiro/index.ts +310 -0
- package/src/adapters/mcp-entries.ts +331 -0
- package/src/adapters/opencode/index.ts +466 -0
- package/src/adapters/pi/index.ts +279 -0
- package/src/adapters/pi/json-members.ts +185 -0
- package/src/adapters/pi/mcp.ts +373 -0
- package/src/adapters/resolve-source.ts +19 -0
- package/src/cli/commands/install.ts +218 -0
- package/src/cli/commands/list-backups.ts +21 -0
- package/src/cli/commands/reconcile.ts +120 -0
- package/src/cli/commands/remove.ts +82 -0
- package/src/cli/commands/rollback.ts +172 -0
- package/src/cli/commands/self-update.ts +33 -0
- package/src/cli/commands/status.ts +138 -0
- package/src/cli/commands/tools.ts +92 -0
- package/src/cli/commands/update.ts +162 -0
- package/src/cli/index.ts +568 -0
- package/src/cli/runtime-commands.ts +281 -0
- package/src/cli/shared.ts +218 -0
- package/src/helper/bash-helper.ts +34 -0
- package/src/helper/git.ts +24 -0
- package/src/helper/guardrails.ts +49 -0
- package/src/helper/harness-paths.ts +140 -0
- package/src/helper/marker-merge.ts +116 -0
- package/src/helper/prompt.ts +61 -0
- package/src/helper/repo-root.ts +29 -0
- package/src/helper/settings-merge.ts +151 -0
- package/src/helper/settings-scope.ts +33 -0
- package/src/helper/toml.ts +244 -0
- package/src/install/backup-set.ts +149 -0
- package/src/install/backup.ts +724 -0
- package/src/install/context.ts +58 -0
- package/src/install/manifest.ts +145 -0
- package/src/install/mcp.ts +254 -0
- package/src/install/paths.ts +84 -0
- package/src/install/targets.ts +90 -0
- package/src/install/tool-lifecycle.ts +151 -0
- package/src/lifecycle/bash-availability.ts +25 -0
- package/src/lifecycle/index.ts +812 -0
- package/src/lifecycle/mcp-index.ts +28 -0
- package/src/lifecycle/policies.ts +73 -0
- package/src/lifecycle/view.ts +175 -0
- package/src/registry/index.ts +611 -0
- package/src/release/sync-plugin-versions.ts +66 -0
- package/src/runtime/advice/cli.ts +283 -0
- package/src/runtime/advice/client.ts +324 -0
- package/src/runtime/advice/config.ts +189 -0
- package/src/runtime/advice/doctor.ts +169 -0
- package/src/runtime/advice/endpoint-guard.ts +134 -0
- package/src/runtime/advice/kinds.ts +154 -0
- package/src/runtime/advice/providers/ollama-systemone.ts +209 -0
- package/src/runtime/advice/record.ts +166 -0
- package/src/runtime/advice/registry.ts +170 -0
- package/src/runtime/advice/strength.ts +66 -0
- package/src/runtime/brief-tools.ts +342 -0
- package/src/runtime/c4-project.ts +371 -0
- package/src/runtime/capability-router.ts +484 -0
- package/src/runtime/checkouts.ts +170 -0
- package/src/runtime/claims.ts +468 -0
- package/src/runtime/cli-result.ts +55 -0
- package/src/runtime/cli.ts +678 -0
- package/src/runtime/command-detect.ts +516 -0
- package/src/runtime/context-pack.ts +492 -0
- package/src/runtime/decision-register.ts +493 -0
- package/src/runtime/evidence-ledger.ts +338 -0
- package/src/runtime/failure/capture.ts +272 -0
- package/src/runtime/failure/classifier.ts +68 -0
- package/src/runtime/failure/cli.ts +178 -0
- package/src/runtime/failure/home.ts +64 -0
- package/src/runtime/failure/store.ts +226 -0
- package/src/runtime/feature-resolve.ts +79 -0
- package/src/runtime/freshness.ts +177 -0
- package/src/runtime/health.ts +863 -0
- package/src/runtime/history-compactor.ts +237 -0
- package/src/runtime/implementation-gate.ts +321 -0
- package/src/runtime/inventory/coverage.ts +218 -0
- package/src/runtime/inventory/identity.ts +65 -0
- package/src/runtime/inventory/index.ts +705 -0
- package/src/runtime/inventory/precedence.ts +134 -0
- package/src/runtime/inventory/read-scopes.ts +131 -0
- package/src/runtime/inventory/siblings.ts +192 -0
- package/src/runtime/knowledge/chunker.ts +124 -0
- package/src/runtime/knowledge/hybrid.ts +149 -0
- package/src/runtime/knowledge/index-store.ts +145 -0
- package/src/runtime/knowledge/retrieval.ts +194 -0
- package/src/runtime/leak-scan.ts +185 -0
- package/src/runtime/lifecycle/cli.ts +276 -0
- package/src/runtime/lifecycle/event-store.ts +340 -0
- package/src/runtime/lifecycle/fold.ts +353 -0
- package/src/runtime/lifecycle/followup.ts +432 -0
- package/src/runtime/lifecycle/goal.ts +249 -0
- package/src/runtime/lifecycle/intent-writer.ts +86 -0
- package/src/runtime/lifecycle/overview.ts +292 -0
- package/src/runtime/lifecycle/release.ts +241 -0
- package/src/runtime/lifecycle/report-store.ts +221 -0
- package/src/runtime/lifecycle/retention.ts +212 -0
- package/src/runtime/lifecycle/root.ts +71 -0
- package/src/runtime/lifecycle/status.ts +353 -0
- package/src/runtime/lifecycle/store-upkeep.ts +393 -0
- package/src/runtime/locator-resolve.ts +158 -0
- package/src/runtime/mask.ts +210 -0
- package/src/runtime/mcp-consultations.ts +412 -0
- package/src/runtime/mcp-reach.ts +157 -0
- package/src/runtime/model-router.ts +153 -0
- package/src/runtime/outcome.ts +467 -0
- package/src/runtime/reach.ts +148 -0
- package/src/runtime/readiness-record.ts +150 -0
- package/src/runtime/readiness.ts +435 -0
- package/src/runtime/recovery.ts +282 -0
- package/src/runtime/research-request.ts +231 -0
- package/src/runtime/retrieval-plan.ts +578 -0
- package/src/runtime/scaffold/artifacts.ts +217 -0
- package/src/runtime/scaffold/fingerprint.ts +56 -0
- package/src/runtime/scaffold/generate.ts +911 -0
- package/src/runtime/scaffold/languages.ts +245 -0
- package/src/runtime/storage/cli.ts +64 -0
- package/src/runtime/storage/engine.ts +249 -0
- package/src/runtime/storage/registry.ts +130 -0
- package/src/runtime/storage/roots.ts +63 -0
- package/src/runtime/storage/rules.ts +282 -0
- package/src/runtime/storage/upkeep.ts +243 -0
- package/src/runtime/storage/work-status.ts +326 -0
- package/src/runtime/task-attempts.ts +415 -0
- package/src/runtime/task-classifier.ts +550 -0
- package/src/runtime/task-scope.ts +130 -0
- package/src/runtime/task-state.ts +176 -0
- package/src/runtime/trace/indicators.ts +235 -0
- package/src/runtime/trace/ledger.ts +626 -0
- package/src/runtime/trace/render.ts +76 -0
- package/src/runtime/trace/views.ts +459 -0
- package/src/runtime/verification/contract-runner.ts +341 -0
- package/src/runtime/verification/engine.ts +1461 -0
- package/src/runtime/verification/registry.ts +130 -0
- package/src/runtime/verification/scope-bound.ts +418 -0
- package/src/runtime/verification/test-selection.ts +172 -0
- package/src/runtime/workflow-engine.ts +588 -0
- package/src/runtime/workflow-orchestrator.ts +953 -0
- package/src/runtime/worktree.ts +340 -0
- package/src/state/index.ts +193 -0
- package/src/state/lockfile.ts +136 -0
- package/src/state/run-lock.ts +408 -0
- package/types/node-commonjs.d.ts +68 -0
- package/types/runtime.d.ts +91 -0
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// TypeScript migration (agent-docs/BRIEF_GOAL_DOFLOW.md): run directly by Node native type-stripping.
|
|
4
|
+
|
|
5
|
+
// Native state and formats remain adapter-owned. The lifecycle layer selects
|
|
6
|
+
// registry assets, validates declarative changes, journals mutations, and keeps
|
|
7
|
+
// the neutral ledger in sync only after successful verification.
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
const crypto = require('node:crypto');
|
|
11
|
+
const { harnessFor, selectAssets, selectMcpServers, mcpCapable } = require('../registry/index.ts');
|
|
12
|
+
const { defaultLedger, ownershipKey, writeLedger, writeRecoveryRecord, upgradeLedger, LEDGER_VERSION } = require('../state/index.ts');
|
|
13
|
+
const { resolveAdapter, projectAdapterInput } = require('../adapters/index.ts');
|
|
14
|
+
const { pruneEmptyAncestors, discoverTree, sameFileIndex, sharedTreeDestDir, walkRelFiles } = require('../adapters/copy-tree.ts');
|
|
15
|
+
const { renderPolicies } = require('./policies.ts');
|
|
16
|
+
const { renderMcpIndex } = require('./mcp-index.ts');
|
|
17
|
+
const { hasBashCapableShell } = require('./bash-availability.ts');
|
|
18
|
+
const { hookTrustFor: geminiHookTrustFor } = require('../adapters/gemini/index.ts');
|
|
19
|
+
|
|
20
|
+
const OPERATIONS = new Set(['create', 'merge', 'update', 'remove']);
|
|
21
|
+
/** A harness's MCP selection meaning "the servers it owns now", read through its adapter's
|
|
22
|
+
* `discover().mcpOwned`. */
|
|
23
|
+
const MCP_KEEP = 'keep';
|
|
24
|
+
|
|
25
|
+
function registryScope(scope: any) { return scope === 'global' ? 'user' : scope; }
|
|
26
|
+
function assertScope(scope: any) {
|
|
27
|
+
if (scope !== 'project' && scope !== 'global') throw new Error(`Lifecycle scope must be project or global, got '${scope}'`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeTargets(registry: any, targets: any) {
|
|
31
|
+
const wanted = targets && targets.length ? targets : registry.harnesses.map((harness: any) => harness.id);
|
|
32
|
+
if (!Array.isArray(wanted)) throw new Error('Lifecycle targets must be an array');
|
|
33
|
+
return [...new Set(wanted)].map((id) => harnessFor(registry, id));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function identityFor(change: any) { return change.ownershipIdentity ?? change.ownership; }
|
|
37
|
+
|
|
38
|
+
function normalizeChange(change: any, { harness, assets }: any) {
|
|
39
|
+
if (!change || typeof change !== 'object') throw new Error(`Adapter '${harness.id}' returned a non-object change`);
|
|
40
|
+
const ownershipIdentity = identityFor(change);
|
|
41
|
+
if (change.harness !== undefined && change.harness !== harness.id) throw new Error(`Adapter '${harness.id}' returned a change for '${change.harness}'`);
|
|
42
|
+
if (!assets.some((asset: any) => asset.id === change.assetId)) throw new Error(`Adapter '${harness.id}' returned unknown asset '${change.assetId}'`);
|
|
43
|
+
if (!OPERATIONS.has(change.operation)) throw new Error(`Adapter '${harness.id}' returned unsupported operation '${change.operation}'`);
|
|
44
|
+
if (typeof change.target !== 'string' || !change.target) throw new Error(`Adapter '${harness.id}' change '${change.assetId}' requires target`);
|
|
45
|
+
if (typeof ownershipIdentity !== 'string' || !ownershipIdentity) throw new Error(`Adapter '${harness.id}' change '${change.assetId}' requires ownershipIdentity`);
|
|
46
|
+
return Object.freeze({ ...change, harness: harness.id, ownershipIdentity });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeVerification(result: any, harness: any) {
|
|
50
|
+
if (!result || typeof result !== 'object') throw new Error(`Adapter '${harness.id}' returned an invalid verification result`);
|
|
51
|
+
const statuses = Array.isArray(result.statuses) ? result.statuses : [];
|
|
52
|
+
const resources = Array.isArray(result.resources) ? result.resources : [];
|
|
53
|
+
return { ...result, harness: harness.id, statuses, resources };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function matchesChange(resource: any, change: any) {
|
|
57
|
+
if (resource?.assetId !== change.assetId || resource?.target !== change.target) return false;
|
|
58
|
+
const resourceOwnership = resource?.ownershipIdentity ?? resource?.ownership;
|
|
59
|
+
const changeOwnership = change.ownershipIdentity ?? change.ownership;
|
|
60
|
+
// Multiple independently-managed records may share one native container
|
|
61
|
+
// (for example Codex config entries and MCP tables in config.toml). Match an
|
|
62
|
+
// exact ownership identity when available, otherwise the adapter's native
|
|
63
|
+
// resource identity; never treat a shared target as the resource itself.
|
|
64
|
+
if (resourceOwnership && changeOwnership && resourceOwnership === changeOwnership) return true;
|
|
65
|
+
return typeof resource?.identity === 'string' && typeof change?.identity === 'string' && resource.identity === change.identity;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whether one removed change is genuinely gone, and if so, how `statuses` should record that.
|
|
69
|
+
* `statuses` is read live, not a snapshot — an earlier change in the same removal batch can add
|
|
70
|
+
* or update an entry a later change's own lookup then finds. */
|
|
71
|
+
function reconcileRemovedChange(verification: any, statuses: any, change: any) {
|
|
72
|
+
const reported = verification.resources.some((resource: any) => matchesChange(resource, change));
|
|
73
|
+
const statusIndex = statuses.findIndex((item: any) => matchesChange(item, change));
|
|
74
|
+
const status = statusIndex >= 0 ? statuses[statusIndex] : null;
|
|
75
|
+
// 'absent' belongs here alongside 'missing': a shared file (settings.json) survives removal
|
|
76
|
+
// holding the user's own content, and its adapter reports 'absent' once none of DoFlow's
|
|
77
|
+
// entries remain. That is a successful strip, not a resource that refused to go away.
|
|
78
|
+
if (reported || (status && !['missing', 'absent', 'removed', 'not-managed'].includes(status.status))) {
|
|
79
|
+
return { conflict: `Removed resource remains present: ${change.target}` };
|
|
80
|
+
}
|
|
81
|
+
const removedStatus = {
|
|
82
|
+
...(status ?? { harness: change.harness, assetId: change.assetId, target: change.target, ownershipIdentity: change.ownershipIdentity }),
|
|
83
|
+
status: 'removed', expectedRemoval: true,
|
|
84
|
+
};
|
|
85
|
+
return { statusIndex, removedStatus };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A missing resource is success only when it is exactly one that this plan
|
|
89
|
+
* removed. All unrelated status rows remain untouched, and a removed resource
|
|
90
|
+
* still reported as present is a verification conflict. */
|
|
91
|
+
/** An adapter's verifier enumerates what the *current source* declares and marks anything not on
|
|
92
|
+
* disk 'missing'. That is the right question for install/update and the inverted one for removal,
|
|
93
|
+
* and it misfires outright when the machine's state predates a rename: v1.0.0-beta.2 consolidated
|
|
94
|
+
* 14 agent specs into 5, so `doflow remove` on an older install planned removals for the 14 names
|
|
95
|
+
* the ledger owned while the codex verifier looked for the 5 the source now declares — and failed
|
|
96
|
+
* on the 4 that had never been installed here. `remove` became impossible for every install
|
|
97
|
+
* predating the consolidation, with no user recourse.
|
|
98
|
+
*
|
|
99
|
+
* You cannot fail to remove what you never installed, and the ledger is the record of what this
|
|
100
|
+
* machine actually owns. So a 'missing' the ledger has no claim on is an expected absence. A
|
|
101
|
+
* 'missing' the ledger *does* claim stays a hard failure — that is a removal which genuinely did
|
|
102
|
+
* not happen, and the existing "unrelated missing remains false" contract depends on it. Without a
|
|
103
|
+
* ledger, non-ownership cannot be proven, so the strict reading is kept. */
|
|
104
|
+
function markUnownedAbsences(statuses: any, ledger: any, harness: any) {
|
|
105
|
+
if (!ledger) return statuses;
|
|
106
|
+
const owned = new Set((ledger.resources || [])
|
|
107
|
+
.filter((resource: any) => resource.harness === harness)
|
|
108
|
+
.map((resource: any) => resource.ownershipIdentity)
|
|
109
|
+
.filter(Boolean));
|
|
110
|
+
return statuses.map((status: any) => (
|
|
111
|
+
status?.status === 'missing' && status.ownershipIdentity && !owned.has(status.ownershipIdentity)
|
|
112
|
+
? { ...status, status: 'absent', expectedAbsence: true }
|
|
113
|
+
: status
|
|
114
|
+
));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizeRemovalVerification(verification: any, changes: any, ledger: any) {
|
|
118
|
+
const removals = changes.filter((change: any) => change.operation === 'remove' && change.harness === verification.harness);
|
|
119
|
+
if (!removals.length) return verification;
|
|
120
|
+
// A retained removal released this harness's ownership row without deleting the file, because
|
|
121
|
+
// another harness still claims it (see markRetainedRemovals). The file is therefore *expected*
|
|
122
|
+
// to still be on disk, so asking "is it gone?" of it would turn the correct outcome into a
|
|
123
|
+
// verification conflict. Everything the plan actually deleted is still checked as strictly as
|
|
124
|
+
// before.
|
|
125
|
+
const removed = removals.filter((change: any) => !change.retained);
|
|
126
|
+
let statuses = [...verification.statuses];
|
|
127
|
+
const conflicts = [...(verification.conflicts || [])];
|
|
128
|
+
for (const change of removed) {
|
|
129
|
+
const outcome = reconcileRemovedChange(verification, statuses, change);
|
|
130
|
+
if (outcome.conflict) { conflicts.push(outcome.conflict); continue; }
|
|
131
|
+
if (outcome.statusIndex >= 0) statuses[outcome.statusIndex] = outcome.removedStatus;
|
|
132
|
+
else statuses.push(outcome.removedStatus);
|
|
133
|
+
}
|
|
134
|
+
// After reconciling the plan's own removals, so this only ever judges what the plan left over.
|
|
135
|
+
statuses = markUnownedAbsences(statuses, ledger, verification.harness);
|
|
136
|
+
return { ...verification, statuses, conflicts, ok: removalVerificationOk(verification.ok, statuses, conflicts) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** An adapter may initially report ok:false because a now-removed resource is missing. Once
|
|
140
|
+
* that exact absence has been normalized (an `expectedRemoval` status), recompute rather than
|
|
141
|
+
* carrying the stale false forward. A bare adapter failure, conflict, or any unrelated
|
|
142
|
+
* missing/error status remains a hard verification failure. */
|
|
143
|
+
function removalVerificationOk(ok: any, statuses: any, conflicts: any) {
|
|
144
|
+
const unresolvedFailure = statuses.some((status: any) => ['missing', 'conflict', 'error', 'invalid'].includes(status?.status));
|
|
145
|
+
const onlyExpectedRemovalFailure = ok === false && statuses.some((status: any) => status?.expectedRemoval || status?.expectedAbsence) && !unresolvedFailure && conflicts.length === 0;
|
|
146
|
+
return conflicts.length === 0 && !unresolvedFailure && (ok !== false || onlyExpectedRemovalFailure);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A `failures` entry comes in three shapes from different adapters: a [name, detail] tuple,
|
|
150
|
+
* a bare string, or an object with its own conflicts/errors. */
|
|
151
|
+
function failureReasons(failure: any) {
|
|
152
|
+
if (Array.isArray(failure)) {
|
|
153
|
+
const [name, detail] = failure;
|
|
154
|
+
const detailReasons = detail?.conflicts || detail?.errors || [];
|
|
155
|
+
return detailReasons.map((reason: any) => `${name}: ${reason}`);
|
|
156
|
+
}
|
|
157
|
+
if (typeof failure === 'string') return [failure];
|
|
158
|
+
if (failure?.conflicts || failure?.errors) return failure.conflicts || failure.errors;
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function componentReasons(name: any, component: any) {
|
|
163
|
+
if (component?.ok !== false) return [];
|
|
164
|
+
const details = component.conflicts || component.errors || [];
|
|
165
|
+
return details.length ? details.map((reason: any) => `${name}: ${reason}`) : [`${name}: native plan failed`];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Promote adapter-declared failed components into the common conflict channel.
|
|
169
|
+
* A false `ok` is never advisory: otherwise a native adapter can decline a
|
|
170
|
+
* dangerous reconciliation while the lifecycle still journals and writes state. */
|
|
171
|
+
function adapterConflicts(result: any, harness: any) {
|
|
172
|
+
const reasons = [
|
|
173
|
+
...(result.conflicts || []),
|
|
174
|
+
...(result.errors || []),
|
|
175
|
+
...(result.failures || []).flatMap(failureReasons),
|
|
176
|
+
...Object.entries(result.components || {}).flatMap(([name, component]) => componentReasons(name, component)),
|
|
177
|
+
];
|
|
178
|
+
if (result.ok === false && reasons.length === 0) reasons.push(`Adapter '${harness.id}' rejected its native plan`);
|
|
179
|
+
return [...new Set(reasons)];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Every ownership row this plan is about to give up, keyed exactly as the neutral ledger keys it.
|
|
183
|
+
* A removal change always releases its harness's claim; whether it also deletes the file is a
|
|
184
|
+
* separate question, answered by markRetainedRemovals below. */
|
|
185
|
+
function releasedOwnershipKeys(harnessPlans: any, scope: any) {
|
|
186
|
+
const keys = new Set();
|
|
187
|
+
for (const target of harnessPlans) {
|
|
188
|
+
for (const change of target.changes || []) {
|
|
189
|
+
if (change.operation !== 'remove') continue;
|
|
190
|
+
keys.add(ownershipKey({ harness: change.harness, scope, assetId: change.assetId, target: change.target, ownershipIdentity: change.ownershipIdentity }));
|
|
191
|
+
for (const key of change.supersedes ?? []) keys.add(key);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return keys;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Which harnesses still hold a ledger row for each destination once this plan's releases are
|
|
198
|
+
* applied. Membership is decided per ROW, not per harness: a harness that is releasing its claim
|
|
199
|
+
* on this file is not a claimant even if it is releasing nothing else, and a harness in the same
|
|
200
|
+
* batch that keeps its row (an install that relocates one asset while a sibling's projection is
|
|
201
|
+
* unchanged) still is. */
|
|
202
|
+
function survivingClaimantsByTarget(ledger: any, releasedKeys: any) {
|
|
203
|
+
const byTarget = new Map();
|
|
204
|
+
for (const resource of ledger?.resources || []) {
|
|
205
|
+
if (typeof resource?.target !== 'string' || typeof resource?.harness !== 'string') continue;
|
|
206
|
+
if (releasedKeys.has(ownershipKey(resource))) continue;
|
|
207
|
+
const key = claimKey(resource);
|
|
208
|
+
if (!byTarget.has(key)) byTarget.set(key, new Set());
|
|
209
|
+
byTarget.get(key).add(resource.harness);
|
|
210
|
+
}
|
|
211
|
+
return byTarget;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** What one claim covers: a whole file, except an MCP entry, which is claimed on its own because
|
|
215
|
+
* several harnesses keep their servers side by side in one MCP file. */
|
|
216
|
+
function claimKey(record: any) {
|
|
217
|
+
return record.kind === 'mcp-server' ? `${record.target}\u0000${record.identity}` : record.target;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** NFR-007: a removal reclaims only what no other harness still claims.
|
|
221
|
+
*
|
|
222
|
+
* Several assets project to ONE destination for several harnesses — `scripts.doflow` is a single
|
|
223
|
+
* `<project>/.doflow/scripts` tree for claude, codex and gemini, and gemini and copilot both
|
|
224
|
+
* resolve to `<root>/.agents` at project scope — while ownership is recorded per harness. A
|
|
225
|
+
* removal that deleted every file its own rows named therefore took the shared runtime out from
|
|
226
|
+
* under the harnesses that are still installed, leaving them with rows pointing at files that no
|
|
227
|
+
* longer exist and, when a global install happens to be present, a locator that silently answers
|
|
228
|
+
* from a different install's registries and state.
|
|
229
|
+
*
|
|
230
|
+
* So a removal change whose destination another surviving row still claims is *released* rather
|
|
231
|
+
* than executed: this harness's ownership row is dropped (it really is uninstalled), the file
|
|
232
|
+
* stays, and the last claimant's removal is the one that reclaims it. The neutral ledger is the
|
|
233
|
+
* only thing consulted — it is already the record of who owns what, and a second ownership
|
|
234
|
+
* registry would be one more thing to keep in sync. */
|
|
235
|
+
function markRetainedRemovals(harnessPlans: any, ledger: any, scope: any) {
|
|
236
|
+
const claimants = survivingClaimantsByTarget(ledger, releasedOwnershipKeys(harnessPlans, scope));
|
|
237
|
+
if (!claimants.size) return harnessPlans;
|
|
238
|
+
return harnessPlans.map((target: any) => {
|
|
239
|
+
if (target.skipped) return target;
|
|
240
|
+
let retainedAny = false;
|
|
241
|
+
const changes = target.changes.map((change: any) => {
|
|
242
|
+
if (change.operation !== 'remove') return change;
|
|
243
|
+
const retainedFor = [...(claimants.get(claimKey(change)) ?? [])].filter((harness) => harness !== change.harness).sort();
|
|
244
|
+
if (!retainedFor.length) return change;
|
|
245
|
+
retainedAny = true;
|
|
246
|
+
return Object.freeze({ ...change, retained: true, retainedFor });
|
|
247
|
+
});
|
|
248
|
+
if (!retainedAny) return target;
|
|
249
|
+
// requiredNativeResources defaults to the very array just replaced; keep the two in step so a
|
|
250
|
+
// caller reading either one sees the same annotated changes.
|
|
251
|
+
const requiredNativeResources = target.requiredNativeResources === target.changes ? changes : target.requiredNativeResources;
|
|
252
|
+
return { ...target, changes, requiredNativeResources };
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Whether this plan leaves no ownership row standing anywhere — the only case in which a shared,
|
|
257
|
+
* install-wide artifact that no single harness owns is genuinely unclaimed. */
|
|
258
|
+
function removalIsTotal(plan: any) {
|
|
259
|
+
const released = releasedOwnershipKeys(plan.targets || [], plan.scope);
|
|
260
|
+
return (plan.ledger?.resources || []).every((resource: any) => released.has(ownershipKey(resource)));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** What a removal kept and why, ready to print. A command that says "removed" while silently
|
|
264
|
+
* leaving files behind is answering confidently about something it did not do, so the retained
|
|
265
|
+
* count and the harnesses responsible for it are reported, never inferred by the caller. */
|
|
266
|
+
function retentionSummary(retained: any = []) {
|
|
267
|
+
const groups = new Map();
|
|
268
|
+
for (const item of retained) {
|
|
269
|
+
// No claimant: a release that is reported on its own line (a kept hand edit) or is silent
|
|
270
|
+
// bookkeeping (a row whose file is gone), never "still claimed by" nobody.
|
|
271
|
+
if (!item.retainedFor?.length) continue;
|
|
272
|
+
const key = `${item.harness} | ${item.retainedFor.join(', ')}`;
|
|
273
|
+
groups.set(key, (groups.get(key) ?? 0) + 1);
|
|
274
|
+
}
|
|
275
|
+
return [...groups.entries()].map(([key, count]) => {
|
|
276
|
+
const [harness, claimants] = key.split(' | ');
|
|
277
|
+
return `${harness}: retained ${count} shared resource(s) still claimed by ${claimants}`;
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function retainedChanges(changes: any) {
|
|
282
|
+
return changes.filter((change: any) => change.retained)
|
|
283
|
+
.map((change: any) => ({ harness: change.harness, assetId: change.assetId, target: change.target, retainedFor: change.retainedFor }));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** An adapter's non-blocking notes: one line of at most 200 characters each. A malformed entry is
|
|
287
|
+
* dropped rather than failing the plan, because a notice must never change whether an install runs. */
|
|
288
|
+
function adapterNotices(result: any) {
|
|
289
|
+
return (Array.isArray(result.notices) ? result.notices : [])
|
|
290
|
+
.filter((notice: any) => typeof notice === 'string' && notice.length <= 200 && !/[\u0000-\u001f\u007f]/.test(notice));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The ids of `ids` the catalog declares, in registry order. */
|
|
294
|
+
function catalogIds(registry: any, ids: any) {
|
|
295
|
+
return registry.mcp.filter((server: any) => ids.includes(server.id)).map((server: any) => server.id);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Each harness takes its own MCP selection: an id array, or MCP_KEEP for the servers it owns now.
|
|
299
|
+
* A harness that takes no MCP servers reads `keep` and an absent entry as none and refuses any id. */
|
|
300
|
+
function harnessMcpSelection(registry: any, harness: any, raw: any) {
|
|
301
|
+
const value = raw ?? [];
|
|
302
|
+
if (value !== MCP_KEEP && !(Array.isArray(value) && value.every((id) => typeof id === 'string'))) {
|
|
303
|
+
throw new Error(`MCP selection for '${harness.id}' must be an array of server ids or '${MCP_KEEP}'`);
|
|
304
|
+
}
|
|
305
|
+
if (mcpCapable(registry, harness.id)) return value;
|
|
306
|
+
if (Array.isArray(value) && value.length) throw new Error(`Harness '${harness.id}' takes no MCP servers`);
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The runtime trees: each one source directory projected to one `<scopeRoot>/.doflow/runtime/...`
|
|
311
|
+
* tree for every harness, with no layout and no transform (test/guards/runtime-tree.test.js pins
|
|
312
|
+
* that). Nothing but the shared scripts reaches into them, so a file the current source no longer
|
|
313
|
+
* ships is dead for every harness at once. Guidance and scripts are not here: a harness this run
|
|
314
|
+
* does not update names files in those trees from its own native files, so NFR-007 retention is
|
|
315
|
+
* right for them. */
|
|
316
|
+
const RUNTIME_TREE_ASSETS = ['runtime.cli', 'runtime.lib', 'runtime.registry'];
|
|
317
|
+
|
|
318
|
+
function sha256File(file: string, fsImpl: any): string {
|
|
319
|
+
return crypto.createHash('sha256').update(fsImpl.readFileSync(file)).digest('hex');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function isUnder(file: string, root: string): boolean {
|
|
323
|
+
return file.startsWith(`${root}${path.sep}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Replace some of a harness plan's changes and append others, keeping `requiredNativeResources`
|
|
327
|
+
* in step: the same array when it is one (as markRetainedRemovals does), else with the added
|
|
328
|
+
* changes listed, so a plan holding only those is still applicable (codex lists its own). */
|
|
329
|
+
function withChanges(target: any, replaced: Map<any, any>, added: any[], notices: string[]) {
|
|
330
|
+
if (!replaced.size && !added.length && !notices.length) return target;
|
|
331
|
+
const changes = [...target.changes.map((change: any) => replaced.get(change) ?? change), ...added];
|
|
332
|
+
const requiredNativeResources = target.requiredNativeResources === target.changes ? changes
|
|
333
|
+
: [...target.requiredNativeResources, ...added.map((change: any) => ({ harness: change.harness, component: 'runtimePrune', target: change.target, operation: change.operation, identity: change.identity }))];
|
|
334
|
+
return { ...target, changes, requiredNativeResources, notices: [...target.notices, ...notices] };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Cross-version orphan prune (073 T4), run on install and update, never on remove.
|
|
339
|
+
*
|
|
340
|
+
* A file under a runtime tree that the current source no longer produces is deleted when its bytes
|
|
341
|
+
* equal a fingerprint some DoFlow ledger row recorded at that exact path. That is DoFlow's own
|
|
342
|
+
* write, unmodified. Without this pass, such a file outlives any update that leaves out one of the
|
|
343
|
+
* harnesses holding a row on it, because markRetainedRemovals keeps a removal another harness still
|
|
344
|
+
* claims, and a bare `update` targets claude alone. The pass:
|
|
345
|
+
* - un-retains the planned removals of that file, or, when no planned harness has a row on it,
|
|
346
|
+
* adds one for the first planned harness (`kind: 'copy-tree-file'` and `projection.renderer:
|
|
347
|
+
* 'copy-tree'`, the two fields every adapter's removal filter reads);
|
|
348
|
+
* - names the other harnesses' rows in `supersedes`, which updateLedger and releasedOwnershipKeys
|
|
349
|
+
* drop together with the removal;
|
|
350
|
+
* - releases rows whose file is already gone, since the source no longer produces it either;
|
|
351
|
+
* - keeps, and names, a changed file, an unrecorded one, one whose real path leaves the tree, and
|
|
352
|
+
* one that is the same file as a path the source produces (a case-only rename).
|
|
353
|
+
* removeTree re-hashes every file just before deleting it, as for any removal, and for the removals
|
|
354
|
+
* this pass plans (`pruneGuard`) re-runs the containment and same-file checks there too.
|
|
355
|
+
*/
|
|
356
|
+
function pruneRetiredRuntimeFiles(harnessPlans: any[], ledger: any, { scopeRoot, repoRoot, fsImpl = fs }: any) {
|
|
357
|
+
const planned = harnessPlans.filter((target: any) => !target.skipped);
|
|
358
|
+
if (!planned.length || !repoRoot) return harnessPlans;
|
|
359
|
+
const replaced = new Map<string, Map<any, any>>(planned.map((target: any) => [target.harness, new Map()]));
|
|
360
|
+
const added = new Map<string, any[]>(planned.map((target: any) => [target.harness, []]));
|
|
361
|
+
const notices = new Map<string, string[]>(planned.map((target: any) => [target.harness, []]));
|
|
362
|
+
const rows = (ledger?.resources ?? []).filter((row: any) => typeof row?.target === 'string');
|
|
363
|
+
const recorded = new Set(rows.map((row: any) => row.target));
|
|
364
|
+
const shown = (file: string) => path.relative(scopeRoot, file);
|
|
365
|
+
|
|
366
|
+
for (const assetId of RUNTIME_TREE_ASSETS) {
|
|
367
|
+
const executor = planned.find((target: any) => target.assets.some((asset: any) => asset.id === assetId));
|
|
368
|
+
if (!executor) continue;
|
|
369
|
+
const asset = executor.assets.find((item: any) => item.id === assetId);
|
|
370
|
+
const nativeDir = typeof asset.nativeDir === 'string' ? asset.nativeDir : asset.nativeDir?.[executor.harness];
|
|
371
|
+
const destDir = sharedTreeDestDir(scopeRoot, nativeDir);
|
|
372
|
+
const sourceDir = path.resolve(repoRoot, asset.source);
|
|
373
|
+
if (!destDir || !fsImpl.existsSync(sourceDir) || !fsImpl.existsSync(destDir)) continue;
|
|
374
|
+
const produced = discoverTree({ sourceDir, destDir, fsImpl }).files.map((file: any) => file.destAbs);
|
|
375
|
+
const producedSet = new Set(produced);
|
|
376
|
+
const sameFile = sameFileIndex(produced, fsImpl);
|
|
377
|
+
const realRoot = fsImpl.realpathSync(destDir);
|
|
378
|
+
|
|
379
|
+
const rowsAt = new Map<string, any[]>();
|
|
380
|
+
for (const row of rows) {
|
|
381
|
+
if (!isUnder(row.target, destDir) || producedSet.has(row.target)) continue;
|
|
382
|
+
if (!rowsAt.has(row.target)) rowsAt.set(row.target, []);
|
|
383
|
+
rowsAt.get(row.target)!.push(row);
|
|
384
|
+
}
|
|
385
|
+
const removalsAt = new Map<string, any[]>();
|
|
386
|
+
for (const target of planned) {
|
|
387
|
+
for (const change of target.changes) {
|
|
388
|
+
if (change.operation !== 'remove' || change.assetId !== assetId || !isUnder(change.target, destDir) || producedSet.has(change.target)) continue;
|
|
389
|
+
if (!removalsAt.has(change.target)) removalsAt.set(change.target, []);
|
|
390
|
+
removalsAt.get(change.target)!.push(change);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/** Turn every planned removal of `file` into a release that deletes nothing. */
|
|
394
|
+
const keepFile = (file: string) => {
|
|
395
|
+
for (const change of removalsAt.get(file) ?? []) {
|
|
396
|
+
if (!change.retained) replaced.get(change.harness)!.set(change, Object.freeze({ ...change, retained: true, retainedFor: [] }));
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
for (const file of new Set([...rowsAt.keys(), ...removalsAt.keys()])) {
|
|
401
|
+
const fileRows = rowsAt.get(file) ?? [];
|
|
402
|
+
// Another asset's live row on this path is a claim this pass does not judge.
|
|
403
|
+
if (fileRows.some((row: any) => row.assetId !== assetId)) continue;
|
|
404
|
+
const removals = removalsAt.get(file) ?? [];
|
|
405
|
+
const identity = path.relative(destDir, file);
|
|
406
|
+
// The rows no planned removal of this file releases on its own.
|
|
407
|
+
const superseded = fileRows.filter((row: any) => !removals.some((change: any) => change.harness === row.harness)).map((row: any) => ownershipKey(row));
|
|
408
|
+
const synthetic = (extra: any) => Object.freeze({
|
|
409
|
+
harness: executor.harness, assetId, target: file, operation: 'remove', kind: 'copy-tree-file', identity,
|
|
410
|
+
ownershipIdentity: `doflow:${executor.harness}:copy-tree:${assetId}:${identity}`, projection: { renderer: 'copy-tree' }, ...extra,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
if (!fsImpl.existsSync(file)) {
|
|
414
|
+
// Already gone (an interrupted run, a user's delete): the rows only point at nothing.
|
|
415
|
+
if (fileRows.length) added.get(executor.harness)!.push(synthetic({ fingerprint: null, retained: true, retainedFor: [], supersedes: superseded }));
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const real = fsImpl.realpathSync(file);
|
|
419
|
+
if (!isUnder(real, realRoot)) {
|
|
420
|
+
keepFile(file);
|
|
421
|
+
notices.get(executor.harness)!.push(`kept ${shown(file)}: no longer shipped, but it resolves outside ${shown(destDir)}`);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (sameFile(file)) { keepFile(file); continue; } // the produced file under an old spelling
|
|
425
|
+
const current = sha256File(file, fsImpl);
|
|
426
|
+
if (!fileRows.some((row: any) => row.fingerprint === current)) {
|
|
427
|
+
// A planned removal here was already kept by planTree, and its adapter named it.
|
|
428
|
+
if (!removals.length) notices.get(executor.harness)!.push(`kept ${shown(file)}: no longer shipped, changed since DoFlow wrote it`);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
notices.get(executor.harness)!.push(`prune ${shown(file)}: no longer shipped, unchanged since DoFlow wrote it`);
|
|
432
|
+
// removeTree re-runs containment and the same-file check from this right before the unlink.
|
|
433
|
+
const pruneGuard = { root: destDir, source: sourceDir };
|
|
434
|
+
if (!removals.length) {
|
|
435
|
+
added.get(executor.harness)!.push(synthetic({ fingerprint: current, supersedes: superseded, pruneGuard }));
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
removals.forEach((change: any, index: number) => {
|
|
439
|
+
const { retained, retainedFor, ...rest } = change;
|
|
440
|
+
void retained; void retainedFor;
|
|
441
|
+
replaced.get(change.harness)!.set(change, Object.freeze({ ...rest, fingerprint: current, pruneGuard, ...(index === 0 ? { supersedes: superseded } : {}) }));
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Files no row names and the source does not produce: never DoFlow's to delete, but named.
|
|
446
|
+
for (const rel of walkRelFiles(destDir, fsImpl)) {
|
|
447
|
+
const file = path.join(destDir, rel);
|
|
448
|
+
if (producedSet.has(file) || recorded.has(file) || sameFile(file)) continue;
|
|
449
|
+
notices.get(executor.harness)!.push(`kept ${shown(file)}: not shipped by this version and not recorded as DoFlow's`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return harnessPlans.map((target: any) => (target.skipped ? target
|
|
453
|
+
: withChanges(target, replaced.get(target.harness)!, added.get(target.harness)!, notices.get(target.harness)!)));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function planLifecycle({ registry, adapters, scope, scopeRoot, targets, mcpSelections = {}, mcpAdoptable = {}, retainedMcpIds = [], ledger, context = {} }: any) {
|
|
457
|
+
assertScope(scope);
|
|
458
|
+
if (!registry) throw new Error('registry is required');
|
|
459
|
+
const baseLedger = ledger ?? defaultLedger({ scope, scopeRoot });
|
|
460
|
+
const harnessPlans = normalizeTargets(registry, targets).map((harness) => {
|
|
461
|
+
if (!harness.scopes.includes(registryScope(scope))) {
|
|
462
|
+
return { harness: harness.id, assets: [], changes: [], conflicts: [`Harness '${harness.id}' does not support ${scope} scope`], prerequisites: [], notices: [], skipped: true, mcpSelected: null };
|
|
463
|
+
}
|
|
464
|
+
const capable = mcpCapable(registry, harness.id);
|
|
465
|
+
const selection = harnessMcpSelection(registry, harness, mcpSelections[harness.id]);
|
|
466
|
+
const assets = selectAssets(registry, { harness: harness.id });
|
|
467
|
+
const policies = renderPolicies(registry, { harness: harness.id });
|
|
468
|
+
const adoptable = capable ? selectMcpServers(registry, catalogIds(registry, mcpAdoptable[harness.id] ?? [])) : [];
|
|
469
|
+
const adapter = resolveAdapter(adapters, harness);
|
|
470
|
+
const project = (mcp: any) => projectAdapterInput({ registry, harness, scope, scopeRoot, assets, mcp, mcpAdoptable: adoptable, policies, context });
|
|
471
|
+
// `keep` asks the adapter what it owns before the real projection: the probe carries no
|
|
472
|
+
// selection, so discovery reads the files as they are.
|
|
473
|
+
const ids = selection === MCP_KEEP
|
|
474
|
+
? catalogIds(registry, adapter.discover({ ...project([]), registry, ledger: baseLedger }).mcpOwned ?? [])
|
|
475
|
+
: selection;
|
|
476
|
+
const mcp = selectMcpServers(registry, ids);
|
|
477
|
+
const adapterInput = project(mcp);
|
|
478
|
+
const input = { ...adapterInput, registry, ledger: baseLedger };
|
|
479
|
+
const discovery = adapter.discover(input);
|
|
480
|
+
const result = adapter.plan({ ...input, discovery });
|
|
481
|
+
if (!result || typeof result !== 'object') throw new Error(`Adapter '${harness.id}' returned an invalid plan`);
|
|
482
|
+
const changes = (result.changes || []).map((change: any) => normalizeChange(change, { harness, assets }));
|
|
483
|
+
const conflicts = adapterConflicts(result, harness);
|
|
484
|
+
const prerequisites = [...(result.prerequisites || []), ...changes.map((change: any) => change.prerequisite).filter(Boolean)];
|
|
485
|
+
const requiredNativeResources = result.requiredNativeResources ?? changes;
|
|
486
|
+
if (!Array.isArray(requiredNativeResources)) throw new Error(`Adapter '${harness.id}' returned invalid requiredNativeResources`);
|
|
487
|
+
return { harness: harness.id, adapter: harness.adapter, assets, mcp, mcpSelected: capable ? mcp.map((server: any) => server.id) : null, policies, adapterInput, discovery, changes, requiredNativeResources, conflicts, prerequisites, notices: adapterNotices(result), skipped: false };
|
|
488
|
+
});
|
|
489
|
+
// The MCP index names every server a harness of this scope uses: the selections planned here and
|
|
490
|
+
// the recorded selections of the harnesses this run does not touch.
|
|
491
|
+
const selectedMcp = selectMcpServers(registry, catalogIds(registry,
|
|
492
|
+
[...retainedMcpIds, ...harnessPlans.flatMap((item) => (item.skipped ? [] : item.mcpSelected ?? []))]));
|
|
493
|
+
// Only once every harness's plan is known: whether a file may be deleted depends on the rows
|
|
494
|
+
// the WHOLE plan leaves standing, which no single harness's plan can see.
|
|
495
|
+
const retained = markRetainedRemovals(harnessPlans, baseLedger, scope);
|
|
496
|
+
const annotated = context.operation === 'remove' ? retained
|
|
497
|
+
: pruneRetiredRuntimeFiles(retained, baseLedger, { scopeRoot, repoRoot: context.repoRoot });
|
|
498
|
+
const changes = annotated.flatMap((item: any) => item.changes);
|
|
499
|
+
const conflicts = annotated.flatMap((item: any) => item.conflicts.map((reason: any) => ({ harness: item.harness, reason })));
|
|
500
|
+
const prerequisites = annotated.flatMap((item: any) => item.prerequisites.map((prerequisite: any) => ({ harness: item.harness, prerequisite })));
|
|
501
|
+
const requiredNativeResources = annotated.flatMap((item: any) => item.requiredNativeResources || []);
|
|
502
|
+
const notices = annotated.flatMap((item: any) => item.notices.map((notice: any) => ({ harness: item.harness, notice })));
|
|
503
|
+
return Object.freeze({ scope, scopeRoot, mcp: selectedMcp, ledger: baseLedger, targets: annotated, changes, requiredNativeResources, conflicts, prerequisites, notices,
|
|
504
|
+
retained: retainedChanges(changes), safe: conflicts.length === 0 && prerequisites.length === 0 });
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function verificationResources(verification: any, changes: any, scope: any, recoveryRef: any) {
|
|
508
|
+
if (verification.resources.length) return verification.resources.map((resource: any) => ({ ...resource, harness: verification.harness, scope, recoveryRef: resource.recoveryRef ?? recoveryRef }));
|
|
509
|
+
return changes.filter((change: any) => change.operation !== 'remove').map((change: any) => ({
|
|
510
|
+
harness: change.harness, scope, assetId: change.assetId, target: change.target,
|
|
511
|
+
ownershipIdentity: change.ownershipIdentity, fingerprint: change.afterFingerprint ?? null,
|
|
512
|
+
sourceVersion: change.sourceVersion ?? 'unknown', projection: change.projection ?? null, recoveryRef,
|
|
513
|
+
}));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** The guidance-content layer's own version marker (core/shared/guidance/VERSION, copy-treed
|
|
517
|
+
* to <scopeRoot>/.doflow/guidance/VERSION) is independent of this package's version — read it
|
|
518
|
+
* fresh on every ledger update so the ledger reflects whatever is actually on disk. */
|
|
519
|
+
function readGuidanceVersion(scopeRoot: any, fsImpl = fs) {
|
|
520
|
+
const file = path.join(scopeRoot, '.doflow', 'guidance', 'VERSION');
|
|
521
|
+
if (!fsImpl.existsSync(file)) return null;
|
|
522
|
+
return fsImpl.readFileSync(file, 'utf8').trim();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Path to the per-install MCP short-flag index. Generated fresh by applyLifecycle on every
|
|
526
|
+
* apply, rather than copy-treed from core/shared/guidance/ — the one guidance-tree file whose
|
|
527
|
+
* content varies by install, so it stays isolated from copy-tree's byte-mirror/fingerprint
|
|
528
|
+
* contract for the rest of that tree.
|
|
529
|
+
*
|
|
530
|
+
* It sits at the guidance ROOT, next to DOFLOW_CORE.md. That is load-bearing: the `doc` paths it
|
|
531
|
+
* emits come from core/registry/mcp.json as `mcp/MCP_*.md`, anchored at the guidance root — the
|
|
532
|
+
* same anchor DOFLOW_CORE.md's own @-imports use. Writing this file into a subdirectory would
|
|
533
|
+
* silently reinterpret every one of those paths against that subdirectory and break them all,
|
|
534
|
+
* with no error at install time. test/mcp-index.test.js pins the anchor from both ends. */
|
|
535
|
+
function mcpIndexPath(scopeRoot: any) {
|
|
536
|
+
return path.join(scopeRoot, '.doflow', 'guidance', 'MCP_INDEX.md');
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** apply + non-empty selection -> write; apply + empty selection -> delete if present (an agent
|
|
540
|
+
* must never see a stale entry for a server that's no longer selected); remove -> delete, unless
|
|
541
|
+
* `retain` says another harness is still installed.
|
|
542
|
+
*
|
|
543
|
+
* `retain` exists because "remove means the whole install is going away" is only true of a removal
|
|
544
|
+
* that names every harness. This file sits in the shared guidance tree and is @-imported by
|
|
545
|
+
* DOFLOW_CORE.md, so deleting it on a single-harness removal strips a line of always-loaded
|
|
546
|
+
* context out from under every harness that is staying — the same NFR-007 mistake as deleting the
|
|
547
|
+
* shared runtime, in the one asset that is generated rather than ledger-tracked and so cannot be
|
|
548
|
+
* protected by ownership counting. The remaining harnesses' MCP selection has not changed, so the
|
|
549
|
+
* file is left exactly as it is rather than re-rendered from this removal's empty selection. */
|
|
550
|
+
function applyMcpIndex({ scopeRoot, selectedMcp, mode, retain = false, fsImpl = fs }: any) {
|
|
551
|
+
const file = mcpIndexPath(scopeRoot);
|
|
552
|
+
const content = mode === 'remove' ? null : renderMcpIndex(selectedMcp);
|
|
553
|
+
if (content === null) {
|
|
554
|
+
if (mode === 'remove' && retain) return;
|
|
555
|
+
if (fsImpl.existsSync(file)) fsImpl.unlinkSync(file);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
fsImpl.mkdirSync(path.dirname(file), { recursive: true });
|
|
559
|
+
fsImpl.writeFileSync(file, content);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function updateLedger({ ledger, scope, scopeRoot, verifications, changes, recoveryRef, fsImpl = fs }: any) {
|
|
563
|
+
// upgradeLedger makes every written ledger v2-shaped (tombstone log present) while v1 inputs
|
|
564
|
+
// stay readable — migration happens on write, never as a separate user-facing step.
|
|
565
|
+
const next = JSON.parse(JSON.stringify(upgradeLedger(ledger ?? defaultLedger({ scope, scopeRoot }))));
|
|
566
|
+
if (!Array.isArray(next.tombstones)) next.tombstones = [];
|
|
567
|
+
// Snapshot pre-run claims by ownership identity (harness+asset+identity) so a re-verification
|
|
568
|
+
// that relocates the same claim to a new target is detectable as a MOVE, not remove-plus-add.
|
|
569
|
+
const priorByIdentity = new Map();
|
|
570
|
+
for (const resource of next.resources) {
|
|
571
|
+
priorByIdentity.set(`${resource.harness}\u0000${resource.assetId}\u0000${resource.identity ?? ''}`, resource);
|
|
572
|
+
}
|
|
573
|
+
for (const verification of verifications) {
|
|
574
|
+
const related = changes.filter((change: any) => change.harness === verification.harness);
|
|
575
|
+
const removed = related.filter((change: any) => change.operation === 'remove');
|
|
576
|
+
const removeKeys = new Set(removed.map((change: any) => ownershipKey({ harness: change.harness, scope, assetId: change.assetId, target: change.target, ownershipIdentity: change.ownershipIdentity })));
|
|
577
|
+
// A runtime prune also gives up the rows other harnesses hold on the file it deletes
|
|
578
|
+
// (pruneRetiredRuntimeFiles): released here, in the same write, so no row outlives its file.
|
|
579
|
+
for (const change of removed) for (const key of change.supersedes ?? []) removeKeys.add(key);
|
|
580
|
+
next.resources = next.resources.filter((resource: any) => !removeKeys.has(ownershipKey(resource)));
|
|
581
|
+
for (const resource of verificationResources(verification, related, scope, recoveryRef)) {
|
|
582
|
+
const key = ownershipKey(resource);
|
|
583
|
+
// A verifier enumerates what is on disk, and a released-but-retained file is deliberately
|
|
584
|
+
// still there (markRetainedRemovals). Re-adding it would resurrect the exact claim this run
|
|
585
|
+
// gave up, so a key this run removed is never written back by the same run.
|
|
586
|
+
if (removeKeys.has(key)) continue;
|
|
587
|
+
// A claim re-verified at a NEW target supersedes its old-location row (same harness+asset+
|
|
588
|
+
// identity, different path). Without this the moved-from row would survive the merge as a
|
|
589
|
+
// live claim, keeping the stale bytes owned and blocking the tombstone sweep below.
|
|
590
|
+
const prior = priorByIdentity.get(`${resource.harness}\u0000${resource.assetId}\u0000${resource.identity ?? ''}`);
|
|
591
|
+
if (prior && prior.target !== resource.target) {
|
|
592
|
+
const supersededKey = ownershipKey(prior);
|
|
593
|
+
next.resources = next.resources.filter((item: any) => ownershipKey(item) !== supersededKey);
|
|
594
|
+
}
|
|
595
|
+
const index = next.resources.findIndex((item: any) => ownershipKey(item) === key);
|
|
596
|
+
if (index >= 0) next.resources[index] = resource;
|
|
597
|
+
else next.resources.push(resource);
|
|
598
|
+
}
|
|
599
|
+
next.targets[verification.harness] = { installed: true, lastUpdated: new Date().toISOString() };
|
|
600
|
+
}
|
|
601
|
+
// Tombstones (ledger v2): a claim that moved leaves its old bytes behind at the old target.
|
|
602
|
+
// Record the relocation, then sweep the stale copy — but only when its bytes still hash to the
|
|
603
|
+
// fingerprint DoFlow last verified there. A mismatch means someone edited that file after us:
|
|
604
|
+
// their content outranks our tidiness, so the file stays and the unswept tombstone says why.
|
|
605
|
+
for (const resource of next.resources) {
|
|
606
|
+
const prior = priorByIdentity.get(`${resource.harness}\u0000${resource.assetId}\u0000${resource.identity ?? ''}`);
|
|
607
|
+
if (!prior || prior.target === resource.target) continue;
|
|
608
|
+
if (!next.tombstones.some((entry: any) => entry.harness === resource.harness
|
|
609
|
+
&& entry.fromTarget === prior.target && entry.toTarget === resource.target)) {
|
|
610
|
+
next.tombstones.push({
|
|
611
|
+
harness: resource.harness, assetId: resource.assetId,
|
|
612
|
+
fromTarget: prior.target, toTarget: resource.target,
|
|
613
|
+
fingerprint: prior.fingerprint ?? null, movedAt: new Date().toISOString(),
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const claims = new Set(next.resources.map((resource: any) => resource.target));
|
|
618
|
+
for (const entry of next.tombstones) {
|
|
619
|
+
if (entry.sweptAt || !fsImpl.existsSync(entry.fromTarget) || claims.has(entry.fromTarget)) continue;
|
|
620
|
+
try {
|
|
621
|
+
const current = crypto.createHash('sha256').update(fsImpl.readFileSync(entry.fromTarget)).digest('hex');
|
|
622
|
+
if (entry.fingerprint && current === String(entry.fingerprint).replace(/^sha256:/, '')) {
|
|
623
|
+
fsImpl.rmSync(entry.fromTarget);
|
|
624
|
+
entry.sweptAt = new Date().toISOString();
|
|
625
|
+
pruneEmptyAncestors(path.dirname(entry.fromTarget), { fsImpl });
|
|
626
|
+
}
|
|
627
|
+
} catch { /* unreadable or undeletable: leave it; the tombstone stays unswept and visible */ }
|
|
628
|
+
}
|
|
629
|
+
next.lastRecoveryId = recoveryRef;
|
|
630
|
+
const guidanceVersion = readGuidanceVersion(scopeRoot, fsImpl);
|
|
631
|
+
if (guidanceVersion !== null) next.guidanceVersion = guidanceVersion;
|
|
632
|
+
return next;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function verifyLifecycle({ plan, adapters, context = {} }: any) {
|
|
636
|
+
const verifications = [];
|
|
637
|
+
for (const target of plan.targets) {
|
|
638
|
+
if (target.skipped) continue;
|
|
639
|
+
const harness = harnessFor(context.registry ?? plan.registry, target.harness);
|
|
640
|
+
const adapter = resolveAdapter(adapters, harness);
|
|
641
|
+
const operation = context.operation ?? 'verify';
|
|
642
|
+
// After an apply, the files this harness just wrote: a copy-tree verifier reports one that is
|
|
643
|
+
// missing instead of skipping it like a file the user deleted.
|
|
644
|
+
const plannedWrites = context.checkPlannedWrites
|
|
645
|
+
? target.changes.filter((change: any) => !change.retained && (change.operation === 'create' || change.operation === 'update')).map((change: any) => change.target)
|
|
646
|
+
: undefined;
|
|
647
|
+
let verification = normalizeVerification(adapter.verify({ ...target.adapterInput,
|
|
648
|
+
registry: context.registry ?? plan.registry, discovery: target.discovery, ledger: plan.ledger,
|
|
649
|
+
operation, context: { ...target.adapterInput.context, operation, ...(plannedWrites ? { plannedWrites } : {}) } }), harness);
|
|
650
|
+
if (operation === 'remove') verification = normalizeRemovalVerification(verification, target.changes, plan.ledger);
|
|
651
|
+
verifications.push({ ...verification, hookWiring: hookWiringStatus(harness, target, verification) });
|
|
652
|
+
}
|
|
653
|
+
const conflicts = verifications.flatMap((result) => (result.conflicts || []).map((reason: any) => ({ harness: result.harness, reason })));
|
|
654
|
+
return { verifications, conflicts, ok: conflicts.length === 0 && verifications.every((result) => result.ok !== false) };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function assertPlanApplicable(plan: any, stateRoot: any, acceptPrerequisites: any) {
|
|
658
|
+
if (!plan.safe && !(acceptPrerequisites && plan.conflicts.length === 0)) throw new Error('Refusing to apply an unsafe lifecycle plan');
|
|
659
|
+
if (!stateRoot) throw new Error('stateRoot is required before lifecycle mutation');
|
|
660
|
+
if (!plan.requiredNativeResources?.length || !plan.changes.length) throw new Error('Refusing to apply a lifecycle plan with no required native resources');
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Registry-declared hooks assets (`kind: "hooks"` in core/registry/assets.json) this harness's
|
|
664
|
+
* plan selected — currently only claude.hooks-scripts and kiro.hooks-scripts. Codex and Gemini
|
|
665
|
+
* deploy hooks via their own bespoke code paths (see targetNeedsHooks below) rather than a
|
|
666
|
+
* registered asset, so they never appear here. */
|
|
667
|
+
function hookAssetIds(target: any) {
|
|
668
|
+
return new Set((target.assets || []).filter((asset: any) => asset.kind === 'hooks').map((asset: any) => asset.id));
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Whether this harness's plan includes at least one hooks-bearing change. Claude's hooks are a
|
|
672
|
+
* registry-declared asset (`kind: "hooks"` in core/registry/assets.json); Codex and Gemini deploy
|
|
673
|
+
* hooks via their own bespoke code paths and tag the resulting change with `nativeComponent:
|
|
674
|
+
* 'hooks'` instead (see src/adapters/codex/index.ts and src/adapters/gemini/index.ts). Either
|
|
675
|
+
* signal is sufficient — this stays harness-agnostic on purpose. */
|
|
676
|
+
function targetNeedsHooks(target: any) {
|
|
677
|
+
const hookIds = hookAssetIds(target);
|
|
678
|
+
return (target.changes || []).some((change: any) => hookIds.has(change.assetId) || change.nativeComponent === 'hooks');
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Whether a verified resource is the hooks resource this harness owns. A registry-declared hooks
|
|
682
|
+
* asset (Claude, Kiro) is matched by assetId; Codex's bespoke hooks-file resource is matched by
|
|
683
|
+
* `kind`; Gemini's bespoke hooks resource (a key inside settings.json, not a standalone file) is
|
|
684
|
+
* matched by its `projection.renderer` tag — see src/adapters/codex/index.ts's
|
|
685
|
+
* `lifecycleResource(..., kind: 'hooks-file', ...)` and src/adapters/gemini/index.ts's verify(),
|
|
686
|
+
* which tags its hooks resource `projection: { renderer: 'gemini-hooks' }`. */
|
|
687
|
+
function verificationOwnsHooks(verification: any, hookIds: any) {
|
|
688
|
+
return (verification?.resources || []).some((resource: any) =>
|
|
689
|
+
hookIds.has(resource.assetId) || resource.kind === 'hooks-file' || resource.projection?.renderer === 'gemini-hooks');
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Gemini's hooks trust, observed through the adapter's own API. How trust is computed — the
|
|
693
|
+
* fingerprint semantics, which context fields matter — lives in src/adapters/gemini (review A3:
|
|
694
|
+
* this file used to import planGeminiHooks and re-derive the plan itself). */
|
|
695
|
+
function geminiHookTrust(target: any) {
|
|
696
|
+
return geminiHookTrustFor(target);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** General, per-harness hook-wiring status — the one place install status distinguishes an
|
|
700
|
+
* installed-but-not-yet-active hooks resource from one already live, replacing the ad-hoc,
|
|
701
|
+
* Codex-only hardcode bin/doflow.js's `cmdStatus` used to carry. Three outcomes:
|
|
702
|
+
* - 'absent': this harness owns no hooks resource in the current verification (nothing installed).
|
|
703
|
+
* - 'installed-pending': a hooks resource is installed, but a prerequisite is unmet — Codex's
|
|
704
|
+
* static `capabilities.hooks.prerequisites` (`trusted-project`, `hook-review`), which DoFlow has
|
|
705
|
+
* no way to confirm a human satisfied, so it is reported unmet unconditionally; or Gemini's live
|
|
706
|
+
* trust computation via geminiHookTrust above.
|
|
707
|
+
* - 'active': installed with no unmet prerequisite (Claude and Kiro today, since neither declares
|
|
708
|
+
* any `capabilities.hooks.prerequisites` and Kiro hooks activate automatically per kiro.dev). */
|
|
709
|
+
function hookWiringStatus(harness: any, target: any, verification: any) {
|
|
710
|
+
if (!target || target.skipped) return { status: 'absent', prerequisites: [] };
|
|
711
|
+
if (!verificationOwnsHooks(verification, hookAssetIds(target))) return { status: 'absent', prerequisites: [] };
|
|
712
|
+
if (harness.id === 'gemini') {
|
|
713
|
+
const trust = geminiHookTrust(target);
|
|
714
|
+
if (trust?.required && !trust.trusted) return { status: 'installed-pending', prerequisites: ['hook-trust'] };
|
|
715
|
+
return { status: 'active', prerequisites: [] };
|
|
716
|
+
}
|
|
717
|
+
const prerequisites = harness.capabilities?.hooks?.prerequisites || [];
|
|
718
|
+
return prerequisites.length
|
|
719
|
+
? { status: 'installed-pending', prerequisites: [...prerequisites] }
|
|
720
|
+
: { status: 'active', prerequisites: [] };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** FR-003 preflight: refuse to write hook-bearing changes when no bash-capable shell (POSIX
|
|
724
|
+
* bash, Git Bash/MSYS2, or WSL) is invocable, rather than installing hooks that will error
|
|
725
|
+
* silently at runtime. Only harnesses whose selected changes actually include hooks trigger
|
|
726
|
+
* this — a plan touching only guidance/skills assets never invokes the check. Removal is
|
|
727
|
+
* exempt: deleting hook files does not require running them. */
|
|
728
|
+
function assertBashAvailableForHooks(plan: any, mode: any, hasBashCapableShellFn: any) {
|
|
729
|
+
if (mode === 'remove') return;
|
|
730
|
+
const hookHarnesses = plan.targets.filter((target: any) => !target.skipped && targetNeedsHooks(target)).map((target: any) => target.harness);
|
|
731
|
+
if (hookHarnesses.length === 0) return;
|
|
732
|
+
if (hasBashCapableShellFn()) return;
|
|
733
|
+
throw new Error(`No bash-capable shell detected (checked: bash --version). Install Git Bash for Windows, or run inside WSL, before installing hooks for ${hookHarnesses.join(', ')}.`);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function applyLifecycle({ plan, registry, adapters, stateRoot, ledger = plan.ledger, context = {}, mode = 'apply', acceptPrerequisites = false, writeLedgerFn = writeLedger, writeRecoveryRecordFn = writeRecoveryRecord, hasBashCapableShellFn = hasBashCapableShell }: any) {
|
|
737
|
+
assertPlanApplicable(plan, stateRoot, acceptPrerequisites);
|
|
738
|
+
assertBashAvailableForHooks(plan, mode, hasBashCapableShellFn);
|
|
739
|
+
const recovery = writeRecoveryRecordFn(stateRoot, { status: 'pending', changes: plan.changes });
|
|
740
|
+
const completedHarnesses = [];
|
|
741
|
+
try {
|
|
742
|
+
for (const target of plan.targets) {
|
|
743
|
+
if (target.skipped || target.changes.length === 0) continue;
|
|
744
|
+
// A retained change is a ledger release, not a native mutation: the adapter must never see
|
|
745
|
+
// it, because every adapter's job with a removal change is to delete the thing. Filtered
|
|
746
|
+
// here rather than in planLifecycle so the change still reaches updateLedger, which is what
|
|
747
|
+
// actually drops this harness's ownership row.
|
|
748
|
+
const applicable = target.changes.filter((change: any) => !change.retained);
|
|
749
|
+
if (!applicable.length) continue;
|
|
750
|
+
const harness = harnessFor(registry, target.harness);
|
|
751
|
+
const adapter = resolveAdapter(adapters, harness);
|
|
752
|
+
adapter[mode === 'remove' ? 'remove' : 'apply']({ ...target.adapterInput, registry, changes: applicable, ledger, recoveryRef: recovery.id });
|
|
753
|
+
completedHarnesses.push(target.harness);
|
|
754
|
+
}
|
|
755
|
+
} catch (error: any) {
|
|
756
|
+
// A mid-loop throw (fs error, TOCTOU ownership mismatch) leaves some harnesses' native writes
|
|
757
|
+
// already on disk. Recording exactly which ones completed — rather than leaving the record
|
|
758
|
+
// stuck at 'pending' forever — is the difference between a diagnosable partial failure and a
|
|
759
|
+
// silent one a later run can't distinguish from "nothing happened yet".
|
|
760
|
+
writeRecoveryRecordFn(stateRoot, { ...recovery.record, status: 'failed', completedHarnesses, error: error.message });
|
|
761
|
+
throw error;
|
|
762
|
+
}
|
|
763
|
+
const verification = verifyLifecycle({ plan: { ...plan, registry }, adapters,
|
|
764
|
+
context: { ...context, registry, operation: mode === 'remove' ? 'remove' : 'apply', checkPlannedWrites: mode !== 'remove' } });
|
|
765
|
+
if (!verification.ok) {
|
|
766
|
+
writeRecoveryRecordFn(stateRoot, { ...recovery.record, status: 'verification-failed', verification });
|
|
767
|
+
throw new Error('Lifecycle verification failed; ledger was not updated');
|
|
768
|
+
}
|
|
769
|
+
try {
|
|
770
|
+
applyMcpIndex({ scopeRoot: plan.scopeRoot, selectedMcp: plan.mcp, mode, retain: !removalIsTotal(plan) });
|
|
771
|
+
} catch (error: any) {
|
|
772
|
+
// Same reasoning as the harness-loop catch above: every native resource already applied and
|
|
773
|
+
// verified successfully by this point, so leaving the record at 'pending' on a failure here
|
|
774
|
+
// would hide that success and make a retry indistinguishable from a from-scratch install.
|
|
775
|
+
writeRecoveryRecordFn(stateRoot, { ...recovery.record, status: 'failed', completedHarnesses, verification, error: error.message });
|
|
776
|
+
throw error;
|
|
777
|
+
}
|
|
778
|
+
const nextLedger = updateLedger({ ledger, scope: plan.scope, scopeRoot: plan.scopeRoot, verifications: verification.verifications, changes: plan.changes, recoveryRef: recovery.id });
|
|
779
|
+
writeLedgerFn(stateRoot, nextLedger);
|
|
780
|
+
writeRecoveryRecordFn(stateRoot, { ...recovery.record, status: 'verified', verification });
|
|
781
|
+
return { recovery, verification, ledger: nextLedger, retained: plan.retained ?? [] };
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Record the MCP entries a plan finds DoFlow's without changing them: an entry co-owned beside
|
|
786
|
+
* another harness's row, or one adopted from a 1.18.0 install. Their rows are written by verify,
|
|
787
|
+
* which only runs after an apply, so a run with no native change would otherwise leave them out of
|
|
788
|
+
* the ledger, and a later removal of the other harness would delete an entry this harness still
|
|
789
|
+
* selects. Verify only reads; this writes the ledger and nothing native. `dryRun` reports without
|
|
790
|
+
* writing. Returns the ledger and the server ids recorded per harness.
|
|
791
|
+
*/
|
|
792
|
+
function recordMcpOwnership({ plan, registry, adapters, stateRoot, ledger = plan.ledger, dryRun = false, writeLedgerFn = writeLedger }: any) {
|
|
793
|
+
const { verifications } = verifyLifecycle({ plan: { ...plan, registry }, adapters, context: { registry, operation: 'apply' } });
|
|
794
|
+
const held = new Set((ledger.resources || []).map((resource: any) => ownershipKey(resource)));
|
|
795
|
+
const added = verifications.flatMap((verification) => (verification.resources || [])
|
|
796
|
+
.filter((resource: any) => resource.kind === 'mcp-server')
|
|
797
|
+
.map((resource: any) => ({ ...resource, harness: verification.harness, scope: plan.scope, recoveryRef: resource.recoveryRef ?? null })))
|
|
798
|
+
.filter((resource) => !held.has(ownershipKey(resource)));
|
|
799
|
+
const recorded: Record<string, any[]> = {};
|
|
800
|
+
for (const resource of added) (recorded[resource.harness] ??= []).push(resource.identity);
|
|
801
|
+
if (!added.length || dryRun) return { ledger, recorded };
|
|
802
|
+
const next = upgradeLedger({ ...ledger, resources: [...(ledger.resources || []), ...added] });
|
|
803
|
+
writeLedgerFn(stateRoot, next);
|
|
804
|
+
return { ledger: next, recorded };
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function removeLifecycle(options: any) {
|
|
808
|
+
const plan = planLifecycle({ ...options, context: { ...(options.context || {}), operation: 'remove' } });
|
|
809
|
+
return applyLifecycle({ ...options, plan, mode: 'remove', acceptPrerequisites: options.acceptPrerequisites });
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
module.exports = { OPERATIONS, MCP_KEEP, recordMcpOwnership, registryScope, normalizeTargets, normalizeChange, matchesChange, normalizeRemovalVerification, adapterConflicts, planLifecycle, verifyLifecycle, applyLifecycle, removeLifecycle, updateLedger, mcpIndexPath, applyMcpIndex, targetNeedsHooks, assertBashAvailableForHooks, hookWiringStatus, markRetainedRemovals, retentionSummary, RUNTIME_TREE_ASSETS };
|