@ashlr/hub 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1158 -0
- package/LICENSE +21 -0
- package/README.md +833 -0
- package/bin/ashlr +8 -0
- package/dist/api/core.d.ts +28 -0
- package/dist/api/core.js +42 -0
- package/dist/api/core.js.map +1 -0
- package/dist/api/index.d.ts +9 -0
- package/dist/api/index.js +9 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/plugin.d.ts +12 -0
- package/dist/api/plugin.js +13 -0
- package/dist/api/plugin.js.map +1 -0
- package/dist/api/types.d.ts +9 -0
- package/dist/api/types.js +9 -0
- package/dist/api/types.js.map +1 -0
- package/dist/cli/args.d.ts +20 -0
- package/dist/cli/args.js +23 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/ask.d.ts +22 -0
- package/dist/cli/ask.js +240 -0
- package/dist/cli/ask.js.map +1 -0
- package/dist/cli/audit.d.ts +26 -0
- package/dist/cli/audit.js +199 -0
- package/dist/cli/audit.js.map +1 -0
- package/dist/cli/backlog.d.ts +26 -0
- package/dist/cli/backlog.js +322 -0
- package/dist/cli/backlog.js.map +1 -0
- package/dist/cli/completions.d.ts +18 -0
- package/dist/cli/completions.js +156 -0
- package/dist/cli/completions.js.map +1 -0
- package/dist/cli/daemon.d.ts +31 -0
- package/dist/cli/daemon.js +334 -0
- package/dist/cli/daemon.js.map +1 -0
- package/dist/cli/demo-sandbox.d.ts +78 -0
- package/dist/cli/demo-sandbox.js +227 -0
- package/dist/cli/demo-sandbox.js.map +1 -0
- package/dist/cli/demo.d.ts +76 -0
- package/dist/cli/demo.js +285 -0
- package/dist/cli/demo.js.map +1 -0
- package/dist/cli/digest.d.ts +46 -0
- package/dist/cli/digest.js +352 -0
- package/dist/cli/digest.js.map +1 -0
- package/dist/cli/doctor-init.d.ts +28 -0
- package/dist/cli/doctor-init.js +335 -0
- package/dist/cli/doctor-init.js.map +1 -0
- package/dist/cli/genome.d.ts +62 -0
- package/dist/cli/genome.js +1061 -0
- package/dist/cli/genome.js.map +1 -0
- package/dist/cli/gh.d.ts +27 -0
- package/dist/cli/gh.js +322 -0
- package/dist/cli/gh.js.map +1 -0
- package/dist/cli/goals.d.ts +55 -0
- package/dist/cli/goals.js +667 -0
- package/dist/cli/goals.js.map +1 -0
- package/dist/cli/health.d.ts +56 -0
- package/dist/cli/health.js +407 -0
- package/dist/cli/health.js.map +1 -0
- package/dist/cli/help.d.ts +51 -0
- package/dist/cli/help.js +436 -0
- package/dist/cli/help.js.map +1 -0
- package/dist/cli/inbox.d.ts +28 -0
- package/dist/cli/inbox.js +589 -0
- package/dist/cli/inbox.js.map +1 -0
- package/dist/cli/index.d.ts +41 -0
- package/dist/cli/index.js +1208 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/knowledge.d.ts +33 -0
- package/dist/cli/knowledge.js +575 -0
- package/dist/cli/knowledge.js.map +1 -0
- package/dist/cli/mcp.d.ts +26 -0
- package/dist/cli/mcp.js +487 -0
- package/dist/cli/mcp.js.map +1 -0
- package/dist/cli/models.d.ts +26 -0
- package/dist/cli/models.js +435 -0
- package/dist/cli/models.js.map +1 -0
- package/dist/cli/new.d.ts +32 -0
- package/dist/cli/new.js +565 -0
- package/dist/cli/new.js.map +1 -0
- package/dist/cli/notify.d.ts +20 -0
- package/dist/cli/notify.js +188 -0
- package/dist/cli/notify.js.map +1 -0
- package/dist/cli/onboard.d.ts +85 -0
- package/dist/cli/onboard.js +305 -0
- package/dist/cli/onboard.js.map +1 -0
- package/dist/cli/open.d.ts +38 -0
- package/dist/cli/open.js +86 -0
- package/dist/cli/open.js.map +1 -0
- package/dist/cli/orient.d.ts +14 -0
- package/dist/cli/orient.js +110 -0
- package/dist/cli/orient.js.map +1 -0
- package/dist/cli/picker.d.ts +21 -0
- package/dist/cli/picker.js +177 -0
- package/dist/cli/picker.js.map +1 -0
- package/dist/cli/plugins.d.ts +21 -0
- package/dist/cli/plugins.js +394 -0
- package/dist/cli/plugins.js.map +1 -0
- package/dist/cli/preflight.d.ts +29 -0
- package/dist/cli/preflight.js +131 -0
- package/dist/cli/preflight.js.map +1 -0
- package/dist/cli/pulse.d.ts +26 -0
- package/dist/cli/pulse.js +482 -0
- package/dist/cli/pulse.js.map +1 -0
- package/dist/cli/reflect.d.ts +42 -0
- package/dist/cli/reflect.js +381 -0
- package/dist/cli/reflect.js.map +1 -0
- package/dist/cli/run.d.ts +29 -0
- package/dist/cli/run.js +687 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/cli/sandbox.d.ts +20 -0
- package/dist/cli/sandbox.js +345 -0
- package/dist/cli/sandbox.js.map +1 -0
- package/dist/cli/seams.d.ts +41 -0
- package/dist/cli/seams.js +167 -0
- package/dist/cli/seams.js.map +1 -0
- package/dist/cli/serve.d.ts +25 -0
- package/dist/cli/serve.js +249 -0
- package/dist/cli/serve.js.map +1 -0
- package/dist/cli/ship.d.ts +33 -0
- package/dist/cli/ship.js +433 -0
- package/dist/cli/ship.js.map +1 -0
- package/dist/cli/spec.d.ts +19 -0
- package/dist/cli/spec.js +478 -0
- package/dist/cli/spec.js.map +1 -0
- package/dist/cli/swarm.d.ts +42 -0
- package/dist/cli/swarm.js +1365 -0
- package/dist/cli/swarm.js.map +1 -0
- package/dist/cli/telemetry.d.ts +26 -0
- package/dist/cli/telemetry.js +364 -0
- package/dist/cli/telemetry.js.map +1 -0
- package/dist/cli/tui.d.ts +17 -0
- package/dist/cli/tui.js +52 -0
- package/dist/cli/tui.js.map +1 -0
- package/dist/cli/ui.d.ts +53 -0
- package/dist/cli/ui.js +66 -0
- package/dist/cli/ui.js.map +1 -0
- package/dist/cli/update.d.ts +38 -0
- package/dist/cli/update.js +682 -0
- package/dist/cli/update.js.map +1 -0
- package/dist/cli/vercel.d.ts +20 -0
- package/dist/cli/vercel.js +193 -0
- package/dist/cli/vercel.js.map +1 -0
- package/dist/cli/verify-safety.d.ts +96 -0
- package/dist/cli/verify-safety.js +509 -0
- package/dist/cli/verify-safety.js.map +1 -0
- package/dist/cli/wire.d.ts +22 -0
- package/dist/cli/wire.js +205 -0
- package/dist/cli/wire.js.map +1 -0
- package/dist/core/classify.d.ts +51 -0
- package/dist/core/classify.js +450 -0
- package/dist/core/classify.js.map +1 -0
- package/dist/core/config.d.ts +63 -0
- package/dist/core/config.js +474 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/daemon/loop.d.ts +74 -0
- package/dist/core/daemon/loop.js +618 -0
- package/dist/core/daemon/loop.js.map +1 -0
- package/dist/core/daemon/state.d.ts +66 -0
- package/dist/core/daemon/state.js +197 -0
- package/dist/core/daemon/state.js.map +1 -0
- package/dist/core/dashboard.d.ts +40 -0
- package/dist/core/dashboard.js +463 -0
- package/dist/core/dashboard.js.map +1 -0
- package/dist/core/digest/build.d.ts +51 -0
- package/dist/core/digest/build.js +230 -0
- package/dist/core/digest/build.js.map +1 -0
- package/dist/core/digest/deliver.d.ts +47 -0
- package/dist/core/digest/deliver.js +230 -0
- package/dist/core/digest/deliver.js.map +1 -0
- package/dist/core/digest/store.d.ts +57 -0
- package/dist/core/digest/store.js +223 -0
- package/dist/core/digest/store.js.map +1 -0
- package/dist/core/doctor-fix.d.ts +21 -0
- package/dist/core/doctor-fix.js +440 -0
- package/dist/core/doctor-fix.js.map +1 -0
- package/dist/core/doctor.d.ts +18 -0
- package/dist/core/doctor.js +806 -0
- package/dist/core/doctor.js.map +1 -0
- package/dist/core/env-bridge.d.ts +64 -0
- package/dist/core/env-bridge.js +103 -0
- package/dist/core/env-bridge.js.map +1 -0
- package/dist/core/genome/capture.d.ts +49 -0
- package/dist/core/genome/capture.js +352 -0
- package/dist/core/genome/capture.js.map +1 -0
- package/dist/core/genome/consolidate.d.ts +38 -0
- package/dist/core/genome/consolidate.js +426 -0
- package/dist/core/genome/consolidate.js.map +1 -0
- package/dist/core/genome/export.d.ts +29 -0
- package/dist/core/genome/export.js +102 -0
- package/dist/core/genome/export.js.map +1 -0
- package/dist/core/genome/playbook.d.ts +33 -0
- package/dist/core/genome/playbook.js +320 -0
- package/dist/core/genome/playbook.js.map +1 -0
- package/dist/core/genome/recall.d.ts +45 -0
- package/dist/core/genome/recall.js +293 -0
- package/dist/core/genome/recall.js.map +1 -0
- package/dist/core/genome/store.d.ts +62 -0
- package/dist/core/genome/store.js +711 -0
- package/dist/core/genome/store.js.map +1 -0
- package/dist/core/git.d.ts +34 -0
- package/dist/core/git.js +115 -0
- package/dist/core/git.js.map +1 -0
- package/dist/core/goals/advance.d.ts +82 -0
- package/dist/core/goals/advance.js +263 -0
- package/dist/core/goals/advance.js.map +1 -0
- package/dist/core/goals/planner.d.ts +58 -0
- package/dist/core/goals/planner.js +233 -0
- package/dist/core/goals/planner.js.map +1 -0
- package/dist/core/goals/store.d.ts +128 -0
- package/dist/core/goals/store.js +442 -0
- package/dist/core/goals/store.js.map +1 -0
- package/dist/core/inbox/apply.d.ts +36 -0
- package/dist/core/inbox/apply.js +407 -0
- package/dist/core/inbox/apply.js.map +1 -0
- package/dist/core/inbox/notify-proposal.d.ts +13 -0
- package/dist/core/inbox/notify-proposal.js +21 -0
- package/dist/core/inbox/notify-proposal.js.map +1 -0
- package/dist/core/inbox/store.d.ts +67 -0
- package/dist/core/inbox/store.js +258 -0
- package/dist/core/inbox/store.js.map +1 -0
- package/dist/core/index-engine.d.ts +62 -0
- package/dist/core/index-engine.js +486 -0
- package/dist/core/index-engine.js.map +1 -0
- package/dist/core/integrations/desktop-notify.d.ts +18 -0
- package/dist/core/integrations/desktop-notify.js +42 -0
- package/dist/core/integrations/desktop-notify.js.map +1 -0
- package/dist/core/integrations/editors.d.ts +50 -0
- package/dist/core/integrations/editors.js +216 -0
- package/dist/core/integrations/editors.js.map +1 -0
- package/dist/core/integrations/github.d.ts +66 -0
- package/dist/core/integrations/github.js +332 -0
- package/dist/core/integrations/github.js.map +1 -0
- package/dist/core/integrations/identity.d.ts +29 -0
- package/dist/core/integrations/identity.js +359 -0
- package/dist/core/integrations/identity.js.map +1 -0
- package/dist/core/integrations/notify.d.ts +23 -0
- package/dist/core/integrations/notify.js +96 -0
- package/dist/core/integrations/notify.js.map +1 -0
- package/dist/core/integrations/vercel.d.ts +37 -0
- package/dist/core/integrations/vercel.js +192 -0
- package/dist/core/integrations/vercel.js.map +1 -0
- package/dist/core/knowledge/ask.d.ts +34 -0
- package/dist/core/knowledge/ask.js +325 -0
- package/dist/core/knowledge/ask.js.map +1 -0
- package/dist/core/knowledge/graph.d.ts +51 -0
- package/dist/core/knowledge/graph.js +564 -0
- package/dist/core/knowledge/graph.js.map +1 -0
- package/dist/core/knowledge/index.d.ts +74 -0
- package/dist/core/knowledge/index.js +586 -0
- package/dist/core/knowledge/index.js.map +1 -0
- package/dist/core/learn/playbooks.d.ts +84 -0
- package/dist/core/learn/playbooks.js +241 -0
- package/dist/core/learn/playbooks.js.map +1 -0
- package/dist/core/learn/reflect.d.ts +87 -0
- package/dist/core/learn/reflect.js +435 -0
- package/dist/core/learn/reflect.js.map +1 -0
- package/dist/core/learn/store.d.ts +51 -0
- package/dist/core/learn/store.js +165 -0
- package/dist/core/learn/store.js.map +1 -0
- package/dist/core/learn/tuning.d.ts +48 -0
- package/dist/core/learn/tuning.js +201 -0
- package/dist/core/learn/tuning.js.map +1 -0
- package/dist/core/lifecycle/scaffold.d.ts +43 -0
- package/dist/core/lifecycle/scaffold.js +260 -0
- package/dist/core/lifecycle/scaffold.js.map +1 -0
- package/dist/core/lifecycle/ship.d.ts +48 -0
- package/dist/core/lifecycle/ship.js +513 -0
- package/dist/core/lifecycle/ship.js.map +1 -0
- package/dist/core/lifecycle/templates.d.ts +20 -0
- package/dist/core/lifecycle/templates.js +605 -0
- package/dist/core/lifecycle/templates.js.map +1 -0
- package/dist/core/mcp-gateway.d.ts +59 -0
- package/dist/core/mcp-gateway.js +385 -0
- package/dist/core/mcp-gateway.js.map +1 -0
- package/dist/core/mcp-native.d.ts +53 -0
- package/dist/core/mcp-native.js +507 -0
- package/dist/core/mcp-native.js.map +1 -0
- package/dist/core/mcp-registry.d.ts +36 -0
- package/dist/core/mcp-registry.js +180 -0
- package/dist/core/mcp-registry.js.map +1 -0
- package/dist/core/observability/budget-alert.d.ts +18 -0
- package/dist/core/observability/budget-alert.js +90 -0
- package/dist/core/observability/budget-alert.js.map +1 -0
- package/dist/core/observability/estimate.d.ts +27 -0
- package/dist/core/observability/estimate.js +188 -0
- package/dist/core/observability/estimate.js.map +1 -0
- package/dist/core/observability/forecast.d.ts +19 -0
- package/dist/core/observability/forecast.js +101 -0
- package/dist/core/observability/forecast.js.map +1 -0
- package/dist/core/observability/governance.d.ts +28 -0
- package/dist/core/observability/governance.js +101 -0
- package/dist/core/observability/governance.js.map +1 -0
- package/dist/core/observability/otlp.d.ts +88 -0
- package/dist/core/observability/otlp.js +217 -0
- package/dist/core/observability/otlp.js.map +1 -0
- package/dist/core/observability/rollup.d.ts +35 -0
- package/dist/core/observability/rollup.js +311 -0
- package/dist/core/observability/rollup.js.map +1 -0
- package/dist/core/observability/telemetry-sink.d.ts +63 -0
- package/dist/core/observability/telemetry-sink.js +350 -0
- package/dist/core/observability/telemetry-sink.js.map +1 -0
- package/dist/core/observability/usage-source.d.ts +60 -0
- package/dist/core/observability/usage-source.js +347 -0
- package/dist/core/observability/usage-source.js.map +1 -0
- package/dist/core/onboard.d.ts +27 -0
- package/dist/core/onboard.js +288 -0
- package/dist/core/onboard.js.map +1 -0
- package/dist/core/orient.d.ts +23 -0
- package/dist/core/orient.js +135 -0
- package/dist/core/orient.js.map +1 -0
- package/dist/core/phantom.d.ts +23 -0
- package/dist/core/phantom.js +279 -0
- package/dist/core/phantom.js.map +1 -0
- package/dist/core/plugins/host-api.d.ts +29 -0
- package/dist/core/plugins/host-api.js +113 -0
- package/dist/core/plugins/host-api.js.map +1 -0
- package/dist/core/plugins/integrity.d.ts +36 -0
- package/dist/core/plugins/integrity.js +73 -0
- package/dist/core/plugins/integrity.js.map +1 -0
- package/dist/core/plugins/manifest.d.ts +31 -0
- package/dist/core/plugins/manifest.js +316 -0
- package/dist/core/plugins/manifest.js.map +1 -0
- package/dist/core/plugins/registry.d.ts +87 -0
- package/dist/core/plugins/registry.js +415 -0
- package/dist/core/plugins/registry.js.map +1 -0
- package/dist/core/plugins/types.d.ts +182 -0
- package/dist/core/plugins/types.js +40 -0
- package/dist/core/plugins/types.js.map +1 -0
- package/dist/core/plugins/wrappers.d.ts +40 -0
- package/dist/core/plugins/wrappers.js +229 -0
- package/dist/core/plugins/wrappers.js.map +1 -0
- package/dist/core/portfolio/backlog.d.ts +40 -0
- package/dist/core/portfolio/backlog.js +177 -0
- package/dist/core/portfolio/backlog.js.map +1 -0
- package/dist/core/portfolio/scanners.d.ts +21 -0
- package/dist/core/portfolio/scanners.js +600 -0
- package/dist/core/portfolio/scanners.js.map +1 -0
- package/dist/core/providers.d.ts +34 -0
- package/dist/core/providers.js +250 -0
- package/dist/core/providers.js.map +1 -0
- package/dist/core/quality/conventions.d.ts +35 -0
- package/dist/core/quality/conventions.js +267 -0
- package/dist/core/quality/conventions.js.map +1 -0
- package/dist/core/quality/fixes.d.ts +56 -0
- package/dist/core/quality/fixes.js +209 -0
- package/dist/core/quality/fixes.js.map +1 -0
- package/dist/core/quality/health.d.ts +69 -0
- package/dist/core/quality/health.js +350 -0
- package/dist/core/quality/health.js.map +1 -0
- package/dist/core/quality/store.d.ts +56 -0
- package/dist/core/quality/store.js +195 -0
- package/dist/core/quality/store.js.map +1 -0
- package/dist/core/readiness.d.ts +112 -0
- package/dist/core/readiness.js +431 -0
- package/dist/core/readiness.js.map +1 -0
- package/dist/core/run/agent-loop.d.ts +40 -0
- package/dist/core/run/agent-loop.js +291 -0
- package/dist/core/run/agent-loop.js.map +1 -0
- package/dist/core/run/budget.d.ts +47 -0
- package/dist/core/run/budget.js +113 -0
- package/dist/core/run/budget.js.map +1 -0
- package/dist/core/run/engines.d.ts +75 -0
- package/dist/core/run/engines.js +199 -0
- package/dist/core/run/engines.js.map +1 -0
- package/dist/core/run/model-manager.d.ts +64 -0
- package/dist/core/run/model-manager.js +339 -0
- package/dist/core/run/model-manager.js.map +1 -0
- package/dist/core/run/orchestrator.d.ts +100 -0
- package/dist/core/run/orchestrator.js +1515 -0
- package/dist/core/run/orchestrator.js.map +1 -0
- package/dist/core/run/provider-client.d.ts +46 -0
- package/dist/core/run/provider-client.js +796 -0
- package/dist/core/run/provider-client.js.map +1 -0
- package/dist/core/run/retry.d.ts +19 -0
- package/dist/core/run/retry.js +68 -0
- package/dist/core/run/retry.js.map +1 -0
- package/dist/core/run/router.d.ts +50 -0
- package/dist/core/run/router.js +257 -0
- package/dist/core/run/router.js.map +1 -0
- package/dist/core/run/self-heal.d.ts +52 -0
- package/dist/core/run/self-heal.js +181 -0
- package/dist/core/run/self-heal.js.map +1 -0
- package/dist/core/run/streaming.d.ts +31 -0
- package/dist/core/run/streaming.js +122 -0
- package/dist/core/run/streaming.js.map +1 -0
- package/dist/core/run/verify.d.ts +30 -0
- package/dist/core/run/verify.js +204 -0
- package/dist/core/run/verify.js.map +1 -0
- package/dist/core/sandbox/audit.d.ts +31 -0
- package/dist/core/sandbox/audit.js +169 -0
- package/dist/core/sandbox/audit.js.map +1 -0
- package/dist/core/sandbox/policy.d.ts +61 -0
- package/dist/core/sandbox/policy.js +211 -0
- package/dist/core/sandbox/policy.js.map +1 -0
- package/dist/core/sandbox/worktree.d.ts +175 -0
- package/dist/core/sandbox/worktree.js +673 -0
- package/dist/core/sandbox/worktree.js.map +1 -0
- package/dist/core/seams/backlog.d.ts +47 -0
- package/dist/core/seams/backlog.js +47 -0
- package/dist/core/seams/backlog.js.map +1 -0
- package/dist/core/seams/daemon-coordinator.d.ts +72 -0
- package/dist/core/seams/daemon-coordinator.js +76 -0
- package/dist/core/seams/daemon-coordinator.js.map +1 -0
- package/dist/core/seams/genome.d.ts +45 -0
- package/dist/core/seams/genome.js +53 -0
- package/dist/core/seams/genome.js.map +1 -0
- package/dist/core/seams/identity.d.ts +40 -0
- package/dist/core/seams/identity.js +44 -0
- package/dist/core/seams/identity.js.map +1 -0
- package/dist/core/seams/inbox.d.ts +60 -0
- package/dist/core/seams/inbox.js +66 -0
- package/dist/core/seams/inbox.js.map +1 -0
- package/dist/core/seams/index.d.ts +20 -0
- package/dist/core/seams/index.js +21 -0
- package/dist/core/seams/index.js.map +1 -0
- package/dist/core/seams/portfolio.d.ts +50 -0
- package/dist/core/seams/portfolio.js +61 -0
- package/dist/core/seams/portfolio.js.map +1 -0
- package/dist/core/seams/registry.d.ts +42 -0
- package/dist/core/seams/registry.js +128 -0
- package/dist/core/seams/registry.js.map +1 -0
- package/dist/core/seams/run-swarm.d.ts +66 -0
- package/dist/core/seams/run-swarm.js +74 -0
- package/dist/core/seams/run-swarm.js.map +1 -0
- package/dist/core/seams/types.d.ts +123 -0
- package/dist/core/seams/types.js +35 -0
- package/dist/core/seams/types.js.map +1 -0
- package/dist/core/spec/spec-store.d.ts +62 -0
- package/dist/core/spec/spec-store.js +359 -0
- package/dist/core/spec/spec-store.js.map +1 -0
- package/dist/core/swarm/gate.d.ts +45 -0
- package/dist/core/swarm/gate.js +114 -0
- package/dist/core/swarm/gate.js.map +1 -0
- package/dist/core/swarm/planner.d.ts +32 -0
- package/dist/core/swarm/planner.js +293 -0
- package/dist/core/swarm/planner.js.map +1 -0
- package/dist/core/swarm/rollback.d.ts +56 -0
- package/dist/core/swarm/rollback.js +266 -0
- package/dist/core/swarm/rollback.js.map +1 -0
- package/dist/core/swarm/runner.d.ts +62 -0
- package/dist/core/swarm/runner.js +1263 -0
- package/dist/core/swarm/runner.js.map +1 -0
- package/dist/core/swarm/sign.d.ts +71 -0
- package/dist/core/swarm/sign.js +362 -0
- package/dist/core/swarm/sign.js.map +1 -0
- package/dist/core/swarm/store.d.ts +52 -0
- package/dist/core/swarm/store.js +195 -0
- package/dist/core/swarm/store.js.map +1 -0
- package/dist/core/tidy.d.ts +32 -0
- package/dist/core/tidy.js +354 -0
- package/dist/core/tidy.js.map +1 -0
- package/dist/core/tools-registry.d.ts +16 -0
- package/dist/core/tools-registry.js +308 -0
- package/dist/core/tools-registry.js.map +1 -0
- package/dist/core/types.d.ts +2545 -0
- package/dist/core/types.js +9 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/web/api.d.ts +53 -0
- package/dist/core/web/api.js +698 -0
- package/dist/core/web/api.js.map +1 -0
- package/dist/core/web/public/app.js +1906 -0
- package/dist/core/web/public/index.html +721 -0
- package/dist/core/web/public/styles.css +2007 -0
- package/dist/core/web/server.d.ts +18 -0
- package/dist/core/web/server.js +122 -0
- package/dist/core/web/server.js.map +1 -0
- package/dist/core/web/static.d.ts +18 -0
- package/dist/core/web/static.js +122 -0
- package/dist/core/web/static.js.map +1 -0
- package/dist/tui/app.d.ts +33 -0
- package/dist/tui/app.js +350 -0
- package/dist/tui/app.js.map +1 -0
- package/dist/tui/render.d.ts +20 -0
- package/dist/tui/render.js +558 -0
- package/dist/tui/render.js.map +1 -0
- package/package.json +80 -0
- package/schema/config.schema.json +223 -0
|
@@ -0,0 +1,1515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/run/orchestrator.ts — M4/M11/M15 local-first agent orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - planGoal: single chat call -> RunTask[] DAG (1-6 tasks, deps valid).
|
|
6
|
+
* - runGoal: resolve client, plan/resume, execute DAG (parallel up to opts.parallel),
|
|
7
|
+
* enforce HARD budget, persist RunState after every step, synthesize
|
|
8
|
+
* final answer, best-effort Pulse POST.
|
|
9
|
+
* - loadRun / listRuns / saveRun: JSON persistence under ~/.ashlr/runs/.
|
|
10
|
+
*
|
|
11
|
+
* M7 addition: genome-aware injection. Before planning, runGoal calls
|
|
12
|
+
* recall(goal, cfg) from src/core/genome/recall.ts (dynamic import, best-effort)
|
|
13
|
+
* and prepends a bounded "Relevant project memory:" block to the planning system
|
|
14
|
+
* prompt so the planner starts with relevant cross-project context.
|
|
15
|
+
* Gated on cfg.genome?.injectOnRun (default true) and opts.noMemory (opt-out).
|
|
16
|
+
* Never throws — if recall fails or is empty, the run proceeds unchanged.
|
|
17
|
+
*
|
|
18
|
+
* M16 addition: playbook injection + auto-capture.
|
|
19
|
+
* - Planning injection: when cfg.genome?.playbookOnRun !== false and !noMemory,
|
|
20
|
+
* builds a synthesized playbook via genome/playbook.buildPlaybook (dynamic import,
|
|
21
|
+
* best-effort) and injects playbookText(...) instead of raw recall. Falls back to
|
|
22
|
+
* the existing raw-recall block on any playbook failure.
|
|
23
|
+
* - Auto-capture: after final state is persisted, calls captureFromRun (fire-and-
|
|
24
|
+
* forget) from genome/capture.ts. Disabled via opts.noCapture or
|
|
25
|
+
* cfg.genome?.autoCapture === false. Never throws, never blocks.
|
|
26
|
+
*
|
|
27
|
+
* M11 additions:
|
|
28
|
+
* - HARDENED ENGINE DELEGATION: buildEngineCommand + spawnEngine (engines.ts)
|
|
29
|
+
* replace the guessed ['--goal',goal] spawn. Per-engine adapters produce
|
|
30
|
+
* correct argv; phantom-exec wraps when cfg.phantom?.enabled.
|
|
31
|
+
* - STREAMING: StreamSink threaded from CLI (__sink on opts) through runGoal
|
|
32
|
+
* → runTask → agent loop. Events: task-start/model-delta/tool-call/task-done/
|
|
33
|
+
* retry/verify/log. nullSink used when absent.
|
|
34
|
+
* - RETRY: per-task withRetry (bounded, budget-aware) on tool/transient failures.
|
|
35
|
+
* - VERIFY: verifyTask after each builtin task; one retry on !ok if budget allows;
|
|
36
|
+
* else annotates result with [needs-attention].
|
|
37
|
+
*
|
|
38
|
+
* M15 additions:
|
|
39
|
+
* - PER-TASK ROUTING: before each task attempt, chooseRoute() selects the best
|
|
40
|
+
* LOCAL provider+model (or cloud when allowCloud + key + escalation reason).
|
|
41
|
+
* Dynamic import of router.ts — best-effort; falls back to getActiveClient when
|
|
42
|
+
* the module is absent (preserves pre-M15 behavior in the build pipeline).
|
|
43
|
+
* - AUTO-ESCALATE: on task failure or verify !ok, if allowCloud is set AND a cloud
|
|
44
|
+
* key is present, ONE escalated routed retry is attempted. Otherwise stays local
|
|
45
|
+
* and marks needs-attention. Gated exactly by chooseRoute's guardrails.
|
|
46
|
+
* - COST ATTRIBUTION: estCostUsd uses the per-task RouteDecision.provider so local
|
|
47
|
+
* tasks always cost $0 and cloud escalations are estimated correctly.
|
|
48
|
+
*
|
|
49
|
+
* Safety guardrails (binding):
|
|
50
|
+
* - Never writes outside ~/.ashlr/runs/ — no repos/Desktop, no git.
|
|
51
|
+
* - Budget is a HARD ceiling (aborts with partial results preserved).
|
|
52
|
+
* - Cloud endpoints require explicit allowCloud + key present (delegated to
|
|
53
|
+
* getActiveClient / chooseRoute). NO SILENT CLOUD SPEND.
|
|
54
|
+
* - Zero new runtime deps (Node builtins + @modelcontextprotocol/sdk only).
|
|
55
|
+
* - Genome recall is local-only (keyword/TF-IDF, optional local Ollama embeddings).
|
|
56
|
+
* - Engine delegation is a single bounded spawn — never recursive.
|
|
57
|
+
* - NO AUTO-DOWNLOAD: ollama pull is never called from routing or runs.
|
|
58
|
+
*/
|
|
59
|
+
import * as fs from 'node:fs';
|
|
60
|
+
import * as os from 'node:os';
|
|
61
|
+
import * as path from 'node:path';
|
|
62
|
+
import { execFileSync } from 'node:child_process';
|
|
63
|
+
import { getActiveClient } from './provider-client.js';
|
|
64
|
+
import { newUsage, overBudget, estCostUsd } from './budget.js';
|
|
65
|
+
import { runTask } from './agent-loop.js';
|
|
66
|
+
import { withToolEnv } from '../env-bridge.js';
|
|
67
|
+
import { buildEngineCommand, engineInstalled, spawnEngine } from './engines.js';
|
|
68
|
+
import { nullSink } from './streaming.js';
|
|
69
|
+
import { withRetry } from './retry.js';
|
|
70
|
+
import { verifyTask } from './verify.js';
|
|
71
|
+
import { withHeal, defaultHealPolicy } from './self-heal.js';
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Constants / defaults
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
/** Default token budget per run. */
|
|
76
|
+
export const DEFAULT_MAX_TOKENS = 50_000;
|
|
77
|
+
/** Default step budget per run. */
|
|
78
|
+
export const DEFAULT_MAX_STEPS = 40;
|
|
79
|
+
/** Default parallel task execution limit. */
|
|
80
|
+
export const DEFAULT_PARALLEL = 2;
|
|
81
|
+
/** Directory for persisted run state. */
|
|
82
|
+
/** Re-resolved at call time so tests can relocate HOME (matches swarmsDir()). */
|
|
83
|
+
function runsDir() {
|
|
84
|
+
return path.join(os.homedir(), '.ashlr', 'runs');
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Sentinel error attached to tasks that were force-failed by a budget abort
|
|
88
|
+
* (as opposed to a genuine model failure). On --resume we reset tasks bearing
|
|
89
|
+
* this exact error back to 'pending' so they re-run under the new budget.
|
|
90
|
+
*/
|
|
91
|
+
const ABORT_TASK_ERROR = 'Aborted: run budget exceeded';
|
|
92
|
+
/**
|
|
93
|
+
* Maximum characters of genome memory injected into the planning prompt.
|
|
94
|
+
* Keeps the injection bounded regardless of entry size.
|
|
95
|
+
*/
|
|
96
|
+
const GENOME_INJECT_CHAR_CAP = 1500;
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Persistence helpers
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
/**
|
|
101
|
+
* Ensure the runs directory exists (mkdir -p).
|
|
102
|
+
* Only creates entries under ~/.ashlr/runs — never repos/Desktop.
|
|
103
|
+
*/
|
|
104
|
+
function ensureRunsDir() {
|
|
105
|
+
fs.mkdirSync(runsDir(), { recursive: true });
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Compute the absolute path for a run file.
|
|
109
|
+
* Validates the id contains only safe characters to prevent path traversal.
|
|
110
|
+
*/
|
|
111
|
+
function runFilePath(id) {
|
|
112
|
+
// Only allow alphanumeric, hyphens, underscores, dots — no slashes or traversal
|
|
113
|
+
if (!/^[\w.-]+$/.test(id)) {
|
|
114
|
+
throw new Error(`Invalid run id: ${JSON.stringify(id)}`);
|
|
115
|
+
}
|
|
116
|
+
return path.join(runsDir(), `${id}.json`);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Load a persisted RunState by id. Returns null if absent, unreadable, or invalid JSON.
|
|
120
|
+
*/
|
|
121
|
+
export function loadRun(id) {
|
|
122
|
+
try {
|
|
123
|
+
const file = runFilePath(id);
|
|
124
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
125
|
+
return JSON.parse(raw);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* List all persisted runs, newest first by createdAt.
|
|
133
|
+
*/
|
|
134
|
+
export function listRuns() {
|
|
135
|
+
try {
|
|
136
|
+
ensureRunsDir();
|
|
137
|
+
const files = fs.readdirSync(runsDir()).filter((f) => f.endsWith('.json'));
|
|
138
|
+
const runs = [];
|
|
139
|
+
for (const file of files) {
|
|
140
|
+
try {
|
|
141
|
+
const raw = fs.readFileSync(path.join(runsDir(), file), 'utf8');
|
|
142
|
+
const state = JSON.parse(raw);
|
|
143
|
+
runs.push(state);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Skip corrupt/unreadable files silently
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return runs.sort((a, b) => (b.createdAt > a.createdAt ? 1 : -1));
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Atomically persist a RunState to ~/.ashlr/runs/<id>.json (write-then-rename).
|
|
157
|
+
* ONLY writes under runsDir() — never touches repos or Desktop.
|
|
158
|
+
*/
|
|
159
|
+
export function saveRun(s) {
|
|
160
|
+
ensureRunsDir();
|
|
161
|
+
const dest = runFilePath(s.id);
|
|
162
|
+
const tmp = dest + '.tmp';
|
|
163
|
+
const payload = JSON.stringify(s, null, 2);
|
|
164
|
+
fs.writeFileSync(tmp, payload, 'utf8');
|
|
165
|
+
fs.renameSync(tmp, dest);
|
|
166
|
+
}
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Run id generation
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
/**
|
|
171
|
+
* Generate a unique run id from the wall clock (format: run-<timestamp>-<random>).
|
|
172
|
+
* Callers may inject an id for test determinism.
|
|
173
|
+
*/
|
|
174
|
+
function generateRunId() {
|
|
175
|
+
const ts = Date.now();
|
|
176
|
+
const rand = Math.random().toString(36).slice(2, 7);
|
|
177
|
+
return `run-${ts}-${rand}`;
|
|
178
|
+
}
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// M7: Genome recall injection (best-effort, local-only)
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
/**
|
|
183
|
+
* Attempt to recall relevant genome entries for the goal and format them as a
|
|
184
|
+
* bounded context block suitable for prepending to a planning system prompt.
|
|
185
|
+
*
|
|
186
|
+
* Rules:
|
|
187
|
+
* - Dynamic import of ../genome/recall.js — if the module does not exist yet
|
|
188
|
+
* (other M7 agents have not shipped it), returns '' gracefully.
|
|
189
|
+
* - Total injected text is capped at GENOME_INJECT_CHAR_CAP characters.
|
|
190
|
+
* - Never throws — any error returns '' so the run proceeds unchanged.
|
|
191
|
+
* - Local-only: embeddings via local Ollama only, never cloud.
|
|
192
|
+
*/
|
|
193
|
+
async function buildMemoryBlock(goal, cfg) {
|
|
194
|
+
try {
|
|
195
|
+
// Dynamic import: tolerates the module being absent (pre-M7 build).
|
|
196
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
197
|
+
const recallMod = await import('../genome/recall.js');
|
|
198
|
+
if (typeof recallMod.recall !== 'function')
|
|
199
|
+
return '';
|
|
200
|
+
const limit = cfg.genome?.maxRecall ?? 3;
|
|
201
|
+
const hits = await recallMod.recall(goal, cfg, { limit });
|
|
202
|
+
if (!Array.isArray(hits) || hits.length === 0)
|
|
203
|
+
return '';
|
|
204
|
+
const lines = ['Relevant project memory:'];
|
|
205
|
+
let charCount = lines[0].length + 1;
|
|
206
|
+
for (const hit of hits) {
|
|
207
|
+
if (!hit?.entry)
|
|
208
|
+
continue;
|
|
209
|
+
const project = hit.entry.project ? ` [${hit.entry.project}]` : '';
|
|
210
|
+
const header = `- ${hit.entry.title ?? 'note'}${project}:`;
|
|
211
|
+
const body = String(hit.entry.text ?? '').replace(/\s+/g, ' ').trim();
|
|
212
|
+
const fragment = `${header} ${body}`;
|
|
213
|
+
// Stop if adding this entry would exceed the character cap
|
|
214
|
+
if (charCount + fragment.length + 1 > GENOME_INJECT_CHAR_CAP) {
|
|
215
|
+
// Attempt a truncated version (at least 20 chars of body are worth showing)
|
|
216
|
+
const remaining = GENOME_INJECT_CHAR_CAP - charCount - header.length - 4;
|
|
217
|
+
if (remaining > 20) {
|
|
218
|
+
lines.push(`${header} ${body.slice(0, remaining)}…`);
|
|
219
|
+
}
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
lines.push(fragment);
|
|
223
|
+
charCount += fragment.length + 1;
|
|
224
|
+
}
|
|
225
|
+
// Only return the block if we actually added at least one entry beyond header
|
|
226
|
+
if (lines.length <= 1)
|
|
227
|
+
return '';
|
|
228
|
+
return lines.join('\n');
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// Module absent, recall failed, or any other error — proceed without memory
|
|
232
|
+
return '';
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Cached router module reference (loaded once, null when unavailable). */
|
|
236
|
+
let _routerMod = undefined; // undefined = not yet tried
|
|
237
|
+
/**
|
|
238
|
+
* Load the router module (core/run/router.ts) exactly once, best-effort.
|
|
239
|
+
* Returns null when the module is not yet present in the build (pre-M15).
|
|
240
|
+
* Never throws.
|
|
241
|
+
*/
|
|
242
|
+
async function loadRouter() {
|
|
243
|
+
if (_routerMod !== undefined)
|
|
244
|
+
return _routerMod;
|
|
245
|
+
try {
|
|
246
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
247
|
+
const mod = await import('./router.js');
|
|
248
|
+
if (typeof mod.chooseRoute === 'function' && typeof mod.cloudKeyAvailable === 'function') {
|
|
249
|
+
_routerMod = mod;
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
_routerMod = null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// Module not present or failed to load — fall back to getActiveClient.
|
|
257
|
+
_routerMod = null;
|
|
258
|
+
}
|
|
259
|
+
return _routerMod;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Build a ProviderClient for a given RouteDecision.
|
|
263
|
+
*
|
|
264
|
+
* Provider-aware (M15): the routed provider+model are passed EXPLICITLY into
|
|
265
|
+
* getActiveClient (no process.env mutation), so a cloud RouteDecision actually
|
|
266
|
+
* targets the routed cloud provider instead of silently re-running on the local
|
|
267
|
+
* active provider. This also removes the global ASHLR_MODEL env race that would
|
|
268
|
+
* misroute concurrent tasks resolving to different per-task models.
|
|
269
|
+
*
|
|
270
|
+
* For local routes (tier='local'): getActiveClient(provider, model, allowCloud=false).
|
|
271
|
+
* For cloud routes (tier='cloud'): getActiveClient(provider, model, allowCloud=true) —
|
|
272
|
+
* which enforces the key check and (until cloud completions are implemented)
|
|
273
|
+
* throws; on ANY failure we fall back to the default local client.
|
|
274
|
+
*
|
|
275
|
+
* Never throws — on failure, falls back to the default client. The CALLER must
|
|
276
|
+
* attribute cost using the returned client's `.id` (not the decision's intended
|
|
277
|
+
* provider), because a cloud decision that fails to build falls back to local
|
|
278
|
+
* and must be charged at $0, not at cloud rates.
|
|
279
|
+
*/
|
|
280
|
+
async function buildRoutedClient(decision, cfg, allowCloud) {
|
|
281
|
+
const routedModel = decision.model && decision.model !== 'default' ? decision.model : undefined;
|
|
282
|
+
try {
|
|
283
|
+
const cloudOk = decision.tier === 'cloud' && allowCloud;
|
|
284
|
+
return await getActiveClient(cfg, {
|
|
285
|
+
allowCloud: cloudOk,
|
|
286
|
+
provider: decision.provider,
|
|
287
|
+
model: routedModel,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// Route failed (e.g. provider down, cloud key missing, cloud completions
|
|
292
|
+
// not implemented) — fall back to the default local-first client. The
|
|
293
|
+
// returned client's .id reflects the LOCAL provider, so the caller charges
|
|
294
|
+
// local rates ($0) for this attempt rather than the unbuilt cloud provider.
|
|
295
|
+
return await getActiveClient(cfg, { allowCloud, model: routedModel });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Choose a route for a task attempt and build the appropriate ProviderClient.
|
|
300
|
+
*
|
|
301
|
+
* On success: returns {client, decision}.
|
|
302
|
+
* On any error (router absent, provider down): falls back to the run-level
|
|
303
|
+
* client and returns a synthetic local RouteDecision with reason 'fallback'.
|
|
304
|
+
*
|
|
305
|
+
* GUARDRAIL: cloud routes only when allowCloud && lastReason !== 'none' && key present.
|
|
306
|
+
* This is enforced by chooseRoute itself; we never bypass it.
|
|
307
|
+
*/
|
|
308
|
+
async function routeTask(taskGoal, cfg, opts, fallbackClient) {
|
|
309
|
+
const router = await loadRouter();
|
|
310
|
+
if (!router) {
|
|
311
|
+
// Pre-M15 build or router unavailable — use the run-level client as-is.
|
|
312
|
+
return {
|
|
313
|
+
client: fallbackClient,
|
|
314
|
+
decision: {
|
|
315
|
+
provider: fallbackClient.id,
|
|
316
|
+
model: process.env['ASHLR_MODEL'] ?? 'default',
|
|
317
|
+
tier: 'local',
|
|
318
|
+
reason: 'router unavailable — local-first fallback',
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
const decision = await router.chooseRoute(taskGoal, cfg, opts);
|
|
324
|
+
const client = await buildRoutedClient(decision, cfg, opts.allowCloud);
|
|
325
|
+
return { client, decision };
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// chooseRoute or buildRoutedClient failed — use fallback client.
|
|
329
|
+
return {
|
|
330
|
+
client: fallbackClient,
|
|
331
|
+
decision: {
|
|
332
|
+
provider: fallbackClient.id,
|
|
333
|
+
model: process.env['ASHLR_MODEL'] ?? 'default',
|
|
334
|
+
tier: 'local',
|
|
335
|
+
reason: 'route error — local-first fallback',
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
// Planning
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
/** Prompt template for decomposing a goal into a task DAG. */
|
|
344
|
+
const PLANNING_SYSTEM = `You are a task planner. Decompose the user's goal into 1-6 subtasks that together accomplish it.
|
|
345
|
+
Respond ONLY with a JSON array. Each element must have:
|
|
346
|
+
"id": string (unique short slug, e.g. "t1", "t2"),
|
|
347
|
+
"goal": string (clear sub-goal for this task),
|
|
348
|
+
"deps": string[] (ids of tasks that must complete before this one; empty for root tasks)
|
|
349
|
+
|
|
350
|
+
Rules:
|
|
351
|
+
- deps must reference earlier ids only (no cycles).
|
|
352
|
+
- Keep tasks focused and independently executable.
|
|
353
|
+
- Use a minimal number of tasks (don't over-decompose).
|
|
354
|
+
|
|
355
|
+
Example:
|
|
356
|
+
[
|
|
357
|
+
{"id":"t1","goal":"Research the topic","deps":[]},
|
|
358
|
+
{"id":"t2","goal":"Summarize findings","deps":["t1"]}
|
|
359
|
+
]
|
|
360
|
+
|
|
361
|
+
Return ONLY the JSON array — no prose, no markdown fences.`;
|
|
362
|
+
/**
|
|
363
|
+
* Parse a RunTask[] from model output, tolerating prose wrapped around JSON.
|
|
364
|
+
* Returns null if no valid JSON array of tasks is found.
|
|
365
|
+
*/
|
|
366
|
+
function parseTaskList(text) {
|
|
367
|
+
// Try to find a JSON array in the output (tolerate leading/trailing prose)
|
|
368
|
+
const match = text.match(/\[[\s\S]*\]/);
|
|
369
|
+
if (!match)
|
|
370
|
+
return null;
|
|
371
|
+
let parsed;
|
|
372
|
+
try {
|
|
373
|
+
parsed = JSON.parse(match[0]);
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
if (!Array.isArray(parsed) || parsed.length === 0)
|
|
379
|
+
return null;
|
|
380
|
+
const tasks = [];
|
|
381
|
+
const seenIds = new Set();
|
|
382
|
+
for (const item of parsed) {
|
|
383
|
+
if (typeof item !== 'object' || item === null)
|
|
384
|
+
return null;
|
|
385
|
+
const obj = item;
|
|
386
|
+
const id = typeof obj['id'] === 'string' ? obj['id'].trim() : null;
|
|
387
|
+
const goal = typeof obj['goal'] === 'string' ? obj['goal'].trim() : null;
|
|
388
|
+
if (!id || !goal)
|
|
389
|
+
return null;
|
|
390
|
+
if (seenIds.has(id))
|
|
391
|
+
return null; // duplicate id
|
|
392
|
+
seenIds.add(id);
|
|
393
|
+
const rawDeps = Array.isArray(obj['deps']) ? obj['deps'] : [];
|
|
394
|
+
const deps = rawDeps.filter((d) => typeof d === 'string');
|
|
395
|
+
tasks.push({
|
|
396
|
+
id,
|
|
397
|
+
goal,
|
|
398
|
+
deps,
|
|
399
|
+
status: 'pending',
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
// Validate deps reference only known ids (no forward deps that are cycles)
|
|
403
|
+
const taskIds = new Set(tasks.map((t) => t.id));
|
|
404
|
+
for (const task of tasks) {
|
|
405
|
+
for (const dep of task.deps) {
|
|
406
|
+
if (!taskIds.has(dep))
|
|
407
|
+
return null; // unknown dep
|
|
408
|
+
if (dep === task.id)
|
|
409
|
+
return null; // self-dep
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// Reject multi-node cycles (e.g. t1->t2->t1). A cycle is a broken plan: at
|
|
413
|
+
// runtime it would otherwise be silently swallowed as 'skipped' tasks after
|
|
414
|
+
// the planning call was already charged. Returning null surfaces it as a
|
|
415
|
+
// plan-parse failure so planGoal falls back to the single-task plan.
|
|
416
|
+
if (hasCycle(tasks))
|
|
417
|
+
return null;
|
|
418
|
+
return tasks.length > 0 ? tasks : null;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* DFS-based cycle detection over the task DAG (deps are edges dep -> task).
|
|
422
|
+
* Returns true if any cycle exists.
|
|
423
|
+
*/
|
|
424
|
+
function hasCycle(tasks) {
|
|
425
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
426
|
+
const VISITING = 1;
|
|
427
|
+
const DONE = 2;
|
|
428
|
+
const mark = new Map();
|
|
429
|
+
const visit = (id) => {
|
|
430
|
+
const cur = mark.get(id);
|
|
431
|
+
if (cur === VISITING)
|
|
432
|
+
return true; // back-edge -> cycle
|
|
433
|
+
if (cur === DONE)
|
|
434
|
+
return false;
|
|
435
|
+
mark.set(id, VISITING);
|
|
436
|
+
const task = byId.get(id);
|
|
437
|
+
if (task) {
|
|
438
|
+
for (const dep of task.deps) {
|
|
439
|
+
if (byId.has(dep) && visit(dep))
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
mark.set(id, DONE);
|
|
444
|
+
return false;
|
|
445
|
+
};
|
|
446
|
+
for (const t of tasks) {
|
|
447
|
+
if (visit(t.id))
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Planning call: ask the model to decompose `goal` into a RunTask[] DAG.
|
|
454
|
+
* Falls back to a single task whose goal is the original goal on parse failure.
|
|
455
|
+
*
|
|
456
|
+
* @param memoryContext Optional genome memory block to prepend to the system prompt.
|
|
457
|
+
* When non-empty, injects "Relevant project memory:" context so the planner
|
|
458
|
+
* benefits from cross-project knowledge. Kept bounded upstream (GENOME_INJECT_CHAR_CAP).
|
|
459
|
+
*/
|
|
460
|
+
export async function planGoal(goal, client, onUsage, memoryContext) {
|
|
461
|
+
// Prepend memory block when present (bounded by caller)
|
|
462
|
+
const systemContent = memoryContext && memoryContext.length > 0
|
|
463
|
+
? `${memoryContext}\n\n${PLANNING_SYSTEM}`
|
|
464
|
+
: PLANNING_SYSTEM;
|
|
465
|
+
const messages = [
|
|
466
|
+
{ role: 'system', content: systemContent },
|
|
467
|
+
{ role: 'user', content: goal },
|
|
468
|
+
];
|
|
469
|
+
let result;
|
|
470
|
+
try {
|
|
471
|
+
result = await client.chat(messages);
|
|
472
|
+
}
|
|
473
|
+
catch (err) {
|
|
474
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
475
|
+
process.stderr.write(`[ashlr run] planning call failed: ${msg} — using single-task fallback\n`);
|
|
476
|
+
return [{ id: 't1', goal, deps: [], status: 'pending' }];
|
|
477
|
+
}
|
|
478
|
+
// Report planning-call usage so the orchestrator can charge it to the budget
|
|
479
|
+
// and the cost summary. Best-effort: a failed plan call (handled above)
|
|
480
|
+
// reports nothing.
|
|
481
|
+
if (onUsage)
|
|
482
|
+
onUsage({ tokensIn: result.usage.tokensIn, tokensOut: result.usage.tokensOut });
|
|
483
|
+
const parsed = parseTaskList(result.content);
|
|
484
|
+
if (!parsed) {
|
|
485
|
+
process.stderr.write(`[ashlr run] could not parse task list from planning response — using single-task fallback\n`);
|
|
486
|
+
return [{ id: 't1', goal, deps: [], status: 'pending' }];
|
|
487
|
+
}
|
|
488
|
+
return parsed;
|
|
489
|
+
}
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// Synthesis
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
const SYNTHESIS_SYSTEM = `You are a helpful assistant. The user asked a goal and several subtasks were executed to answer it.
|
|
494
|
+
Combine the results into a single, coherent final answer. Be concise and accurate.`;
|
|
495
|
+
/**
|
|
496
|
+
* Synthesize a final answer from completed task results.
|
|
497
|
+
* Returns a best-effort string even if the model call fails.
|
|
498
|
+
*/
|
|
499
|
+
async function synthesize(goal, tasks, client) {
|
|
500
|
+
const doneTasks = tasks.filter((t) => t.status === 'done' && t.result);
|
|
501
|
+
if (doneTasks.length === 0) {
|
|
502
|
+
return {
|
|
503
|
+
content: 'No tasks completed successfully — no result to synthesize.',
|
|
504
|
+
usage: { tokensIn: 0, tokensOut: 0 },
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
const taskSummary = doneTasks
|
|
508
|
+
.map((t) => `### ${t.id}: ${t.goal}\n${t.result ?? '(no result)'}`)
|
|
509
|
+
.join('\n\n');
|
|
510
|
+
const messages = [
|
|
511
|
+
{ role: 'system', content: SYNTHESIS_SYSTEM },
|
|
512
|
+
{
|
|
513
|
+
role: 'user',
|
|
514
|
+
content: `Goal: ${goal}\n\nTask results:\n\n${taskSummary}\n\nPlease synthesize a final answer.`,
|
|
515
|
+
},
|
|
516
|
+
];
|
|
517
|
+
try {
|
|
518
|
+
const res = await client.chat(messages);
|
|
519
|
+
return { content: res.content, usage: res.usage };
|
|
520
|
+
}
|
|
521
|
+
catch (err) {
|
|
522
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
523
|
+
// Best-effort fallback: concatenate task results
|
|
524
|
+
const fallback = doneTasks.map((t) => `[${t.id}] ${t.result ?? ''}`).join('\n');
|
|
525
|
+
process.stderr.write(`[ashlr run] synthesis call failed: ${msg} — using concatenated fallback\n`);
|
|
526
|
+
return { content: fallback, usage: { tokensIn: 0, tokensOut: 0 } };
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
// DAG execution helpers
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
/**
|
|
533
|
+
* Returns all tasks that are ready to run (pending + all deps done).
|
|
534
|
+
*/
|
|
535
|
+
function readyTasks(tasks) {
|
|
536
|
+
const doneIds = new Set(tasks.filter((t) => t.status === 'done').map((t) => t.id));
|
|
537
|
+
return tasks.filter((t) => t.status === 'pending' && t.deps.every((dep) => doneIds.has(dep)));
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Returns true when all tasks are in a terminal state (done/failed/skipped/aborted).
|
|
541
|
+
*/
|
|
542
|
+
function allTerminal(tasks) {
|
|
543
|
+
const terminal = ['done', 'failed', 'skipped'];
|
|
544
|
+
return tasks.every((t) => terminal.includes(t.status));
|
|
545
|
+
}
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
// M19: Telemetry emit + governance (best-effort, fire-and-forget, opt-in)
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
/**
|
|
550
|
+
* Fire-and-forget OTLP/local telemetry emit for a completed run.
|
|
551
|
+
*
|
|
552
|
+
* Dynamically imports core/observability/telemetry-sink.ts and
|
|
553
|
+
* core/observability/otlp.ts so this file compiles even before those modules
|
|
554
|
+
* exist in the build. Only emits when both modules are available; all failures
|
|
555
|
+
* are logged to stderr and never thrown to the caller. Never blocks the run.
|
|
556
|
+
* METADATA ONLY — spans carry model/token/cost/ids/status; never prompts,
|
|
557
|
+
* completions, tool args, file contents, or secrets.
|
|
558
|
+
*/
|
|
559
|
+
async function fireEmitRun(state, cfg) {
|
|
560
|
+
await (async () => {
|
|
561
|
+
try {
|
|
562
|
+
// Lazy-import the telemetry seam so the orchestrator core has no hard
|
|
563
|
+
// dependency on it at module-load time (keeps the hot path lean and the
|
|
564
|
+
// emit fully best-effort). Both modules are real and fully typed.
|
|
565
|
+
const [sinkMod, otlpMod] = await Promise.all([
|
|
566
|
+
import('../observability/telemetry-sink.js'),
|
|
567
|
+
import('../observability/otlp.js'),
|
|
568
|
+
]);
|
|
569
|
+
if (typeof sinkMod.getSink !== 'function' ||
|
|
570
|
+
typeof otlpMod.spansFromRun !== 'function') {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
// allowPhantomProbe:false — never run a blocking spawnSync phantom probe
|
|
574
|
+
// on the run completion path; OtlpHttpSink resolves the PAT async/bounded.
|
|
575
|
+
const telSink = sinkMod.getSink(cfg, false);
|
|
576
|
+
const spans = otlpMod.spansFromRun(state);
|
|
577
|
+
const result = await telSink.emit(spans);
|
|
578
|
+
if (!result.ok) {
|
|
579
|
+
process.stderr.write(`[ashlr run] telemetry: emit failed — ${result.detail ?? 'unknown'}\n`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
catch (err) {
|
|
583
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
584
|
+
process.stderr.write(`[ashlr run] telemetry: best-effort emit failed — ${msg}\n`);
|
|
585
|
+
}
|
|
586
|
+
})();
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Evaluate spend governance and return a blocking reason string when
|
|
590
|
+
* govAction==='block' AND level==='over' AND --over-budget was not passed.
|
|
591
|
+
* Prints a prominent advisory when level is 'warn' or 'over'. Never throws.
|
|
592
|
+
* Returns null to proceed normally.
|
|
593
|
+
*/
|
|
594
|
+
async function checkGovernance(cfg, overBudgetFlag) {
|
|
595
|
+
try {
|
|
596
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
597
|
+
const govMod = await import('../observability/governance.js');
|
|
598
|
+
if (typeof govMod.evalGovernance !== 'function')
|
|
599
|
+
return null;
|
|
600
|
+
const verdict = govMod.evalGovernance(cfg);
|
|
601
|
+
if (verdict.level === 'over') {
|
|
602
|
+
process.stderr.write(`\n[ashlr run] SPEND GOVERNANCE OVER-CAP: ${verdict.message}\n\n`);
|
|
603
|
+
if (cfg.telemetry?.govAction === 'block' && !overBudgetFlag) {
|
|
604
|
+
return (`Run blocked by spend governance: ${verdict.message} ` +
|
|
605
|
+
`Pass --over-budget to proceed.`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
else if (verdict.level === 'warn') {
|
|
609
|
+
process.stderr.write(`\n[ashlr run] SPEND GOVERNANCE WARNING: ${verdict.message}\n\n`);
|
|
610
|
+
}
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
// Governance must never block a run on error.
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
// ---------------------------------------------------------------------------
|
|
619
|
+
// Engine delegation helpers
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
/**
|
|
622
|
+
* Check if a binary is installed by probing PATH via `which`.
|
|
623
|
+
* Uses the top-level execFileSync import (Node builtin, ESM-safe).
|
|
624
|
+
* Kept for non-engine-id fallback detection (e.g. arbitrary string engines).
|
|
625
|
+
*/
|
|
626
|
+
function isBinaryInstalled(name) {
|
|
627
|
+
try {
|
|
628
|
+
execFileSync('which', [name], { stdio: 'ignore' });
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Emit a RunStreamEvent via the sink. Never throws.
|
|
637
|
+
*/
|
|
638
|
+
function emit(sink, event) {
|
|
639
|
+
try {
|
|
640
|
+
sink({ ...event, ts: new Date().toISOString() });
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
// Sinks must never crash the run.
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
/** Known engine ids (typed subset). */
|
|
647
|
+
const KNOWN_ENGINE_IDS = new Set(['builtin', 'ashlrcode', 'aw', 'claude']);
|
|
648
|
+
// ---------------------------------------------------------------------------
|
|
649
|
+
// Main: runGoal
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
/**
|
|
652
|
+
* Top-level orchestrator driver. Builds/loads RunState, plans (unless resuming),
|
|
653
|
+
* executes the DAG with parallelism up to opts.parallel, enforces HARD budget,
|
|
654
|
+
* persists after every step, synthesizes final answer, best-effort Pulse POST.
|
|
655
|
+
*
|
|
656
|
+
* M7: genome-aware. Before planning, recalls top-k genome hits for the goal
|
|
657
|
+
* and injects them as context into the planning prompt — bounded, local-only,
|
|
658
|
+
* best-effort. Disabled via opts.noMemory or cfg.genome?.injectOnRun === false.
|
|
659
|
+
*/
|
|
660
|
+
export async function runGoal(goal, cfg, opts) {
|
|
661
|
+
// Optional CLI progress hook. The CLI (src/cli/run.ts) attaches a non-typed
|
|
662
|
+
// __onStep property to opts to receive live per-step progress. We read it off
|
|
663
|
+
// here and invoke it after each persisted step (model/plan/synthesize). It is
|
|
664
|
+
// best-effort: it must never crash the run.
|
|
665
|
+
const rawCliOnStep = opts.__onStep;
|
|
666
|
+
const cliOnStep = typeof rawCliOnStep === 'function'
|
|
667
|
+
? (step, tasks) => {
|
|
668
|
+
try {
|
|
669
|
+
rawCliOnStep(step, tasks);
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
// Progress reporting must never break the run.
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
: undefined;
|
|
676
|
+
// M11: read __sink (StreamSink) from opts. The CLI attaches it for live progress.
|
|
677
|
+
// Falls back to nullSink() when absent (non-TTY, tests, --no-stream).
|
|
678
|
+
const rawSink = opts.__sink;
|
|
679
|
+
const sink = typeof rawSink === 'function' ? rawSink : nullSink();
|
|
680
|
+
// M11: opt-in model verification. Default OFF → the per-task verify step is
|
|
681
|
+
// heuristic-only, charging NO extra model calls (preserves M4 deterministic
|
|
682
|
+
// usage accounting). When enabled, verifyTask may make one cheap model call
|
|
683
|
+
// per task (and one verify-driven retry) under the global budget.
|
|
684
|
+
const verifyModel = opts.verifyModel === true;
|
|
685
|
+
// M7: read noMemory from opts. Not yet typed in RunOptions (avoid editing
|
|
686
|
+
// types.ts) — read as an extended property, same pattern as __onStep above.
|
|
687
|
+
const noMemory = opts.noMemory === true;
|
|
688
|
+
// -- Resume short-circuit (M10 fix: must run BEFORE engine delegation) -------
|
|
689
|
+
// When --resume is requested we must NEVER delegate to an external engine —
|
|
690
|
+
// the run was already started by whichever engine created it, and resuming
|
|
691
|
+
// means continuing with the builtin executor against the persisted state.
|
|
692
|
+
// Previously the engine-delegation block ran first, so
|
|
693
|
+
// `run --engine ashlrcode --resume <id>` would re-run via ashlrcode instead
|
|
694
|
+
// of resuming. Now we handle all resume guards here, before engine selection:
|
|
695
|
+
// 1. Not found → throw immediately.
|
|
696
|
+
// 2. Already complete → return early (no-op).
|
|
697
|
+
// 3. Incomplete → fall through with opts.resumeId set; engine selection
|
|
698
|
+
// below skips delegation because we override engine to 'builtin'.
|
|
699
|
+
if (opts.resumeId) {
|
|
700
|
+
const existingForResume = loadRun(opts.resumeId);
|
|
701
|
+
if (!existingForResume) {
|
|
702
|
+
throw new Error(`Run "${opts.resumeId}" not found in ${runsDir()}`);
|
|
703
|
+
}
|
|
704
|
+
if (existingForResume.status === 'done' && existingForResume.result) {
|
|
705
|
+
process.stderr.write(`[ashlr run] run ${existingForResume.id} is already complete — nothing to resume\n`);
|
|
706
|
+
return existingForResume;
|
|
707
|
+
}
|
|
708
|
+
// Incomplete resume: force builtin so engine delegation is skipped.
|
|
709
|
+
// The full state reload / task-reset happens in the "Load or create
|
|
710
|
+
// RunState" block further below.
|
|
711
|
+
opts = { ...opts, engine: 'builtin' };
|
|
712
|
+
}
|
|
713
|
+
// -- Engine selection --------------------------------------------------------
|
|
714
|
+
const requestedEngine = opts.engine ?? 'builtin';
|
|
715
|
+
let engine = requestedEngine;
|
|
716
|
+
if (engine !== 'builtin') {
|
|
717
|
+
// Determine if this is a known typed engine id or an arbitrary binary name.
|
|
718
|
+
const isKnownEngineId = KNOWN_ENGINE_IDS.has(engine);
|
|
719
|
+
const engineId = isKnownEngineId ? engine : 'ashlrcode'; // arbitrary → treat as external
|
|
720
|
+
// Check installation: for known ids use engineInstalled(); for arbitrary names use isBinaryInstalled().
|
|
721
|
+
const installed = isKnownEngineId
|
|
722
|
+
? engineInstalled(engineId)
|
|
723
|
+
: isBinaryInstalled(engine);
|
|
724
|
+
if (!installed) {
|
|
725
|
+
process.stderr.write(`[ashlr run] engine "${engine}" not found on PATH — falling back to builtin\n`);
|
|
726
|
+
emit(sink, { kind: 'log', text: `engine "${engine}" not found — falling back to builtin` });
|
|
727
|
+
engine = 'builtin';
|
|
728
|
+
}
|
|
729
|
+
else {
|
|
730
|
+
// Delegate to the external engine via the hardened per-engine adapter.
|
|
731
|
+
// buildEngineCommand produces the EXACT argv for the real CLI.
|
|
732
|
+
// spawnEngine applies withToolEnv(cfg) + phantom-exec wrap when enabled.
|
|
733
|
+
// This is a SINGLE BOUNDED SPAWN — never recursive.
|
|
734
|
+
const modelEnv = process.env['ASHLR_MODEL'] ?? process.env['AC_MODEL'];
|
|
735
|
+
// Honor opts.cwd (e.g. a swarm task's target project dir) so the engine
|
|
736
|
+
// spawns WITHIN the intended project, not wherever the parent launched.
|
|
737
|
+
// Validate it is an existing directory before use; fall back to cwd.
|
|
738
|
+
let cwd = process.cwd();
|
|
739
|
+
if (opts.cwd) {
|
|
740
|
+
try {
|
|
741
|
+
if (path.isAbsolute(opts.cwd) &&
|
|
742
|
+
fs.existsSync(opts.cwd) &&
|
|
743
|
+
fs.statSync(opts.cwd).isDirectory()) {
|
|
744
|
+
cwd = opts.cwd;
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
747
|
+
process.stderr.write(`[ashlr run] opts.cwd "${opts.cwd}" is not an existing absolute directory — using ${cwd}\n`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
catch {
|
|
751
|
+
// stat failed — keep the default cwd
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
// Build the correct command for known engine ids; for unknown use the
|
|
755
|
+
// old-style fallback (engine binary not in KNOWN_ENGINE_IDS was already
|
|
756
|
+
// handled above via isBinaryInstalled, so this branch is only reached
|
|
757
|
+
// for known ids).
|
|
758
|
+
const cmd = isKnownEngineId
|
|
759
|
+
? buildEngineCommand(engineId, goal, cfg, { cwd, model: modelEnv })
|
|
760
|
+
: null;
|
|
761
|
+
if (!cmd) {
|
|
762
|
+
// buildEngineCommand returned null (builtin) — fall through to builtin path.
|
|
763
|
+
engine = 'builtin';
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
process.stderr.write(`[ashlr run] delegating to engine "${engine}" (${goal.slice(0, 60)}…)\n`);
|
|
767
|
+
emit(sink, { kind: 'log', text: `delegating to engine "${engine}"` });
|
|
768
|
+
const id = generateRunId();
|
|
769
|
+
const now = new Date().toISOString();
|
|
770
|
+
const delegatedState = {
|
|
771
|
+
id,
|
|
772
|
+
goal,
|
|
773
|
+
engine,
|
|
774
|
+
provider: 'external',
|
|
775
|
+
createdAt: now,
|
|
776
|
+
updatedAt: now,
|
|
777
|
+
budget: {
|
|
778
|
+
maxTokens: opts.budget?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
779
|
+
maxSteps: opts.budget?.maxSteps ?? DEFAULT_MAX_STEPS,
|
|
780
|
+
allowCloud: opts.allowCloud ?? false,
|
|
781
|
+
},
|
|
782
|
+
usage: newUsage(),
|
|
783
|
+
tasks: [],
|
|
784
|
+
steps: [],
|
|
785
|
+
status: 'running',
|
|
786
|
+
};
|
|
787
|
+
// spawnEngine: applies withToolEnv(cfg) + phantom-exec when enabled.
|
|
788
|
+
const engineResult = spawnEngine(cmd, cfg);
|
|
789
|
+
if (!engineResult.ok) {
|
|
790
|
+
const errMsg = engineResult.error ?? 'unknown error';
|
|
791
|
+
process.stderr.write(`[ashlr run] engine "${engine}" failed: ${errMsg}\n`);
|
|
792
|
+
emit(sink, { kind: 'log', text: `engine "${engine}" failed: ${errMsg}` });
|
|
793
|
+
delegatedState.status = 'failed';
|
|
794
|
+
delegatedState.result = `Engine "${engine}" failed: ${errMsg}`;
|
|
795
|
+
delegatedState.updatedAt = new Date().toISOString();
|
|
796
|
+
saveRun(delegatedState);
|
|
797
|
+
return delegatedState;
|
|
798
|
+
}
|
|
799
|
+
// Account for reported usage (e.g. claude --output-format json carries tokens).
|
|
800
|
+
if (engineResult.usage) {
|
|
801
|
+
delegatedState.usage.tokensIn = engineResult.usage.tokensIn;
|
|
802
|
+
delegatedState.usage.tokensOut = engineResult.usage.tokensOut;
|
|
803
|
+
delegatedState.usage.steps = 1;
|
|
804
|
+
delegatedState.usage.estCostUsd = estCostUsd(engine, engineResult.usage.tokensIn, engineResult.usage.tokensOut);
|
|
805
|
+
}
|
|
806
|
+
delegatedState.status = 'done';
|
|
807
|
+
delegatedState.result = engineResult.output;
|
|
808
|
+
delegatedState.updatedAt = new Date().toISOString();
|
|
809
|
+
emit(sink, { kind: 'task-done', text: `engine "${engine}" completed` });
|
|
810
|
+
saveRun(delegatedState);
|
|
811
|
+
return delegatedState;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
// Suppress unused-import warning for withToolEnv (still used by engines.ts indirectly;
|
|
816
|
+
// kept here for the M10 env-bridge contract — callers outside this file use it too).
|
|
817
|
+
void withToolEnv;
|
|
818
|
+
// -- M19: Spend governance check (advisory; block only when govAction==='block') --
|
|
819
|
+
// Read the --over-budget flag the same way noMemory/noCapture are read: as an
|
|
820
|
+
// extended property on opts (not yet in the typed RunOptions interface).
|
|
821
|
+
const overBudgetFlag = opts.overBudget === true;
|
|
822
|
+
const govBlock = await checkGovernance(cfg, overBudgetFlag);
|
|
823
|
+
if (govBlock !== null) {
|
|
824
|
+
const now = new Date().toISOString();
|
|
825
|
+
const blockState = {
|
|
826
|
+
id: generateRunId(),
|
|
827
|
+
goal,
|
|
828
|
+
engine: 'builtin',
|
|
829
|
+
provider: 'none',
|
|
830
|
+
createdAt: now,
|
|
831
|
+
updatedAt: now,
|
|
832
|
+
budget: {
|
|
833
|
+
maxTokens: opts.budget?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
834
|
+
maxSteps: opts.budget?.maxSteps ?? DEFAULT_MAX_STEPS,
|
|
835
|
+
allowCloud: opts.allowCloud ?? false,
|
|
836
|
+
},
|
|
837
|
+
usage: newUsage(),
|
|
838
|
+
tasks: [],
|
|
839
|
+
steps: [],
|
|
840
|
+
status: 'failed',
|
|
841
|
+
result: govBlock,
|
|
842
|
+
};
|
|
843
|
+
process.stderr.write(`[ashlr run] ${govBlock}\n`);
|
|
844
|
+
return blockState;
|
|
845
|
+
}
|
|
846
|
+
// -- Budget / parallel defaults ----------------------------------------------
|
|
847
|
+
const allowCloud = opts.allowCloud ?? false;
|
|
848
|
+
const budget = {
|
|
849
|
+
maxTokens: opts.budget?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
850
|
+
maxSteps: opts.budget?.maxSteps ?? DEFAULT_MAX_STEPS,
|
|
851
|
+
allowCloud,
|
|
852
|
+
};
|
|
853
|
+
const parallel = Math.max(1, opts.parallel ?? DEFAULT_PARALLEL);
|
|
854
|
+
// -- Resolve provider client -------------------------------------------------
|
|
855
|
+
const client = await getActiveClient(cfg, { allowCloud });
|
|
856
|
+
// -- Load or create RunState -------------------------------------------------
|
|
857
|
+
let state;
|
|
858
|
+
if (opts.resumeId) {
|
|
859
|
+
const existing = loadRun(opts.resumeId);
|
|
860
|
+
if (!existing) {
|
|
861
|
+
throw new Error(`Run "${opts.resumeId}" not found in ${runsDir()}`);
|
|
862
|
+
}
|
|
863
|
+
// Already-complete run: do NOT redo work. Re-running synthesis would
|
|
864
|
+
// double-count usage, append duplicate steps, and re-POST Pulse. Return the
|
|
865
|
+
// loaded state unchanged so `--resume <id>` on a finished run is a no-op.
|
|
866
|
+
if (existing.status === 'done' && existing.result) {
|
|
867
|
+
process.stderr.write(`[ashlr run] run ${existing.id} is already complete — nothing to resume\n`);
|
|
868
|
+
return existing;
|
|
869
|
+
}
|
|
870
|
+
state = {
|
|
871
|
+
...existing,
|
|
872
|
+
status: 'running',
|
|
873
|
+
updatedAt: new Date().toISOString(),
|
|
874
|
+
};
|
|
875
|
+
// Reset tasks that should re-run with the (presumably larger) new budget:
|
|
876
|
+
// - 'running': were mid-flight when the previous invocation stopped.
|
|
877
|
+
// - abort-failures: tasks the budget abort marked 'failed' with the
|
|
878
|
+
// sentinel error. Genuine model failures are left as-is so we don't
|
|
879
|
+
// loop on a deterministically-failing task.
|
|
880
|
+
for (const task of state.tasks) {
|
|
881
|
+
if (task.status === 'running' ||
|
|
882
|
+
(task.status === 'failed' && task.error === ABORT_TASK_ERROR)) {
|
|
883
|
+
task.status = 'pending';
|
|
884
|
+
task.error = undefined;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
saveRun(state);
|
|
888
|
+
process.stderr.write(`[ashlr run] resumed run ${state.id} (${state.tasks.length} tasks)\n`);
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
const id = generateRunId();
|
|
892
|
+
const now = new Date().toISOString();
|
|
893
|
+
state = {
|
|
894
|
+
id,
|
|
895
|
+
goal,
|
|
896
|
+
engine,
|
|
897
|
+
provider: client.id,
|
|
898
|
+
createdAt: now,
|
|
899
|
+
updatedAt: now,
|
|
900
|
+
budget,
|
|
901
|
+
usage: newUsage(),
|
|
902
|
+
tasks: [],
|
|
903
|
+
steps: [],
|
|
904
|
+
status: 'running',
|
|
905
|
+
};
|
|
906
|
+
saveRun(state);
|
|
907
|
+
}
|
|
908
|
+
// -- Tool wiring (optional) --------------------------------------------------
|
|
909
|
+
// When opts.tools !== false, attempt to connect to the MCP gateway as a client.
|
|
910
|
+
// On any failure, continue tool-free with a warning.
|
|
911
|
+
let tools;
|
|
912
|
+
if (opts.tools !== false && client.supportsTools) {
|
|
913
|
+
try {
|
|
914
|
+
tools = await loadGatewayTools(cfg);
|
|
915
|
+
}
|
|
916
|
+
catch (err) {
|
|
917
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
918
|
+
process.stderr.write(`[ashlr run] tool gateway unavailable (${msg}) — continuing tool-free\n`);
|
|
919
|
+
tools = undefined;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
// -- M16/M7: Genome memory injection (best-effort, bounded, local-only) ------
|
|
923
|
+
// M16: prefer a synthesized playbook over raw recall when playbookOnRun is on.
|
|
924
|
+
// Falls back to the existing raw-recall block on any playbook failure.
|
|
925
|
+
// Skipped when: noMemory is set, cfg disables injection, or this is a resume
|
|
926
|
+
// with existing tasks (context was already embedded in those task goals).
|
|
927
|
+
let memoryContext = '';
|
|
928
|
+
const injectOnRun = cfg.genome?.injectOnRun ?? true;
|
|
929
|
+
if (!noMemory && injectOnRun && state.tasks.length === 0) {
|
|
930
|
+
// Only attempt playbook injection when genome is explicitly configured and
|
|
931
|
+
// playbookOnRun is not disabled. When cfg.genome is absent there is nothing
|
|
932
|
+
// to recall, and the playbook module makes local Ollama fetch calls even on
|
|
933
|
+
// an empty recall — which would interfere with scripted fetch mocks in tests
|
|
934
|
+
// and add unnecessary latency in unconfigured environments.
|
|
935
|
+
const playbookOnRun = cfg.genome != null && cfg.genome.playbookOnRun !== false;
|
|
936
|
+
let playbookInjected = false;
|
|
937
|
+
if (playbookOnRun) {
|
|
938
|
+
try {
|
|
939
|
+
// Dynamic import: tolerates the module being absent (pre-M16 build).
|
|
940
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
941
|
+
const pbMod = await import('../genome/playbook.js');
|
|
942
|
+
if (typeof pbMod.buildPlaybook === 'function' &&
|
|
943
|
+
typeof pbMod.playbookText === 'function') {
|
|
944
|
+
const playbook = await pbMod.buildPlaybook(goal, cfg);
|
|
945
|
+
const pbText = pbMod.playbookText(playbook, GENOME_INJECT_CHAR_CAP);
|
|
946
|
+
if (pbText && pbText.length > 0) {
|
|
947
|
+
memoryContext = pbText;
|
|
948
|
+
playbookInjected = true;
|
|
949
|
+
process.stderr.write(`[ashlr run] genome: injecting ${memoryContext.length} chars of playbook context\n`);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
catch {
|
|
954
|
+
// Playbook module absent or failed — fall through to raw recall below.
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (!playbookInjected) {
|
|
958
|
+
// M7 fallback: raw recall injection.
|
|
959
|
+
memoryContext = await buildMemoryBlock(goal, cfg);
|
|
960
|
+
if (memoryContext.length > 0) {
|
|
961
|
+
process.stderr.write(`[ashlr run] genome: injecting ${memoryContext.length} chars of memory context\n`);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
// -- Plan (unless resuming with existing tasks) ------------------------------
|
|
966
|
+
if (state.tasks.length === 0) {
|
|
967
|
+
const planStep = {
|
|
968
|
+
ts: new Date().toISOString(),
|
|
969
|
+
taskId: '__plan__',
|
|
970
|
+
kind: 'plan',
|
|
971
|
+
summary: `Planning: decomposing goal into tasks`,
|
|
972
|
+
};
|
|
973
|
+
state.steps.push(planStep);
|
|
974
|
+
state.updatedAt = planStep.ts;
|
|
975
|
+
saveRun(state);
|
|
976
|
+
let planTokensIn = 0;
|
|
977
|
+
let planTokensOut = 0;
|
|
978
|
+
const tasks = await planGoal(goal, client, (u) => {
|
|
979
|
+
planTokensIn = u.tokensIn;
|
|
980
|
+
planTokensOut = u.tokensOut;
|
|
981
|
+
}, memoryContext || undefined);
|
|
982
|
+
state.tasks = tasks;
|
|
983
|
+
// Charge the planning call to the run budget so usage/cost stay accurate.
|
|
984
|
+
// (Previously the planning tokens were silently discarded.)
|
|
985
|
+
// Accumulate incrementally (price ONLY the planning tokens at the planner's
|
|
986
|
+
// provider) so this is consistent with the per-step accumulation below and
|
|
987
|
+
// never re-prices later task tokens at the planner's provider.
|
|
988
|
+
state.usage.tokensIn += planTokensIn;
|
|
989
|
+
state.usage.tokensOut += planTokensOut;
|
|
990
|
+
state.usage.steps += 1;
|
|
991
|
+
state.usage.estCostUsd += estCostUsd(client.id, planTokensIn, planTokensOut);
|
|
992
|
+
state.updatedAt = new Date().toISOString();
|
|
993
|
+
const planDoneStep = {
|
|
994
|
+
ts: state.updatedAt,
|
|
995
|
+
taskId: '__plan__',
|
|
996
|
+
kind: 'plan',
|
|
997
|
+
summary: `Planned ${tasks.length} task(s): ${tasks.map((t) => t.id).join(', ')}`,
|
|
998
|
+
usage: { tokensIn: planTokensIn, tokensOut: planTokensOut, steps: 1, estCostUsd: 0 },
|
|
999
|
+
};
|
|
1000
|
+
state.steps.push(planDoneStep);
|
|
1001
|
+
cliOnStep?.(planDoneStep, state.tasks);
|
|
1002
|
+
saveRun(state);
|
|
1003
|
+
}
|
|
1004
|
+
// -- DAG execution loop ------------------------------------------------------
|
|
1005
|
+
let aborted = false;
|
|
1006
|
+
while (!allTerminal(state.tasks) && !aborted) {
|
|
1007
|
+
// Check global budget before picking next batch
|
|
1008
|
+
if (overBudget(state.usage, budget)) {
|
|
1009
|
+
aborted = true;
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
1012
|
+
const ready = readyTasks(state.tasks);
|
|
1013
|
+
if (ready.length === 0) {
|
|
1014
|
+
// No ready tasks but not all terminal — means some tasks have deps on
|
|
1015
|
+
// failed/skipped tasks. Mark them skipped.
|
|
1016
|
+
const pendingBlocked = state.tasks.filter((t) => t.status === 'pending');
|
|
1017
|
+
if (pendingBlocked.length > 0) {
|
|
1018
|
+
for (const t of pendingBlocked) {
|
|
1019
|
+
t.status = 'skipped';
|
|
1020
|
+
t.error = 'Dependency failed or was skipped';
|
|
1021
|
+
}
|
|
1022
|
+
state.updatedAt = new Date().toISOString();
|
|
1023
|
+
saveRun(state);
|
|
1024
|
+
}
|
|
1025
|
+
break;
|
|
1026
|
+
}
|
|
1027
|
+
// Run up to `parallel` tasks concurrently
|
|
1028
|
+
const batch = ready.slice(0, parallel);
|
|
1029
|
+
// Mark them running before spawning
|
|
1030
|
+
for (const task of batch) {
|
|
1031
|
+
task.status = 'running';
|
|
1032
|
+
}
|
|
1033
|
+
state.updatedAt = new Date().toISOString();
|
|
1034
|
+
saveRun(state);
|
|
1035
|
+
// Run the batch in parallel; each task must not crash the whole run
|
|
1036
|
+
await Promise.all(batch.map(async (task) => {
|
|
1037
|
+
try {
|
|
1038
|
+
// M11: emit task-start event.
|
|
1039
|
+
emit(sink, { kind: 'task-start', taskId: task.id, text: task.goal });
|
|
1040
|
+
// M15: Choose route for this task (local-first; cloud only when
|
|
1041
|
+
// allowCloud + escalation reason + key present). Best-effort — falls
|
|
1042
|
+
// back to the run-level client when router is unavailable.
|
|
1043
|
+
const { client: taskClient, decision: taskDecision } = await routeTask(task.goal, cfg, { allowCloud, attempt: 1, lastReason: 'none' }, client);
|
|
1044
|
+
emit(sink, {
|
|
1045
|
+
kind: 'log',
|
|
1046
|
+
taskId: task.id,
|
|
1047
|
+
text: `route: ${taskDecision.provider}/${taskDecision.model} [${taskDecision.tier}] — ${taskDecision.reason}`,
|
|
1048
|
+
});
|
|
1049
|
+
// Build per-task onStep callback (single-writer invariant preserved).
|
|
1050
|
+
// M15: cost attribution uses the provider that actually served EACH step.
|
|
1051
|
+
// We ACCUMULATE cost incrementally (+= this step's tokens priced at this
|
|
1052
|
+
// step's provider) rather than recomputing estCostUsd over the cumulative
|
|
1053
|
+
// run-wide totals at the current provider. Recomputing-from-cumulative is
|
|
1054
|
+
// wrong for mixed local+cloud runs: it would re-price an earlier local
|
|
1055
|
+
// task's tokens at a later cloud escalation's rates (over-charging), or
|
|
1056
|
+
// re-price an earlier cloud task's tokens at $0 when a later step is local
|
|
1057
|
+
// (erasing real spend). Incremental accumulation keeps local steps at $0
|
|
1058
|
+
// regardless of any later cloud escalation, and prices cloud escalations
|
|
1059
|
+
// on only the tokens they served.
|
|
1060
|
+
const makeTaskOnStep = (providerForCost) => (step) => {
|
|
1061
|
+
state.steps.push(step);
|
|
1062
|
+
// SINGLE-WRITER INVARIANT: orchestrator is the only mutator of state.usage.
|
|
1063
|
+
if (step.usage) {
|
|
1064
|
+
state.usage.tokensIn += step.usage.tokensIn;
|
|
1065
|
+
state.usage.tokensOut += step.usage.tokensOut;
|
|
1066
|
+
state.usage.steps += step.usage.steps;
|
|
1067
|
+
state.usage.estCostUsd += estCostUsd(providerForCost, step.usage.tokensIn, step.usage.tokensOut);
|
|
1068
|
+
}
|
|
1069
|
+
state.updatedAt = new Date().toISOString();
|
|
1070
|
+
cliOnStep?.(step, state.tasks);
|
|
1071
|
+
saveRun(state);
|
|
1072
|
+
};
|
|
1073
|
+
let taskOnStep = makeTaskOnStep(taskDecision.provider);
|
|
1074
|
+
// M11: Retry policy — bounded, budget-aware.
|
|
1075
|
+
// We retry on transient/tool failures only; hard budget stops are not retryable.
|
|
1076
|
+
const RETRY_POLICY = { maxAttempts: 2, baseDelayMs: 500 };
|
|
1077
|
+
const isRetryable = (err) => {
|
|
1078
|
+
// Don't retry if budget is already exhausted.
|
|
1079
|
+
if (overBudget(state.usage, budget))
|
|
1080
|
+
return false;
|
|
1081
|
+
// Retry on network/transient errors (not on deterministic task failures).
|
|
1082
|
+
if (err instanceof Error) {
|
|
1083
|
+
const msg = err.message.toLowerCase();
|
|
1084
|
+
return (msg.includes('network') ||
|
|
1085
|
+
msg.includes('timeout') ||
|
|
1086
|
+
msg.includes('econnrefused') ||
|
|
1087
|
+
msg.includes('fetch') ||
|
|
1088
|
+
msg.includes('socket'));
|
|
1089
|
+
}
|
|
1090
|
+
return false;
|
|
1091
|
+
};
|
|
1092
|
+
await withRetry(async (attempt) => {
|
|
1093
|
+
if (attempt > 1) {
|
|
1094
|
+
emit(sink, {
|
|
1095
|
+
kind: 'retry',
|
|
1096
|
+
taskId: task.id,
|
|
1097
|
+
text: `attempt ${attempt} of ${RETRY_POLICY.maxAttempts}`,
|
|
1098
|
+
});
|
|
1099
|
+
// Reset task state for re-run on retry.
|
|
1100
|
+
task.status = 'running';
|
|
1101
|
+
task.result = undefined;
|
|
1102
|
+
task.error = undefined;
|
|
1103
|
+
}
|
|
1104
|
+
// M20: bounded self-heal for OOM/rate-limit on model calls.
|
|
1105
|
+
// Opt-out: ASHLR_NO_HEAL skips the wrapper entirely.
|
|
1106
|
+
const noHeal = process.env['ASHLR_NO_HEAL'] === '1';
|
|
1107
|
+
const runWithHeal = async (healAttempt) => {
|
|
1108
|
+
// On heal attempt > 1 with a 'model-downgrade' event the client
|
|
1109
|
+
// was already logged via onHeal; chooseRoute will pick a smaller
|
|
1110
|
+
// model on the next routeTask call if the outer attempt increments,
|
|
1111
|
+
// so we just re-run with the current client here (the heal retry
|
|
1112
|
+
// is bounded by policy.maxRestarts and stays fully local).
|
|
1113
|
+
if (healAttempt > 1) {
|
|
1114
|
+
// Re-route to a smaller local model for the downgrade attempt.
|
|
1115
|
+
// Best-effort: fall back to existing taskClient on any error.
|
|
1116
|
+
try {
|
|
1117
|
+
const { client: smallerClient } = await routeTask(task.goal, cfg, { allowCloud: false, attempt: healAttempt, lastReason: 'none' }, taskClient);
|
|
1118
|
+
task.status = 'running';
|
|
1119
|
+
task.result = undefined;
|
|
1120
|
+
task.error = undefined;
|
|
1121
|
+
await runTask(task, smallerClient, {
|
|
1122
|
+
tools,
|
|
1123
|
+
budget,
|
|
1124
|
+
usage: state.usage,
|
|
1125
|
+
sink,
|
|
1126
|
+
onStep: makeTaskOnStep(smallerClient.id),
|
|
1127
|
+
});
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
catch {
|
|
1131
|
+
// Fall through to original client below.
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
await runTask(task, taskClient, {
|
|
1135
|
+
tools,
|
|
1136
|
+
budget,
|
|
1137
|
+
usage: state.usage,
|
|
1138
|
+
sink,
|
|
1139
|
+
onStep: taskOnStep,
|
|
1140
|
+
});
|
|
1141
|
+
};
|
|
1142
|
+
if (noHeal) {
|
|
1143
|
+
await runTask(task, taskClient, {
|
|
1144
|
+
tools,
|
|
1145
|
+
budget,
|
|
1146
|
+
usage: state.usage,
|
|
1147
|
+
sink,
|
|
1148
|
+
onStep: taskOnStep,
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
else {
|
|
1152
|
+
const healPolicy = defaultHealPolicy();
|
|
1153
|
+
await withHeal(runWithHeal, healPolicy, (event) => {
|
|
1154
|
+
emit(sink, {
|
|
1155
|
+
kind: 'log',
|
|
1156
|
+
taskId: task.id,
|
|
1157
|
+
text: `[self-heal] ${event.kind} attempt ${event.attempt}: ${event.detail}`,
|
|
1158
|
+
});
|
|
1159
|
+
process.stderr.write(`[ashlr run] self-heal(${event.kind}) task ${task.id} attempt ${event.attempt}: ${event.detail}\n`);
|
|
1160
|
+
}, allowCloud).catch((healErr) => {
|
|
1161
|
+
// withHeal exhausted — re-throw so the outer withRetry sees it.
|
|
1162
|
+
throw healErr;
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
// If runTask set status to failed, surface as a throw so withRetry
|
|
1166
|
+
// can decide whether to retry (only on retryable errors).
|
|
1167
|
+
if (task.status === 'failed') {
|
|
1168
|
+
const errMsg = task.error ?? 'task failed';
|
|
1169
|
+
// Only transient errors get retried; model/parsing errors do not.
|
|
1170
|
+
// We check if the error looks retryable before throwing.
|
|
1171
|
+
if (isRetryable(new Error(errMsg))) {
|
|
1172
|
+
throw new Error(errMsg);
|
|
1173
|
+
}
|
|
1174
|
+
// Non-retryable failure: don't throw (withRetry would still catch
|
|
1175
|
+
// and re-throw since isRetryable returns false). Fall through.
|
|
1176
|
+
}
|
|
1177
|
+
}, RETRY_POLICY, isRetryable).catch((err) => {
|
|
1178
|
+
// withRetry exhausted all attempts or got a non-retryable error.
|
|
1179
|
+
// task.status is already 'failed' (set by runTask); just ensure error is set.
|
|
1180
|
+
if (task.status !== 'failed') {
|
|
1181
|
+
task.status = 'failed';
|
|
1182
|
+
task.error = err instanceof Error ? err.message : String(err);
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
// M15: On task failure, attempt ONE escalated routed retry.
|
|
1186
|
+
// Escalation is gated by: allowCloud AND escalate.onFailure AND !overBudget.
|
|
1187
|
+
// chooseRoute enforces the additional cloud-key check; if it returns a
|
|
1188
|
+
// local route again (key absent, allowCloud false, etc.) we just stay local.
|
|
1189
|
+
if (task.status === 'failed' &&
|
|
1190
|
+
allowCloud &&
|
|
1191
|
+
(cfg.models.escalate?.onFailure ?? false) &&
|
|
1192
|
+
!overBudget(state.usage, budget)) {
|
|
1193
|
+
const { client: escalatedClient, decision: escalatedDecision } = await routeTask(task.goal, cfg, { allowCloud, attempt: 2, lastReason: 'task-failed' }, client);
|
|
1194
|
+
// Only actually escalate if chooseRoute returned a DIFFERENT (cloud)
|
|
1195
|
+
// route AND buildRoutedClient was able to construct a client for that
|
|
1196
|
+
// cloud provider. If the cloud client could not be built (key absent,
|
|
1197
|
+
// cloud completions unimplemented), buildRoutedClient falls back to a
|
|
1198
|
+
// LOCAL client whose .id is the local provider — in that case we must
|
|
1199
|
+
// NOT print "escalating to cloud" or charge cloud rates. Cost is
|
|
1200
|
+
// attributed by the ACTUAL client.id, never the intended provider.
|
|
1201
|
+
const cloudEscalated = escalatedDecision.tier === 'cloud' &&
|
|
1202
|
+
escalatedClient.id === escalatedDecision.provider;
|
|
1203
|
+
if (cloudEscalated) {
|
|
1204
|
+
emit(sink, {
|
|
1205
|
+
kind: 'retry',
|
|
1206
|
+
taskId: task.id,
|
|
1207
|
+
text: `escalating to cloud: ${escalatedDecision.provider}/${escalatedDecision.model} — ${escalatedDecision.reason}`,
|
|
1208
|
+
});
|
|
1209
|
+
task.status = 'running';
|
|
1210
|
+
task.result = undefined;
|
|
1211
|
+
task.error = undefined;
|
|
1212
|
+
// Attribute cost to the ACTUAL serving client (cloud here).
|
|
1213
|
+
taskOnStep = makeTaskOnStep(escalatedClient.id);
|
|
1214
|
+
await runTask(task, escalatedClient, {
|
|
1215
|
+
tools,
|
|
1216
|
+
budget,
|
|
1217
|
+
usage: state.usage,
|
|
1218
|
+
sink,
|
|
1219
|
+
onStep: taskOnStep,
|
|
1220
|
+
}).catch((err) => {
|
|
1221
|
+
if (task.status !== 'failed') {
|
|
1222
|
+
task.status = 'failed';
|
|
1223
|
+
task.error = err instanceof Error ? err.message : String(err);
|
|
1224
|
+
}
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
// If escalation could not reach cloud (still local / cloud client
|
|
1228
|
+
// unbuildable), leave task.status as 'failed' — no further action,
|
|
1229
|
+
// no misleading cloud event, no cloud cost.
|
|
1230
|
+
}
|
|
1231
|
+
// M11: Verify completed tasks; one retry on !ok if budget allows.
|
|
1232
|
+
// Skip verify entirely once the run is over budget: a budget abort can
|
|
1233
|
+
// leave a task 'done' with a result annotated by an abort/needs-attention
|
|
1234
|
+
// marker, which the heuristic's error-sentinel check would flag as a
|
|
1235
|
+
// benign false-positive "verify fail". Skipping keeps the abort path
|
|
1236
|
+
// clean (no confusing verify line) and avoids any model call past the
|
|
1237
|
+
// ceiling. (Real verification still runs on every in-budget completion.)
|
|
1238
|
+
if (task.status === 'done' && !overBudget(state.usage, budget)) {
|
|
1239
|
+
const verdict = await verifyTask(task, taskClient, budget, state.usage, {
|
|
1240
|
+
model: verifyModel,
|
|
1241
|
+
});
|
|
1242
|
+
emit(sink, {
|
|
1243
|
+
kind: 'verify',
|
|
1244
|
+
taskId: task.id,
|
|
1245
|
+
text: verdict.reason,
|
|
1246
|
+
data: verdict,
|
|
1247
|
+
});
|
|
1248
|
+
if (!verdict.ok) {
|
|
1249
|
+
if (!overBudget(state.usage, budget)) {
|
|
1250
|
+
// M15: verify-failed escalation path — attempt ONE routed retry.
|
|
1251
|
+
// If allowCloud + escalate.onFailure + key present, chooseRoute
|
|
1252
|
+
// may return a cloud route; otherwise stays local.
|
|
1253
|
+
const { client: verifyRetryClient, decision: verifyRetryDecision } = await routeTask(task.goal, cfg, { allowCloud, attempt: 2, lastReason: 'verify-failed' }, taskClient);
|
|
1254
|
+
// Only treat this as a cloud escalation if the cloud client was
|
|
1255
|
+
// actually built (decision is cloud AND the returned client's id
|
|
1256
|
+
// matches the routed cloud provider). Otherwise buildRoutedClient
|
|
1257
|
+
// fell back to local — keep the event + cost attribution local.
|
|
1258
|
+
const escalatingToCloud = verifyRetryDecision.tier === 'cloud' &&
|
|
1259
|
+
verifyRetryClient.id === verifyRetryDecision.provider;
|
|
1260
|
+
// One verification-driven retry: re-run the task.
|
|
1261
|
+
emit(sink, {
|
|
1262
|
+
kind: 'retry',
|
|
1263
|
+
taskId: task.id,
|
|
1264
|
+
text: escalatingToCloud
|
|
1265
|
+
? `verify failed (${verdict.reason}) — escalating to cloud retry: ${verifyRetryDecision.provider}`
|
|
1266
|
+
: `verify failed (${verdict.reason}) — retrying once`,
|
|
1267
|
+
});
|
|
1268
|
+
task.status = 'running';
|
|
1269
|
+
task.result = undefined;
|
|
1270
|
+
task.error = undefined;
|
|
1271
|
+
// Attribute cost to the ACTUAL serving client (never the intended
|
|
1272
|
+
// provider) so a local fallback stays $0.
|
|
1273
|
+
const verifyRetryOnStep = makeTaskOnStep(verifyRetryClient.id);
|
|
1274
|
+
await runTask(task, verifyRetryClient, {
|
|
1275
|
+
tools,
|
|
1276
|
+
budget,
|
|
1277
|
+
usage: state.usage,
|
|
1278
|
+
sink,
|
|
1279
|
+
onStep: verifyRetryOnStep,
|
|
1280
|
+
});
|
|
1281
|
+
// Re-verify after the retry (best-effort; don't loop).
|
|
1282
|
+
// Cast through string: TS narrowed to 'running' after the assignment above,
|
|
1283
|
+
// but runTask mutates task.status in place so it may be 'done' now.
|
|
1284
|
+
if (task.status === 'done') {
|
|
1285
|
+
const verdict2 = await verifyTask(task, verifyRetryClient, budget, state.usage, {
|
|
1286
|
+
model: verifyModel,
|
|
1287
|
+
});
|
|
1288
|
+
emit(sink, {
|
|
1289
|
+
kind: 'verify',
|
|
1290
|
+
taskId: task.id,
|
|
1291
|
+
text: verdict2.reason,
|
|
1292
|
+
data: verdict2,
|
|
1293
|
+
});
|
|
1294
|
+
if (!verdict2.ok) {
|
|
1295
|
+
// Still failing: annotate result but keep status 'done'.
|
|
1296
|
+
task.result = `[needs-attention: ${verdict2.reason}]\n${task.result ?? ''}`;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
else {
|
|
1301
|
+
// Budget exhausted: annotate but keep status 'done'.
|
|
1302
|
+
task.result = `[needs-attention: ${verdict.reason}]\n${task.result ?? ''}`;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
// M15: latency-threshold escalation (cfg.models.escalate?.latencyMs).
|
|
1307
|
+
// Latency is tracked by checking whether the task took longer than
|
|
1308
|
+
// the configured threshold. We use task.usage.steps as a proxy:
|
|
1309
|
+
// if the task completed but the run-level elapsed since task-start
|
|
1310
|
+
// is not directly available here, we record the threshold check as
|
|
1311
|
+
// informational only — the latency escalation path is a stub that
|
|
1312
|
+
// emits a log event when cfg.models.escalate.latencyMs is set and
|
|
1313
|
+
// the task usage steps are unusually high (>= TASK_STEP_CAP / 2).
|
|
1314
|
+
// Full wall-clock latency tracking can be wired in a follow-up.
|
|
1315
|
+
if (task.status === 'done' &&
|
|
1316
|
+
allowCloud &&
|
|
1317
|
+
cfg.models.escalate?.latencyMs !== undefined &&
|
|
1318
|
+
(task.usage?.steps ?? 0) >= 10 // heuristic: many steps → slow task
|
|
1319
|
+
) {
|
|
1320
|
+
emit(sink, {
|
|
1321
|
+
kind: 'log',
|
|
1322
|
+
taskId: task.id,
|
|
1323
|
+
text: `[M15] task completed with ${task.usage?.steps ?? 0} steps; latency threshold ${cfg.models.escalate.latencyMs}ms configured (cloud escalation on latency available when re-running with --allow-cloud)`,
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
// M11: emit task-done (or failed) event.
|
|
1327
|
+
if (task.status === 'done') {
|
|
1328
|
+
emit(sink, { kind: 'task-done', taskId: task.id, text: task.goal });
|
|
1329
|
+
}
|
|
1330
|
+
else {
|
|
1331
|
+
emit(sink, {
|
|
1332
|
+
kind: 'log',
|
|
1333
|
+
taskId: task.id,
|
|
1334
|
+
text: `task ${task.id} ${task.status}: ${task.error ?? ''}`,
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
catch (err) {
|
|
1339
|
+
// Defensive: runTask should handle its own errors, but catch any leak
|
|
1340
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1341
|
+
task.status = 'failed';
|
|
1342
|
+
task.error = `Unexpected orchestrator error: ${msg}`;
|
|
1343
|
+
process.stderr.write(`[ashlr run] task ${task.id} crashed unexpectedly: ${msg}\n`);
|
|
1344
|
+
}
|
|
1345
|
+
}));
|
|
1346
|
+
state.updatedAt = new Date().toISOString();
|
|
1347
|
+
saveRun(state);
|
|
1348
|
+
// Check budget after batch completes
|
|
1349
|
+
if (overBudget(state.usage, budget)) {
|
|
1350
|
+
aborted = true;
|
|
1351
|
+
break;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
// -- Abort: mark remaining pending/running tasks as aborted ------------------
|
|
1355
|
+
if (aborted) {
|
|
1356
|
+
for (const task of state.tasks) {
|
|
1357
|
+
if (task.status === 'pending' || task.status === 'running') {
|
|
1358
|
+
task.status = 'failed';
|
|
1359
|
+
task.error = ABORT_TASK_ERROR;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
state.status = 'aborted';
|
|
1363
|
+
state.updatedAt = new Date().toISOString();
|
|
1364
|
+
saveRun(state);
|
|
1365
|
+
// M19: Emit telemetry (best-effort, opt-in). Awaited so the local sink is
|
|
1366
|
+
// flushed before the process exits; bounded + fully caught, never throws.
|
|
1367
|
+
await fireEmitRun(state, cfg);
|
|
1368
|
+
// M16: Auto-capture on abort path (fire-and-forget).
|
|
1369
|
+
const noCaptureAbort = opts.noCapture === true;
|
|
1370
|
+
if (!noCaptureAbort) {
|
|
1371
|
+
void (async () => {
|
|
1372
|
+
try {
|
|
1373
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1374
|
+
const capMod = await import('../genome/capture.js');
|
|
1375
|
+
if (typeof capMod.captureFromRun === 'function') {
|
|
1376
|
+
capMod.captureFromRun(state, cfg);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
catch {
|
|
1380
|
+
// Never surface capture errors to the caller.
|
|
1381
|
+
}
|
|
1382
|
+
})();
|
|
1383
|
+
}
|
|
1384
|
+
return state;
|
|
1385
|
+
}
|
|
1386
|
+
// -- Synthesize final answer -------------------------------------------------
|
|
1387
|
+
const synthStep = {
|
|
1388
|
+
ts: new Date().toISOString(),
|
|
1389
|
+
taskId: '__synthesize__',
|
|
1390
|
+
kind: 'synthesize',
|
|
1391
|
+
summary: 'Synthesizing final answer from task results',
|
|
1392
|
+
};
|
|
1393
|
+
state.steps.push(synthStep);
|
|
1394
|
+
state.updatedAt = synthStep.ts;
|
|
1395
|
+
saveRun(state);
|
|
1396
|
+
// Budget guard for synthesis: if the run already hit the ceiling, do NOT
|
|
1397
|
+
// spend another model call. Fall back to concatenating the completed task
|
|
1398
|
+
// results so maxTokens stays a hard ceiling at the synthesis boundary too.
|
|
1399
|
+
let synthResult;
|
|
1400
|
+
let synthUsage;
|
|
1401
|
+
if (overBudget(state.usage, budget)) {
|
|
1402
|
+
const doneTasks = state.tasks.filter((t) => t.status === 'done' && t.result);
|
|
1403
|
+
synthResult =
|
|
1404
|
+
doneTasks.length > 0
|
|
1405
|
+
? doneTasks.map((t) => `[${t.id}] ${t.result ?? ''}`).join('\n')
|
|
1406
|
+
: 'No tasks completed successfully — no result to synthesize.';
|
|
1407
|
+
synthUsage = { tokensIn: 0, tokensOut: 0 };
|
|
1408
|
+
process.stderr.write(`[ashlr run] budget reached — skipping model synthesis, using concatenated task results\n`);
|
|
1409
|
+
}
|
|
1410
|
+
else {
|
|
1411
|
+
const synth = await synthesize(goal, state.tasks, client);
|
|
1412
|
+
synthResult = synth.content;
|
|
1413
|
+
synthUsage = synth.usage;
|
|
1414
|
+
}
|
|
1415
|
+
state.usage.tokensIn += synthUsage.tokensIn;
|
|
1416
|
+
state.usage.tokensOut += synthUsage.tokensOut;
|
|
1417
|
+
state.usage.steps += 1;
|
|
1418
|
+
// Accumulate incrementally (price ONLY the synthesis tokens at the synthesis
|
|
1419
|
+
// provider). Recomputing from cumulative totals at client.id here would CLOBBER
|
|
1420
|
+
// the per-step mixed-provider cost already accumulated by the task loop —
|
|
1421
|
+
// re-pricing earlier cloud-escalation tokens at the local run-level provider
|
|
1422
|
+
// (erasing real spend) or vice-versa.
|
|
1423
|
+
state.usage.estCostUsd += estCostUsd(client.id, synthUsage.tokensIn, synthUsage.tokensOut);
|
|
1424
|
+
const synthDoneStep = {
|
|
1425
|
+
ts: new Date().toISOString(),
|
|
1426
|
+
taskId: '__synthesize__',
|
|
1427
|
+
kind: 'synthesize',
|
|
1428
|
+
summary: 'Synthesis complete',
|
|
1429
|
+
usage: { tokensIn: synthUsage.tokensIn, tokensOut: synthUsage.tokensOut, steps: 1, estCostUsd: 0 },
|
|
1430
|
+
};
|
|
1431
|
+
state.steps.push(synthDoneStep);
|
|
1432
|
+
cliOnStep?.(synthDoneStep, state.tasks);
|
|
1433
|
+
state.result = synthResult;
|
|
1434
|
+
// Determine final status
|
|
1435
|
+
const failedCount = state.tasks.filter((t) => t.status === 'failed').length;
|
|
1436
|
+
state.status = failedCount === state.tasks.length ? 'failed' : 'done';
|
|
1437
|
+
state.updatedAt = new Date().toISOString();
|
|
1438
|
+
saveRun(state);
|
|
1439
|
+
// -- M19: Emit telemetry (best-effort, opt-in) ------------------------------
|
|
1440
|
+
// Awaited so the local sink is flushed before the process exits; bounded +
|
|
1441
|
+
// fully caught, never throws.
|
|
1442
|
+
await fireEmitRun(state, cfg);
|
|
1443
|
+
// -- M16: Auto-capture (fire-and-forget, never throws, never blocks) ---------
|
|
1444
|
+
// Read noCapture via extended property (same pattern as noMemory above).
|
|
1445
|
+
const noCapture = opts.noCapture === true;
|
|
1446
|
+
if (!noCapture) {
|
|
1447
|
+
// Wrap in void + try to guarantee fire-and-forget with zero blocking.
|
|
1448
|
+
void (async () => {
|
|
1449
|
+
try {
|
|
1450
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1451
|
+
const capMod = await import('../genome/capture.js');
|
|
1452
|
+
if (typeof capMod.captureFromRun === 'function') {
|
|
1453
|
+
capMod.captureFromRun(state, cfg);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
catch {
|
|
1457
|
+
// Never surface capture errors to the caller.
|
|
1458
|
+
}
|
|
1459
|
+
})();
|
|
1460
|
+
}
|
|
1461
|
+
return state;
|
|
1462
|
+
}
|
|
1463
|
+
// ---------------------------------------------------------------------------
|
|
1464
|
+
// Gateway tool loading (optional)
|
|
1465
|
+
// ---------------------------------------------------------------------------
|
|
1466
|
+
/**
|
|
1467
|
+
* Attempt to load aggregated tools from the MCP gateway as a client.
|
|
1468
|
+
* Returns the tool list (OpenAI-style tool specs) or throws on failure.
|
|
1469
|
+
* Used only when opts.tools !== false AND client.supportsTools.
|
|
1470
|
+
*/
|
|
1471
|
+
async function loadGatewayTools(cfg) {
|
|
1472
|
+
// Lazy-import MCP SDK to keep startup fast when tools are disabled
|
|
1473
|
+
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
|
1474
|
+
const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js');
|
|
1475
|
+
// Resolve the ashlr binary path from the config tools map, or fall back to PATH
|
|
1476
|
+
const ashlrBin = cfg.tools?.['ashlr'] ?? 'ashlr';
|
|
1477
|
+
const transport = new StdioClientTransport({
|
|
1478
|
+
command: ashlrBin,
|
|
1479
|
+
args: ['mcp'],
|
|
1480
|
+
stderr: 'ignore',
|
|
1481
|
+
});
|
|
1482
|
+
const mcpClient = new Client({ name: 'ashlr-orchestrator', version: '0.1.0' }, { capabilities: {} });
|
|
1483
|
+
// Connect with a 10s timeout
|
|
1484
|
+
const ctrl = new AbortController();
|
|
1485
|
+
const timer = setTimeout(() => ctrl.abort(), 10_000);
|
|
1486
|
+
try {
|
|
1487
|
+
await mcpClient.connect(transport);
|
|
1488
|
+
clearTimeout(timer);
|
|
1489
|
+
const listed = await mcpClient.listTools({}, { timeout: 10_000 });
|
|
1490
|
+
// Convert MCP tool specs to OpenAI-style function specs for the provider
|
|
1491
|
+
const tools = (listed.tools ?? []).map((t) => ({
|
|
1492
|
+
type: 'function',
|
|
1493
|
+
function: {
|
|
1494
|
+
name: t.name,
|
|
1495
|
+
description: t.description ?? t.name,
|
|
1496
|
+
parameters: t.inputSchema ?? { type: 'object', properties: {} },
|
|
1497
|
+
},
|
|
1498
|
+
}));
|
|
1499
|
+
// Close client after fetching — tools are passed as static specs to the model
|
|
1500
|
+
try {
|
|
1501
|
+
await mcpClient.close();
|
|
1502
|
+
}
|
|
1503
|
+
catch { /* ignore */ }
|
|
1504
|
+
return tools;
|
|
1505
|
+
}
|
|
1506
|
+
catch (err) {
|
|
1507
|
+
clearTimeout(timer);
|
|
1508
|
+
try {
|
|
1509
|
+
await mcpClient.close();
|
|
1510
|
+
}
|
|
1511
|
+
catch { /* ignore */ }
|
|
1512
|
+
throw err;
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
//# sourceMappingURL=orchestrator.js.map
|