@cohortapp/agent-sdk 2.17.0 → 2.18.4
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/.claude/settings.json +18 -0
- package/.env.example +18 -5
- package/README.md +1 -0
- package/bin/maestro.mjs +62 -0
- package/docs/guides/billing-console-keys.md +60 -0
- package/docs/guides/front-door-session.md +54 -9
- package/docs/guides/mac-mini.md +20 -25
- package/docs/guides/setup-wizard.md +1 -1
- package/docs/runbooks/fleet-rollout.md +156 -0
- package/docs/runbooks/mac-mini-bootstrap.md +12 -14
- package/lib/action-executor.js +19 -3
- package/lib/budget-guard.mjs +279 -3
- package/lib/channels/base-adapter.mjs +3 -1
- package/lib/channels/contract.mjs +2 -1
- package/lib/channels/inbox-item.mjs +8 -0
- package/lib/claude-bin.mjs +5 -6
- package/lib/cli/doctor-checks.mjs +141 -10
- package/lib/cli/global-setup-extras.mjs +5 -1
- package/lib/cli/inbox.mjs +100 -15
- package/lib/cli/seat-auth.mjs +463 -0
- package/lib/cli/session.mjs +80 -12
- package/lib/collective/capture.mjs +8 -6
- package/lib/collective/global-config.mjs +63 -1
- package/lib/collective/presence.mjs +142 -5
- package/lib/comms/send-gate.mjs +559 -1
- package/lib/diagnostics/alerts.mjs +49 -0
- package/lib/diagnostics/cadence-output-freshness.mjs +288 -0
- package/lib/engine/agents/definitions.mjs +343 -0
- package/lib/engine/agents/persist.mjs +275 -0
- package/lib/engine/agents/runtime.mjs +748 -0
- package/lib/engine/agents/usage.mjs +95 -0
- package/lib/engine/auth-status.mjs +139 -0
- package/lib/engine/budget.mjs +194 -0
- package/lib/engine/cli.mjs +1204 -0
- package/lib/engine/commands/index.mjs +269 -0
- package/lib/engine/context/budget.mjs +219 -0
- package/lib/engine/context/cache.mjs +125 -0
- package/lib/engine/context/child-env.mjs +215 -0
- package/lib/engine/context/compaction.mjs +342 -0
- package/lib/engine/context/images.mjs +90 -0
- package/lib/engine/context/instructions.mjs +327 -0
- package/lib/engine/context/lazy-instructions.mjs +169 -0
- package/lib/engine/context/manager.mjs +182 -0
- package/lib/engine/context/real-path.mjs +91 -0
- package/lib/engine/context/secret-values.mjs +163 -0
- package/lib/engine/context/settings.mjs +274 -0
- package/lib/engine/context/stream-input.mjs +159 -0
- package/lib/engine/guard.mjs +152 -0
- package/lib/engine/hooks.mjs +713 -0
- package/lib/engine/loop.mjs +560 -0
- package/lib/engine/mcp/client.mjs +254 -0
- package/lib/engine/mcp/config.mjs +301 -0
- package/lib/engine/mcp/http.mjs +201 -0
- package/lib/engine/mcp/index.mjs +146 -0
- package/lib/engine/mcp/jsonrpc.mjs +147 -0
- package/lib/engine/mcp/naming.mjs +66 -0
- package/lib/engine/mcp/resources.mjs +89 -0
- package/lib/engine/mcp/results.mjs +133 -0
- package/lib/engine/mcp/stdio.mjs +137 -0
- package/lib/engine/mcp/supervisor.mjs +116 -0
- package/lib/engine/messages.mjs +104 -0
- package/lib/engine/output/json.mjs +164 -0
- package/lib/engine/output/stream-json.mjs +266 -0
- package/lib/engine/permissions.mjs +845 -0
- package/lib/engine/process-identity.mjs +164 -0
- package/lib/engine/process-tree.mjs +551 -0
- package/lib/engine/prompt.mjs +60 -0
- package/lib/engine/session/store.mjs +299 -0
- package/lib/engine/session-runtime/args.mjs +97 -0
- package/lib/engine/session-runtime/host.mjs +143 -0
- package/lib/engine/session-runtime/inbox.mjs +122 -0
- package/lib/engine/session-runtime/notifications.mjs +129 -0
- package/lib/engine/session-runtime/registry.mjs +328 -0
- package/lib/engine/session-runtime/runner.mjs +344 -0
- package/lib/engine/session-runtime/socket.mjs +212 -0
- package/lib/engine/session-runtime/wakeup.mjs +115 -0
- package/lib/engine/skills/index.mjs +321 -0
- package/lib/engine/tools/bash-background.mjs +533 -0
- package/lib/engine/tools/bash.mjs +216 -0
- package/lib/engine/tools/edit.mjs +97 -0
- package/lib/engine/tools/glob.mjs +81 -0
- package/lib/engine/tools/grep.mjs +224 -0
- package/lib/engine/tools/index.mjs +84 -0
- package/lib/engine/tools/list-agents.mjs +32 -0
- package/lib/engine/tools/ls.mjs +127 -0
- package/lib/engine/tools/monitor.mjs +82 -0
- package/lib/engine/tools/notebook-edit.mjs +218 -0
- package/lib/engine/tools/read.mjs +103 -0
- package/lib/engine/tools/schedule-wakeup.mjs +45 -0
- package/lib/engine/tools/schema.mjs +144 -0
- package/lib/engine/tools/send-message.mjs +77 -0
- package/lib/engine/tools/session.mjs +70 -0
- package/lib/engine/tools/todo.mjs +144 -0
- package/lib/engine/tools/toolsearch.mjs +217 -0
- package/lib/engine/tools/walk.mjs +193 -0
- package/lib/engine/tools/web-switch.mjs +31 -0
- package/lib/engine/tools/webfetch-html.mjs +387 -0
- package/lib/engine/tools/webfetch-net.mjs +340 -0
- package/lib/engine/tools/webfetch.mjs +198 -0
- package/lib/engine/tools/websearch.mjs +91 -0
- package/lib/engine/tools/workflow.mjs +95 -0
- package/lib/engine/tools/write.mjs +76 -0
- package/lib/engine/tui/line-editor.mjs +137 -0
- package/lib/engine/tui/render.mjs +86 -0
- package/lib/engine/tui/tui.mjs +274 -0
- package/lib/engine/wire/anthropic-messages.mjs +263 -0
- package/lib/engine/wire/effort.mjs +36 -0
- package/lib/engine/wire/errors.mjs +496 -0
- package/lib/engine/wire/http.mjs +441 -0
- package/lib/engine/wire/index.mjs +76 -0
- package/lib/engine/wire/openai-chat.mjs +332 -0
- package/lib/engine/wire/prompt-cache.mjs +79 -0
- package/lib/engine/wire/search.mjs +140 -0
- package/lib/engine/wire/sse.mjs +114 -0
- package/lib/engine/wire/stall.mjs +349 -0
- package/lib/engine/wire/token-provider.mjs +175 -0
- package/lib/engine/wire/usage.mjs +192 -0
- package/lib/engine/workflow/host.mjs +524 -0
- package/lib/engine/workflow/journal.mjs +188 -0
- package/lib/engine/workflow/json-schema.mjs +171 -0
- package/lib/engine/workflow/meta.mjs +329 -0
- package/lib/engine/workflow/notifications.mjs +52 -0
- package/lib/engine/workflow/runtime.mjs +447 -0
- package/lib/engine/workflow/sandbox.mjs +534 -0
- package/lib/engine/workflow/worker.mjs +141 -0
- package/lib/engine/workflow/worktree.mjs +74 -0
- package/lib/execution/disposition.mjs +1 -1
- package/lib/execution/intake.mjs +10 -0
- package/lib/execution/surface-policy.mjs +15 -0
- package/lib/learning/curator.mjs +8 -6
- package/lib/learning/reflect.mjs +8 -6
- package/lib/model-router/catalog/cohort.yaml +137 -0
- package/lib/model-router/catalog.mjs +118 -1
- package/lib/model-router/failover.mjs +67 -16
- package/lib/model-router/llm-task.mjs +39 -3
- package/lib/model-router/resolve.mjs +89 -3
- package/lib/model-router/spawn.mjs +46 -47
- package/lib/model-router/taxonomy.mjs +126 -4
- package/lib/org/cost-sync.mjs +141 -11
- package/lib/org/inbound/broadcast.mjs +289 -0
- package/lib/org/inbound/collective.mjs +375 -0
- package/lib/org/inbound/directedness.mjs +96 -8
- package/lib/org/inbound/facts.mjs +78 -2
- package/lib/org/inbound/project.mjs +22 -0
- package/lib/org/inbound/surfaces.mjs +14 -0
- package/lib/org/llm-token.mjs +879 -0
- package/lib/org/mesh.mjs +61 -0
- package/lib/org/messaging.mjs +3 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +15 -0
- package/lib/org/quota.mjs +520 -0
- package/lib/org/tool-surface.mjs +104 -16
- package/lib/org/ui-parity.mjs +16 -1
- package/lib/org/work-ledger.mjs +37 -6
- package/lib/rate-guard.mjs +114 -1
- package/lib/resource-governor.mjs +41 -6
- package/lib/runtime/adapter.mjs +823 -0
- package/lib/runtime/child-env.mjs +191 -0
- package/lib/runtime/legacy-shell-guard.mjs +97 -0
- package/lib/runtime/seat-engine.mjs +162 -0
- package/lib/session/ask-ledger.mjs +271 -0
- package/lib/session/current-work.mjs +676 -0
- package/lib/session/feed-core.mjs +40 -3
- package/lib/session/launch-args.mjs +56 -4
- package/lib/session/status-summary.mjs +26 -9
- package/lib/session/upgrade-notice.mjs +42 -0
- package/lib/setup/claude-probe.mjs +117 -13
- package/lib/setup/enrich.mjs +13 -10
- package/lib/setup/sections/model.mjs +39 -13
- package/lib/telemetry/collect.mjs +208 -9
- package/lib/upgrade/ignored-drift.mjs +105 -0
- package/lib/voice/post-call-brief.mjs +30 -17
- package/package.json +13 -3
- package/plugins/maestro-skills/skills/board-work.md +5 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +56 -15
- package/plugins/maestro-skills/skills/main-session.md +18 -7
- package/scripts/ci/check-tarball-fidelity.mjs +126 -2
- package/scripts/ci/run-tests.mjs +47 -19
- package/scripts/cohort-llm/api-key-helper.mjs +92 -0
- package/scripts/collective/hook-runner.mjs +29 -2
- package/scripts/continuous-monitor.sh +13 -0
- package/scripts/cost/track-claude-usage.mjs +15 -0
- package/scripts/daemon/agent-daemon.mjs +408 -20
- package/scripts/daemon/assurance.mjs +48 -12
- package/scripts/daemon/cadence-consumer.mjs +218 -68
- package/scripts/daemon/cadence-handlers.mjs +73 -4
- package/scripts/daemon/classifier.mjs +75 -26
- package/scripts/daemon/context-compiler.mjs +51 -37
- package/scripts/daemon/deliver.mjs +30 -1
- package/scripts/daemon/dispatcher.mjs +595 -149
- package/scripts/daemon/health.mjs +14 -1
- package/scripts/daemon/maestro-daemon.mjs +11 -0
- package/scripts/daemon/prompt-builder.mjs +24 -0
- package/scripts/daemon/responder.mjs +246 -79
- package/scripts/daemon/sdk-version.mjs +98 -16
- package/scripts/eval/probe-gateway.mjs +635 -0
- package/scripts/eval/replay/extract.mjs +270 -0
- package/scripts/eval/replay/grade.mjs +260 -0
- package/scripts/eval/replay/lib/config.mjs +50 -0
- package/scripts/eval/replay/lib/effects.mjs +65 -0
- package/scripts/eval/replay/lib/fixture.mjs +188 -0
- package/scripts/eval/replay/lib/judge.mjs +72 -0
- package/scripts/eval/replay/lib/redact.mjs +136 -0
- package/scripts/eval/replay/lib/sandbox.mjs +170 -0
- package/scripts/eval/replay/lib/schema-check.mjs +63 -0
- package/scripts/eval/replay/lib/transcript.mjs +76 -0
- package/scripts/eval/replay/mcp-replay-stub.mjs +101 -0
- package/scripts/eval/replay/report.mjs +185 -0
- package/scripts/eval/replay/run.mjs +404 -0
- package/scripts/fleet/rollout.mjs +1094 -0
- package/scripts/hooks/pre-send-audit.sh +36 -245
- package/scripts/hooks/pre-write-yaml-validate.mjs +275 -0
- package/scripts/hooks/validate-state-yaml.sh +190 -0
- package/scripts/huddle/huddle-llm.mjs +361 -0
- package/scripts/huddle/huddle-server.mjs +46 -121
- package/scripts/local-triggers/autoupdate.sh +448 -78
- package/scripts/local-triggers/run-trigger.sh +13 -0
- package/scripts/maintenance/pin-integrity.mjs +364 -0
- package/scripts/poll-slack-events.sh +41 -9
- package/scripts/poller/slack-socket-mode.mjs +28 -3
- package/scripts/session/supervisor.mjs +80 -13
- package/scripts/spawn-session.sh +13 -0
- package/bin/maestro.test.mjs +0 -1574
- package/lib/action-executor.test.mjs +0 -871
- package/lib/archetype.test.mjs +0 -132
- package/lib/assurance/plan-note.test.mjs +0 -234
- package/lib/assurance/room-budget.test.mjs +0 -486
- package/lib/assurance/tier.test.mjs +0 -174
- package/lib/autonomy.test.mjs +0 -66
- package/lib/backlog.test.mjs +0 -302
- package/lib/backup/policy.test.mjs +0 -305
- package/lib/budget-escalate.test.mjs +0 -232
- package/lib/budget-guard.envelope.test.mjs +0 -476
- package/lib/budget-guard.test.mjs +0 -427
- package/lib/cadence-bus-requeue.test.mjs +0 -83
- package/lib/cadence-bus-schedule.test.mjs +0 -194
- package/lib/cadence-bus.test.mjs +0 -720
- package/lib/cadences.test.mjs +0 -230
- package/lib/capability/inventory.test.mjs +0 -232
- package/lib/capability.test.mjs +0 -78
- package/lib/channels/base-adapter.test.mjs +0 -590
- package/lib/channels/channels.test.mjs +0 -371
- package/lib/channels/contract.test.mjs +0 -162
- package/lib/channels/inbox-item.test.mjs +0 -368
- package/lib/channels/orgmail/adapter.test.mjs +0 -448
- package/lib/channels/pairing.test.mjs +0 -270
- package/lib/channels/repeat-suppressor.test.mjs +0 -134
- package/lib/channels/slack-adapter.test.mjs +0 -212
- package/lib/channels/telegram-adapter.test.mjs +0 -306
- package/lib/channels/voice/adapter.test.mjs +0 -278
- package/lib/channels/whatsapp/adapter-baileys.test.mjs +0 -359
- package/lib/channels/whatsapp/baileys-typing.test.mjs +0 -154
- package/lib/charter.test.mjs +0 -89
- package/lib/claude-bin.test.mjs +0 -131
- package/lib/cli/board.test.mjs +0 -227
- package/lib/cli/design.test.mjs +0 -270
- package/lib/cli/doctor-checks.test.mjs +0 -336
- package/lib/cli/global-setup-extras.test.mjs +0 -462
- package/lib/cli/inbox.test.mjs +0 -230
- package/lib/cli/session-ack.test.mjs +0 -63
- package/lib/cli/session.test.mjs +0 -613
- package/lib/collective/capture.test.mjs +0 -121
- package/lib/collective/cards.test.mjs +0 -114
- package/lib/collective/config.test.mjs +0 -123
- package/lib/collective/global-config.test.mjs +0 -220
- package/lib/collective/global-skills.test.mjs +0 -126
- package/lib/collective/presence.test.mjs +0 -95
- package/lib/collective/recall.test.mjs +0 -116
- package/lib/collective/vendor-skills.test.mjs +0 -306
- package/lib/comms/send-gate.test.mjs +0 -770
- package/lib/comms.test.mjs +0 -41
- package/lib/context/budget.test.mjs +0 -252
- package/lib/context/history-scope.test.mjs +0 -79
- package/lib/cost/ledger-row.test.mjs +0 -183
- package/lib/design/design-md.test.mjs +0 -318
- package/lib/design/fixtures/DESIGN.golden.md +0 -238
- package/lib/design/fixtures/PRODUCT.golden.md +0 -67
- package/lib/design/fixtures/foundation.json +0 -133
- package/lib/design/refresh-gate.test.mjs +0 -144
- package/lib/design/write.test.mjs +0 -241
- package/lib/diagnostics/alerts.test.mjs +0 -318
- package/lib/diagnostics/backup-freshness.test.mjs +0 -185
- package/lib/diagnostics/counters.test.mjs +0 -206
- package/lib/diagnostics/events.test.mjs +0 -290
- package/lib/diagnostics/otel.test.mjs +0 -196
- package/lib/diagnostics/trace.test.mjs +0 -251
- package/lib/env-compat.test.mjs +0 -104
- package/lib/execution/disposition.test.mjs +0 -553
- package/lib/execution/drive.test.mjs +0 -270
- package/lib/execution/effects.test.mjs +0 -344
- package/lib/execution/intake.test.mjs +0 -389
- package/lib/execution/journal.test.mjs +0 -261
- package/lib/execution/match.test.mjs +0 -235
- package/lib/execution/pipeline.test.mjs +0 -392
- package/lib/execution/route.test.mjs +0 -186
- package/lib/execution/surface-policy.test.mjs +0 -162
- package/lib/fs-atomic.test.mjs +0 -72
- package/lib/fs-ownership.test.mjs +0 -158
- package/lib/goals/admission.test.mjs +0 -164
- package/lib/goals/classify.test.mjs +0 -167
- package/lib/goals/collaborate.test.mjs +0 -336
- package/lib/goals/gaps.test.mjs +0 -284
- package/lib/goals/loop.test.mjs +0 -845
- package/lib/hooks/bus.test.mjs +0 -387
- package/lib/identity/persona.test.mjs +0 -142
- package/lib/kpi-sensors.test.mjs +0 -278
- package/lib/kpi.test.mjs +0 -244
- package/lib/learning/config.test.mjs +0 -75
- package/lib/learning/counters.test.mjs +0 -69
- package/lib/learning/curator-consolidate.test.mjs +0 -238
- package/lib/learning/curator.test.mjs +0 -106
- package/lib/learning/reflect.test.mjs +0 -0
- package/lib/learning/session-index.test.mjs +0 -125
- package/lib/learning/skill-writer.test.mjs +0 -210
- package/lib/mandate/audit.test.mjs +0 -195
- package/lib/mandate/contract.test.mjs +0 -185
- package/lib/mandate/derive.test.mjs +0 -274
- package/lib/mandate/model.test.mjs +0 -164
- package/lib/mandate/refresh.test.mjs +0 -389
- package/lib/mcp/server.test.mjs +0 -426
- package/lib/model-router/auth-profiles.test.mjs +0 -580
- package/lib/model-router/catalog.test.mjs +0 -385
- package/lib/model-router/economics.test.mjs +0 -438
- package/lib/model-router/failover.test.mjs +0 -439
- package/lib/model-router/health.test.mjs +0 -338
- package/lib/model-router/integration-coverage.test.mjs +0 -831
- package/lib/model-router/integration.test.mjs +0 -564
- package/lib/model-router/ledger.test.mjs +0 -415
- package/lib/model-router/llm-task.test.mjs +0 -392
- package/lib/model-router/org-credentials.test.mjs +0 -265
- package/lib/model-router/pricing-refresh.test.mjs +0 -286
- package/lib/model-router/reconcile.test.mjs +0 -316
- package/lib/model-router/repair.test.mjs +0 -180
- package/lib/model-router/spawn.test.mjs +0 -446
- package/lib/model-router/taxonomy.test.mjs +0 -410
- package/lib/model-router.test.mjs +0 -1207
- package/lib/org/activity.test.mjs +0 -134
- package/lib/org/approvals.test.mjs +0 -216
- package/lib/org/awareness.test.mjs +0 -159
- package/lib/org/board-mine-cache.test.mjs +0 -53
- package/lib/org/board.test.mjs +0 -187
- package/lib/org/bootstrap-context.test.mjs +0 -153
- package/lib/org/client.test.mjs +0 -1206
- package/lib/org/cohort-client.test.mjs +0 -126
- package/lib/org/cost-sync.test.mjs +0 -153
- package/lib/org/doctor.test.mjs +0 -346
- package/lib/org/engagement-ledger.test.mjs +0 -112
- package/lib/org/engagement.test.mjs +0 -739
- package/lib/org/handoff.test.mjs +0 -269
- package/lib/org/inbound/directedness.test.mjs +0 -668
- package/lib/org/inbound/facts.test.mjs +0 -471
- package/lib/org/inbound/hydrate.test.mjs +0 -908
- package/lib/org/inbound/index.test.mjs +0 -429
- package/lib/org/inbound/project.test.mjs +0 -287
- package/lib/org/integration-tools.test.mjs +0 -160
- package/lib/org/keys.test.mjs +0 -92
- package/lib/org/knowledge.test.mjs +0 -326
- package/lib/org/leases.test.mjs +0 -235
- package/lib/org/mesh-directives.test.mjs +0 -110
- package/lib/org/mesh-integration.test.mjs +0 -127
- package/lib/org/mesh.test.mjs +0 -400
- package/lib/org/messaging.test.mjs +0 -471
- package/lib/org/param-contract.test.mjs +0 -477
- package/lib/org/policy.test.mjs +0 -237
- package/lib/org/protocol.checksum.test.mjs +0 -90
- package/lib/org/protocol.test.mjs +0 -323
- package/lib/org/push.test.mjs +0 -792
- package/lib/org/registry.test.mjs +0 -100
- package/lib/org/resource-tools.test.mjs +0 -361
- package/lib/org/tool-access.test.mjs +0 -144
- package/lib/org/tool-surface-integration.test.mjs +0 -120
- package/lib/org/tool-surface.test.mjs +0 -1268
- package/lib/org/typing.test.mjs +0 -291
- package/lib/org/ui-parity.test.mjs +0 -560
- package/lib/org/verify.test.mjs +0 -194
- package/lib/org/work-ledger.test.mjs +0 -273
- package/lib/plan/adoption-e2e.test.mjs +0 -366
- package/lib/plan/budget-enforcement.test.mjs +0 -400
- package/lib/plan/compile.test.mjs +0 -382
- package/lib/plan/emit.test.mjs +0 -269
- package/lib/plan/explain.test.mjs +0 -188
- package/lib/prompts/parallelism.test.mjs +0 -177
- package/lib/rag/rag.test.mjs +0 -505
- package/lib/rate-guard.test.mjs +0 -272
- package/lib/reactive-gate.test.mjs +0 -57
- package/lib/render.test.mjs +0 -68
- package/lib/resource-governor.test.mjs +0 -488
- package/lib/scheduling/dynamic-jobs.test.mjs +0 -344
- package/lib/scheduling/jitter.test.mjs +0 -140
- package/lib/secrets/broker.test.mjs +0 -280
- package/lib/secrets/providers.test.mjs +0 -274
- package/lib/security/audit-engine.test.mjs +0 -424
- package/lib/security/coerce-args.test.mjs +0 -281
- package/lib/security/dangerous-tools.test.mjs +0 -68
- package/lib/security/external-content.test.mjs +0 -84
- package/lib/security/redact.test.mjs +0 -441
- package/lib/security/secret-equal.test.mjs +0 -55
- package/lib/session/config.test.mjs +0 -92
- package/lib/session/feed-core.test.mjs +0 -198
- package/lib/session/first-run.test.mjs +0 -121
- package/lib/session/frontdoor.test.mjs +0 -205
- package/lib/session/handoffs.test.mjs +0 -183
- package/lib/session/identity.test.mjs +0 -180
- package/lib/session/inbox-claims.test.mjs +0 -286
- package/lib/session/launch-args.test.mjs +0 -157
- package/lib/session/liveness.test.mjs +0 -100
- package/lib/session/status-summary.test.mjs +0 -118
- package/lib/session-permissions.test.mjs +0 -120
- package/lib/setup/claude-probe.test.mjs +0 -187
- package/lib/setup/completeness.test.mjs +0 -110
- package/lib/setup/context-pack.test.mjs +0 -89
- package/lib/setup/enrich.test.mjs +0 -115
- package/lib/setup/enroll-from-cohort.test.mjs +0 -300
- package/lib/setup/integration.test.mjs +0 -162
- package/lib/setup/io.test.mjs +0 -77
- package/lib/setup/runner.test.mjs +0 -132
- package/lib/setup/sections/identity.test.mjs +0 -234
- package/lib/setup/sections/inventory.test.mjs +0 -198
- package/lib/setup/sections/learning.test.mjs +0 -81
- package/lib/setup/sections/mandate.test.mjs +0 -388
- package/lib/setup/sections/messaging.test.mjs +0 -127
- package/lib/setup/sections/model.test.mjs +0 -240
- package/lib/setup/sections/org.test.mjs +0 -346
- package/lib/setup/sections/orgmail.test.mjs +0 -118
- package/lib/setup/sections/recovery.test.mjs +0 -98
- package/lib/setup/sections/subagents.test.mjs +0 -429
- package/lib/setup/sections/verify.test.mjs +0 -175
- package/lib/setup/sot.test.mjs +0 -81
- package/lib/setup/state.test.mjs +0 -115
- package/lib/singleton.test.mjs +0 -151
- package/lib/subagents/cli.test.mjs +0 -389
- package/lib/subagents/client.test.mjs +0 -309
- package/lib/subagents/gap.test.mjs +0 -234
- package/lib/subagents/lock.test.mjs +0 -248
- package/lib/subagents/manifest.test.mjs +0 -175
- package/lib/subagents/refs.test.mjs +0 -204
- package/lib/subagents/resolve.test.mjs +0 -422
- package/lib/subagents/schema.test.mjs +0 -328
- package/lib/telemetry/alerts.test.mjs +0 -109
- package/lib/telemetry/collect.test.mjs +0 -1274
- package/lib/tool-definitions-integration.test.mjs +0 -83
- package/lib/tool-definitions.test.mjs +0 -437
- package/lib/upgrade/global-refresh.test.mjs +0 -65
- package/lib/upgrade/launchd-reconcile.test.mjs +0 -272
- package/lib/upgrade/post-steps.test.mjs +0 -200
- package/lib/upgrade/verify.test.mjs +0 -164
- package/lib/util/fetch-timeout.test.mjs +0 -202
- package/lib/util/reconnect.test.mjs +0 -369
- package/lib/util/unhandled.test.mjs +0 -216
- package/lib/voice/outbound.test.mjs +0 -69
- package/lib/voice/session-rotation.test.mjs +0 -114
- package/lib/voice/stt.test.mjs +0 -226
- package/lib/voice/voice.test.mjs +0 -990
- package/scripts/cadence/enqueue-cadence-tick.test.mjs +0 -187
- package/scripts/ci/check-docs-accuracy.test.mjs +0 -409
- package/scripts/ci/check-durable-write-seam.test.mjs +0 -90
- package/scripts/ci/check-no-build-artifacts.test.mjs +0 -71
- package/scripts/ci/check-no-residual-identity.test.mjs +0 -202
- package/scripts/ci/check-skill-packs.test.mjs +0 -495
- package/scripts/ci/check-subagent-frontmatter.test.mjs +0 -124
- package/scripts/ci/check.test.mjs +0 -194
- package/scripts/ci/conformance-org-api.test.mjs +0 -425
- package/scripts/cloud-relay/voice/relay-identity.test.mjs +0 -96
- package/scripts/collective/hook-runner.test.mjs +0 -173
- package/scripts/cost/fleet-digest.test.mjs +0 -207
- package/scripts/cost/track-claude-usage-pricing.test.mjs +0 -183
- package/scripts/cost/track-claude-usage.test.mjs +0 -148
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +0 -96
- package/scripts/daemon/agent-daemon-design.test.mjs +0 -238
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +0 -60
- package/scripts/daemon/agent-daemon.test.mjs +0 -995
- package/scripts/daemon/assurance-e2e.test.mjs +0 -613
- package/scripts/daemon/assurance.test.mjs +0 -1791
- package/scripts/daemon/board-mirror.test.mjs +0 -165
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +0 -393
- package/scripts/daemon/cadence-consumer-governance.test.mjs +0 -276
- package/scripts/daemon/cadence-consumer.test.mjs +0 -776
- package/scripts/daemon/cadence-handlers.test.mjs +0 -837
- package/scripts/daemon/classifier-identity.test.mjs +0 -137
- package/scripts/daemon/classifier.test.mjs +0 -266
- package/scripts/daemon/classify-kind.test.mjs +0 -40
- package/scripts/daemon/context-compiler.test.mjs +0 -406
- package/scripts/daemon/deliver.test.mjs +0 -564
- package/scripts/daemon/dispatcher-cooldown.test.mjs +0 -122
- package/scripts/daemon/dispatcher-governance.test.mjs +0 -1013
- package/scripts/daemon/dispatcher-resume.test.mjs +0 -166
- package/scripts/daemon/dispatcher-session-continuity.test.mjs +0 -365
- package/scripts/daemon/execution-ladder.test.mjs +0 -470
- package/scripts/daemon/goal-steward-cadence.test.mjs +0 -312
- package/scripts/daemon/inbox-deferral-session.test.mjs +0 -49
- package/scripts/daemon/inbox-deferral.test.mjs +0 -336
- package/scripts/daemon/inbox-wake.test.mjs +0 -199
- package/scripts/daemon/integration.test.mjs +0 -149
- package/scripts/daemon/lib/self-echo.test.mjs +0 -153
- package/scripts/daemon/lib/session-router.test.mjs +0 -554
- package/scripts/daemon/prompt-builder-preamble.test.mjs +0 -210
- package/scripts/daemon/prompt-builder.test.mjs +0 -556
- package/scripts/daemon/responder-cost.test.mjs +0 -68
- package/scripts/daemon/responder-history.test.mjs +0 -221
- package/scripts/daemon/sdk-version.test.mjs +0 -31
- package/scripts/daemon/session-lock.test.mjs +0 -252
- package/scripts/daemon/session-outcomes.test.mjs +0 -533
- package/scripts/daemon/typing-registry.test.mjs +0 -102
- package/scripts/hooks/pre-send-audit.test.mjs +0 -354
- package/scripts/huddle/huddle-prompt.test.mjs +0 -176
- package/scripts/local-triggers/autoupdate.test.mjs +0 -518
- package/scripts/local-triggers/generate-plists.test.mjs +0 -456
- package/scripts/media-generation/brand-clause.test.mjs +0 -135
- package/scripts/org/send-orgmail.first-contact.test.mjs +0 -102
- package/scripts/poller/inbox-privilege-injection.test.mjs +0 -167
- package/scripts/poller/inbox-scan-poller.test.mjs +0 -295
- package/scripts/poller/lib/cloud-relay-dedup.test.mjs +0 -133
- package/scripts/poller/slack-socket-mode.test.mjs +0 -805
- package/scripts/poller-launchd/install.test.mjs +0 -243
- package/scripts/restore-from-backup.test.mjs +0 -181
- package/scripts/session/feed.test.mjs +0 -196
- package/scripts/session/supervisor-sh.test.mjs +0 -218
- package/scripts/session/supervisor.test.mjs +0 -482
- package/scripts/setup/configure-macos.test.mjs +0 -306
- package/scripts/setup/gen-subagent-manifest.test.mjs +0 -124
- package/scripts/setup/generate-agent-package-json.test.mjs +0 -143
- package/scripts/setup/generate-capability.test.mjs +0 -134
- package/scripts/setup/init-agent.test.mjs +0 -370
- package/scripts/setup/init-skill-marketplace.test.mjs +0 -193
- package/scripts/vendor/sync-skill-packs.test.mjs +0 -103
- package/scripts/watchdog/memory-watchdog.test.mjs +0 -64
package/bin/maestro.test.mjs
DELETED
|
@@ -1,1574 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* maestro.test.mjs — CLI tests for the bin/maestro.mjs commands.
|
|
3
|
-
*
|
|
4
|
-
* Covers:
|
|
5
|
-
* • `create` scaffolds the cadence-bus directory tree, the enqueue
|
|
6
|
-
* script, the consumer, and the cadence handlers under the new agent.
|
|
7
|
-
* • `upgrade` migrates a legacy-style agent repo: backs up old plists,
|
|
8
|
-
* bootstraps state/cadence-bus/, regenerates plists.
|
|
9
|
-
* • `upgrade` preserves agent-specific files (CLAUDE.md, config/,
|
|
10
|
-
* knowledge/, memory/, state/, logs/, outputs/, .env) untouched.
|
|
11
|
-
* • `upgrade` is idempotent when run twice on the same repo.
|
|
12
|
-
* • `doctor` flags legacy plists with actionable remediation.
|
|
13
|
-
*
|
|
14
|
-
* Note: `create` runs `npm install` at the end, which is heavyweight. To
|
|
15
|
-
* keep test runtime reasonable we skip the npm install step by passing
|
|
16
|
-
* an alternate npm shim on PATH that is a no-op. Same trick for git.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { test } from "node:test";
|
|
20
|
-
import assert from "node:assert/strict";
|
|
21
|
-
import { promises as fsp } from "fs";
|
|
22
|
-
import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
23
|
-
import { tmpdir } from "os";
|
|
24
|
-
import { join, resolve, dirname } from "path";
|
|
25
|
-
import { fileURLToPath } from "node:url";
|
|
26
|
-
import { spawnSync, execFileSync } from "node:child_process";
|
|
27
|
-
import { chmodSync, statSync } from "node:fs";
|
|
28
|
-
|
|
29
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
30
|
-
const MAESTRO_ROOT = resolve(__dirname, "..");
|
|
31
|
-
const MAESTRO_CLI = resolve(__dirname, "maestro.mjs");
|
|
32
|
-
|
|
33
|
-
// Pure predicates exported from the CLI module (importing does not dispatch).
|
|
34
|
-
const { evaluateCostTripwire, summariseLedgerRows, evaluateStaleness } = await import(MAESTRO_CLI);
|
|
35
|
-
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
// Helpers
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
|
|
40
|
-
async function tmpRoot(prefix = "maestro-cli-test") {
|
|
41
|
-
const path = join(
|
|
42
|
-
tmpdir(),
|
|
43
|
-
`${prefix}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
44
|
-
);
|
|
45
|
-
await fsp.mkdir(path, { recursive: true });
|
|
46
|
-
return path;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async function rmRoot(path) {
|
|
50
|
-
try { await fsp.rm(path, { recursive: true, force: true }); } catch { /* */ }
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
// The machine the CLI must NEVER touch from a test: the developer's real
|
|
55
|
-
// ~/Library/LaunchAgents, ~/.claude and global npm. `upgrade` now reconciles
|
|
56
|
-
// all three (WP-M6), so every CLI run gets a throwaway HOME, a `launchctl`
|
|
57
|
-
// stub that treats "installed" as "loaded" (in `list` AND in `print gui/<uid>`,
|
|
58
|
-
// which is what the reconcile reads), an `npm` stub, a `claude` stub (logs and
|
|
59
|
-
// exits 1 → global-setup's JSON fallback) and MAESTRO_SKIP_GLOBAL_INSTALL=1.
|
|
60
|
-
// The claude stub is reached through CLAUDE_BIN, not PATH: lib/claude-bin.mjs
|
|
61
|
-
// resolves ~/.local/bin, /opt/homebrew/bin, /usr/local/bin BEFORE PATH, so a
|
|
62
|
-
// PATH-only stub is silently bypassed by the developer's real `claude`.
|
|
63
|
-
// ---------------------------------------------------------------------------
|
|
64
|
-
|
|
65
|
-
const TEST_SHIM = await tmpRoot("maestro-shim-global");
|
|
66
|
-
{
|
|
67
|
-
const stub = async (name, body) => { writeFileSync(join(TEST_SHIM, name), `#!/bin/bash\n${body}\n`); await fsp.chmod(join(TEST_SHIM, name), 0o755); };
|
|
68
|
-
await stub("launchctl", `echo "launchctl $*" >> "$HOME/launchctl.log"
|
|
69
|
-
if [ "$1" = "list" ]; then
|
|
70
|
-
if [ -n "\${2:-}" ]; then [ -f "$HOME/Library/LaunchAgents/$2.plist" ] && exit 0 || exit 113; fi
|
|
71
|
-
printf 'PID\tStatus\tLabel\n'
|
|
72
|
-
for f in "$HOME"/Library/LaunchAgents/*.plist; do [ -f "$f" ] && printf -- '-\t0\t%s\n' "$(basename "$f" .plist)"; done
|
|
73
|
-
exit 0
|
|
74
|
-
fi
|
|
75
|
-
if [ "$1" = "print" ]; then
|
|
76
|
-
case "$2" in
|
|
77
|
-
gui/*/*) l="\${2##*/}"; if [ -f "$HOME/Library/LaunchAgents/$l.plist" ]; then echo "$l = {}"; exit 0; fi; echo "Could not find service \"$l\" in domain for user gui: 501" >&2; exit 113 ;;
|
|
78
|
-
gui/*) printf '%s = {\n\tservices = {\n' "$2"; for f in "$HOME"/Library/LaunchAgents/*.plist; do [ -f "$f" ] && printf '\t\t0\t0\t%s\n' "$(basename "$f" .plist)"; done; printf '\t}\n}\n'; exit 0 ;;
|
|
79
|
-
esac
|
|
80
|
-
fi
|
|
81
|
-
exit 0`);
|
|
82
|
-
await stub("npm", `echo "npm $*" >> "$HOME/npm.log"
|
|
83
|
-
case "$1" in
|
|
84
|
-
ls) echo '{}'; exit 1 ;;
|
|
85
|
-
view) exit 1 ;;
|
|
86
|
-
root) echo "$HOME/npm-global/lib/node_modules"; exit 0 ;;
|
|
87
|
-
esac
|
|
88
|
-
exit 0`);
|
|
89
|
-
await stub("claude", `echo "claude $*" >> "$HOME/claude.log"
|
|
90
|
-
exit 1`);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** A throwaway HOME for one CLI run (or a shared one when the test does not care). */
|
|
94
|
-
async function freshHome() {
|
|
95
|
-
const home = await tmpRoot("maestro-home");
|
|
96
|
-
mkdirSync(join(home, "Library", "LaunchAgents"), { recursive: true });
|
|
97
|
-
return home;
|
|
98
|
-
}
|
|
99
|
-
const TEST_HOME = await freshHome();
|
|
100
|
-
|
|
101
|
-
function baseEnv(extra = {}) {
|
|
102
|
-
return {
|
|
103
|
-
...process.env,
|
|
104
|
-
HOME: TEST_HOME,
|
|
105
|
-
PATH: `${TEST_SHIM}:${process.env.PATH || ""}`,
|
|
106
|
-
CLAUDE_BIN: join(TEST_SHIM, "claude"),
|
|
107
|
-
MAESTRO_SKIP_GLOBAL_INSTALL: "1",
|
|
108
|
-
...extra,
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Build a PATH that shadows `npm` (and optionally `git`) with no-op shims
|
|
114
|
-
* so `create` doesn't actually try to install dependencies. Returns the new
|
|
115
|
-
* env block plus the shim dir (for cleanup).
|
|
116
|
-
*/
|
|
117
|
-
async function withShim() {
|
|
118
|
-
const dir = await tmpRoot("maestro-shim");
|
|
119
|
-
const npm = join(dir, "npm");
|
|
120
|
-
writeFileSync(npm, "#!/bin/bash\necho '[shim npm]' \"$@\" >&2\nexit 0\n");
|
|
121
|
-
await fsp.chmod(npm, 0o755);
|
|
122
|
-
// Keep real git (we want git init to actually init).
|
|
123
|
-
const env = baseEnv({ PATH: `${dir}:${TEST_SHIM}:${process.env.PATH || ""}` });
|
|
124
|
-
return { env, dir };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function runCli(args, cwd, env) {
|
|
128
|
-
return spawnSync(process.execPath, [MAESTRO_CLI, ...args], {
|
|
129
|
-
cwd: cwd || process.cwd(),
|
|
130
|
-
env: env || baseEnv(),
|
|
131
|
-
encoding: "utf-8",
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** Like runCli but pipes `input` to the child's stdin (for `secrets rotate`). */
|
|
136
|
-
function runCliStdin(args, cwd, input, env) {
|
|
137
|
-
return spawnSync(process.execPath, [MAESTRO_CLI, ...args], {
|
|
138
|
-
cwd: cwd || process.cwd(),
|
|
139
|
-
env: env || baseEnv(),
|
|
140
|
-
input,
|
|
141
|
-
encoding: "utf-8",
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// ---------------------------------------------------------------------------
|
|
146
|
-
// create
|
|
147
|
-
// ---------------------------------------------------------------------------
|
|
148
|
-
|
|
149
|
-
test("create scaffolds cadence-bus dirs and enqueue/consumer/handlers", async () => {
|
|
150
|
-
const parent = await tmpRoot("maestro-create");
|
|
151
|
-
const { env, dir: shim } = await withShim();
|
|
152
|
-
try {
|
|
153
|
-
const r = runCli(["create", "test-agent"], parent, env);
|
|
154
|
-
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
155
|
-
const agent = join(parent, "test-agent");
|
|
156
|
-
|
|
157
|
-
// Cadence bus directory tree
|
|
158
|
-
for (const d of [
|
|
159
|
-
"state/cadence-bus",
|
|
160
|
-
"state/cadence-bus/inbox",
|
|
161
|
-
"state/cadence-bus/claimed",
|
|
162
|
-
"state/cadence-bus/processed",
|
|
163
|
-
"state/cadence-bus/failed",
|
|
164
|
-
"state/cadence-bus/dlq",
|
|
165
|
-
"logs/cadence-bus",
|
|
166
|
-
]) {
|
|
167
|
-
assert.ok(existsSync(join(agent, d)), `missing ${d}`);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// New framework files copied into the agent repo.
|
|
171
|
-
for (const f of [
|
|
172
|
-
"lib/cadence-bus.mjs",
|
|
173
|
-
"scripts/cadence/enqueue-cadence-tick.mjs",
|
|
174
|
-
"scripts/cadence/launchd-cadence-wrapper.sh",
|
|
175
|
-
"scripts/cadence/cadence-status.mjs",
|
|
176
|
-
"scripts/daemon/cadence-consumer.mjs",
|
|
177
|
-
"scripts/daemon/cadence-handlers.mjs",
|
|
178
|
-
]) {
|
|
179
|
-
assert.ok(existsSync(join(agent, f)), `missing ${f}`);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Agent package.json has the cadence:* scripts.
|
|
183
|
-
const pkg = JSON.parse(readFileSync(join(agent, "package.json"), "utf-8"));
|
|
184
|
-
assert.ok(pkg.scripts["cadence:enqueue"]);
|
|
185
|
-
assert.ok(pkg.scripts["cadence:consume"]);
|
|
186
|
-
assert.ok(pkg.scripts["cadence:status"]);
|
|
187
|
-
} finally {
|
|
188
|
-
await rmRoot(parent);
|
|
189
|
-
await rmRoot(shim);
|
|
190
|
-
}
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
// ---------------------------------------------------------------------------
|
|
194
|
-
// upgrade — migration
|
|
195
|
-
// ---------------------------------------------------------------------------
|
|
196
|
-
|
|
197
|
-
async function makeLegacyAgent() {
|
|
198
|
-
const root = await tmpRoot("maestro-legacy");
|
|
199
|
-
// Minimal identity so `upgrade` recognises this as a Maestro repo.
|
|
200
|
-
mkdirSync(join(root, "config"), { recursive: true });
|
|
201
|
-
writeFileSync(join(root, "CLAUDE.md"), "# legacy agent\n");
|
|
202
|
-
writeFileSync(join(root, "config/agent.ts"), "export const AGENT = { firstName: 'legacy' };\n");
|
|
203
|
-
writeFileSync(join(root, "package.json"), '{"name":"legacy-agent","version":"1.0.0"}\n');
|
|
204
|
-
// Personal/local agent state that must NOT be touched.
|
|
205
|
-
mkdirSync(join(root, "knowledge/decisions"), { recursive: true });
|
|
206
|
-
mkdirSync(join(root, "memory/interactions"), { recursive: true });
|
|
207
|
-
mkdirSync(join(root, "state/queues"), { recursive: true });
|
|
208
|
-
mkdirSync(join(root, "outputs"), { recursive: true });
|
|
209
|
-
mkdirSync(join(root, "logs"), { recursive: true });
|
|
210
|
-
writeFileSync(join(root, "knowledge/decisions/local-decision.md"), "# do not touch");
|
|
211
|
-
writeFileSync(join(root, "memory/interactions/local-memory.yaml"), "# do not touch");
|
|
212
|
-
writeFileSync(join(root, "state/queues/action-stack.yaml"), "items:\n - id: P1\n title: local item\n");
|
|
213
|
-
writeFileSync(join(root, "outputs/local-output.md"), "# my draft");
|
|
214
|
-
writeFileSync(join(root, "logs/local.log"), "audit data");
|
|
215
|
-
writeFileSync(join(root, ".env"), "ANTHROPIC_API_KEY=sk-x\n");
|
|
216
|
-
|
|
217
|
-
// Legacy plist that calls run-trigger.sh directly.
|
|
218
|
-
mkdirSync(join(root, "scripts/local-triggers/plists"), { recursive: true });
|
|
219
|
-
writeFileSync(
|
|
220
|
-
join(root, "scripts/local-triggers/plists/ai.adaptic.legacy-inbox-processor.plist"),
|
|
221
|
-
`<?xml version="1.0"?><plist><dict>
|
|
222
|
-
<key>ProgramArguments</key><array>
|
|
223
|
-
<string>/foo/scripts/local-triggers/run-trigger.sh</string>
|
|
224
|
-
<string>inbox-processor</string>
|
|
225
|
-
</array></dict></plist>`
|
|
226
|
-
);
|
|
227
|
-
|
|
228
|
-
// Init git so smart-merge sees the working tree as clean.
|
|
229
|
-
execFileSync("git", ["init", "-q"], { cwd: root });
|
|
230
|
-
execFileSync("git", ["add", "-A"], { cwd: root });
|
|
231
|
-
execFileSync(
|
|
232
|
-
"git",
|
|
233
|
-
["-c", "user.email=test@test", "-c", "user.name=test", "commit", "-q", "-m", "init"],
|
|
234
|
-
{ cwd: root },
|
|
235
|
-
);
|
|
236
|
-
return root;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
test("upgrade backs up legacy plists and regenerates with cadence-bus marker", async () => {
|
|
240
|
-
const root = await makeLegacyAgent();
|
|
241
|
-
try {
|
|
242
|
-
const r = runCli(["upgrade"], root);
|
|
243
|
-
assert.equal(r.status, 0, r.stderr);
|
|
244
|
-
// Backup directory exists.
|
|
245
|
-
const backupRoot = join(root, ".maestro/backup/plists");
|
|
246
|
-
assert.ok(existsSync(backupRoot), "backup root must exist");
|
|
247
|
-
const stamps = readdirSync(backupRoot);
|
|
248
|
-
assert.ok(stamps.length >= 1);
|
|
249
|
-
const backedUp = readdirSync(join(backupRoot, stamps[0]));
|
|
250
|
-
assert.ok(backedUp.some((n) => /legacy-inbox-processor\.plist$/.test(n)));
|
|
251
|
-
|
|
252
|
-
// After regeneration, plists carry the cadence-bus marker and none
|
|
253
|
-
// reference run-trigger.sh.
|
|
254
|
-
const plistDir = join(root, "scripts/local-triggers/plists");
|
|
255
|
-
const plists = readdirSync(plistDir).filter((n) => n.endsWith(".plist"));
|
|
256
|
-
assert.ok(plists.length >= 1);
|
|
257
|
-
let legacy = 0;
|
|
258
|
-
let modern = 0;
|
|
259
|
-
for (const name of plists) {
|
|
260
|
-
const body = readFileSync(join(plistDir, name), "utf-8");
|
|
261
|
-
if (body.includes("run-trigger.sh")) legacy++;
|
|
262
|
-
if (body.includes("maestro-plist-arch: cadence-bus")) modern++;
|
|
263
|
-
}
|
|
264
|
-
assert.equal(legacy, 0, "no legacy plists should remain post-upgrade");
|
|
265
|
-
assert.equal(modern, plists.length, "every plist must carry the cadence-bus marker");
|
|
266
|
-
} finally { await rmRoot(root); }
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
test("upgrade preserves agent-specific files untouched", async () => {
|
|
270
|
-
const root = await makeLegacyAgent();
|
|
271
|
-
try {
|
|
272
|
-
const before = {
|
|
273
|
-
claudemd: readFileSync(join(root, "CLAUDE.md"), "utf-8"),
|
|
274
|
-
agentTs: readFileSync(join(root, "config/agent.ts"), "utf-8"),
|
|
275
|
-
decision: readFileSync(join(root, "knowledge/decisions/local-decision.md"), "utf-8"),
|
|
276
|
-
memory: readFileSync(join(root, "memory/interactions/local-memory.yaml"), "utf-8"),
|
|
277
|
-
queue: readFileSync(join(root, "state/queues/action-stack.yaml"), "utf-8"),
|
|
278
|
-
output: readFileSync(join(root, "outputs/local-output.md"), "utf-8"),
|
|
279
|
-
log: readFileSync(join(root, "logs/local.log"), "utf-8"),
|
|
280
|
-
env: readFileSync(join(root, ".env"), "utf-8"),
|
|
281
|
-
};
|
|
282
|
-
const r = runCli(["upgrade"], root);
|
|
283
|
-
assert.equal(r.status, 0, r.stderr);
|
|
284
|
-
for (const [k, v] of Object.entries(before)) {
|
|
285
|
-
const path = ({
|
|
286
|
-
claudemd: "CLAUDE.md",
|
|
287
|
-
agentTs: "config/agent.ts",
|
|
288
|
-
decision: "knowledge/decisions/local-decision.md",
|
|
289
|
-
memory: "memory/interactions/local-memory.yaml",
|
|
290
|
-
queue: "state/queues/action-stack.yaml",
|
|
291
|
-
output: "outputs/local-output.md",
|
|
292
|
-
log: "logs/local.log",
|
|
293
|
-
env: ".env",
|
|
294
|
-
})[k];
|
|
295
|
-
assert.equal(
|
|
296
|
-
readFileSync(join(root, path), "utf-8"),
|
|
297
|
-
v,
|
|
298
|
-
`${path} was modified by upgrade`,
|
|
299
|
-
);
|
|
300
|
-
}
|
|
301
|
-
} finally { await rmRoot(root); }
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
test("upgrade is idempotent (second run is a clean no-op for plists)", async () => {
|
|
305
|
-
const root = await makeLegacyAgent();
|
|
306
|
-
try {
|
|
307
|
-
const first = runCli(["upgrade"], root);
|
|
308
|
-
assert.equal(first.status, 0);
|
|
309
|
-
// Snapshot the regenerated plist contents.
|
|
310
|
-
const plistDir = join(root, "scripts/local-triggers/plists");
|
|
311
|
-
const before = {};
|
|
312
|
-
for (const name of readdirSync(plistDir)) {
|
|
313
|
-
before[name] = readFileSync(join(plistDir, name), "utf-8");
|
|
314
|
-
}
|
|
315
|
-
const second = runCli(["upgrade"], root);
|
|
316
|
-
assert.equal(second.status, 0);
|
|
317
|
-
// The second run should NOT introduce any new backup directory beyond
|
|
318
|
-
// what the first run created, because there are no legacy plists left.
|
|
319
|
-
const backups = readdirSync(join(root, ".maestro/backup/plists"));
|
|
320
|
-
assert.equal(backups.length, 1, "no new backup directory on idempotent run");
|
|
321
|
-
// Plist contents byte-identical.
|
|
322
|
-
for (const [name, body] of Object.entries(before)) {
|
|
323
|
-
assert.equal(readFileSync(join(plistDir, name), "utf-8"), body, `${name} changed`);
|
|
324
|
-
}
|
|
325
|
-
} finally { await rmRoot(root); }
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
test("upgrade --dry-run reports legacy without modifying anything", async () => {
|
|
329
|
-
const root = await makeLegacyAgent();
|
|
330
|
-
try {
|
|
331
|
-
// Snapshot a known sentinel file.
|
|
332
|
-
const sentinel = readFileSync(join(root, "scripts/local-triggers/plists/ai.adaptic.legacy-inbox-processor.plist"), "utf-8");
|
|
333
|
-
const r = runCli(["upgrade", "--dry-run"], root);
|
|
334
|
-
assert.equal(r.status, 0);
|
|
335
|
-
assert.match(r.stdout, /still call run-trigger\.sh/);
|
|
336
|
-
// Sentinel must not have changed.
|
|
337
|
-
assert.equal(
|
|
338
|
-
readFileSync(join(root, "scripts/local-triggers/plists/ai.adaptic.legacy-inbox-processor.plist"), "utf-8"),
|
|
339
|
-
sentinel,
|
|
340
|
-
);
|
|
341
|
-
// No backup directory created.
|
|
342
|
-
assert.ok(!existsSync(join(root, ".maestro/backup")), "dry-run must not create backups");
|
|
343
|
-
} finally { await rmRoot(root); }
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
// ---------------------------------------------------------------------------
|
|
347
|
-
// doctor
|
|
348
|
-
// ---------------------------------------------------------------------------
|
|
349
|
-
|
|
350
|
-
test("doctor flags legacy plists with actionable remediation", async () => {
|
|
351
|
-
const root = await makeLegacyAgent();
|
|
352
|
-
try {
|
|
353
|
-
const r = runCli(["doctor"], root);
|
|
354
|
-
// doctor exits 1 when issues are found; that's expected here.
|
|
355
|
-
assert.ok(r.status >= 0);
|
|
356
|
-
const combined = (r.stdout || "") + (r.stderr || "");
|
|
357
|
-
assert.match(combined, /run-trigger\.sh/);
|
|
358
|
-
assert.match(combined, /maestro upgrade/);
|
|
359
|
-
} finally { await rmRoot(root); }
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
test("doctor recognises new cadence-bus architecture after upgrade", async () => {
|
|
363
|
-
const root = await makeLegacyAgent();
|
|
364
|
-
try {
|
|
365
|
-
runCli(["upgrade"], root);
|
|
366
|
-
const r = runCli(["doctor"], root);
|
|
367
|
-
const combined = (r.stdout || "") + (r.stderr || "");
|
|
368
|
-
assert.match(combined, /cadence-bus architecture/);
|
|
369
|
-
// 'plist(s) still call run-trigger.sh' must NOT appear.
|
|
370
|
-
assert.ok(!combined.includes("plist(s) still call run-trigger.sh"));
|
|
371
|
-
} finally { await rmRoot(root); }
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
// ---------------------------------------------------------------------------
|
|
375
|
-
// setup (WS1 wizard) — CLI surface
|
|
376
|
-
// ---------------------------------------------------------------------------
|
|
377
|
-
|
|
378
|
-
test("setup --help runs in a non-agent dir without throwing (exit 0)", async () => {
|
|
379
|
-
const dir = await tmpRoot("maestro-setup-help");
|
|
380
|
-
try {
|
|
381
|
-
const r = runCli(["setup", "--help"], dir);
|
|
382
|
-
assert.equal(r.status, 0, r.stderr);
|
|
383
|
-
assert.match(r.stdout, /maestro setup/);
|
|
384
|
-
assert.match(r.stdout, /--headless/);
|
|
385
|
-
} finally { await rmRoot(dir); }
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
test("setup --status in a non-agent dir prints a graceful message (exit 0)", async () => {
|
|
389
|
-
const dir = await tmpRoot("maestro-setup-status");
|
|
390
|
-
try {
|
|
391
|
-
const r = runCli(["setup", "--status"], dir);
|
|
392
|
-
assert.equal(r.status, 0, r.stderr);
|
|
393
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
394
|
-
assert.match(out, /Not a Maestro agent directory/);
|
|
395
|
-
} finally { await rmRoot(dir); }
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
test("setup --status in an agent dir lists section detect() statuses", async () => {
|
|
399
|
-
const dir = await tmpRoot("maestro-setup-agent");
|
|
400
|
-
try {
|
|
401
|
-
mkdirSync(join(dir, "config"), { recursive: true });
|
|
402
|
-
// Minimal SoT so the agent-repo guard passes + sections can detect.
|
|
403
|
-
writeFileSync(join(dir, "config/agent.json"), JSON.stringify({
|
|
404
|
-
firstName: "UNCONFIGURED", lastName: "AGENT", title: "Unconfigured Role", email: "",
|
|
405
|
-
function: "executive-operator", altitude: "c-suite",
|
|
406
|
-
}, null, 2));
|
|
407
|
-
const r = runCli(["setup", "--status"], dir);
|
|
408
|
-
assert.equal(r.status, 0, r.stderr);
|
|
409
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
410
|
-
assert.match(out, /identity/);
|
|
411
|
-
assert.match(out, /operating-model/);
|
|
412
|
-
assert.match(out, /verify/);
|
|
413
|
-
} finally { await rmRoot(dir); }
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
// ---------------------------------------------------------------------------
|
|
417
|
-
// doctor cost-telemetry tripwire — pure predicate (observability F2)
|
|
418
|
-
// ---------------------------------------------------------------------------
|
|
419
|
-
|
|
420
|
-
/** A measured session row in the shape the tracker now writes. */
|
|
421
|
-
function measuredRow(over = {}) {
|
|
422
|
-
return {
|
|
423
|
-
ts: new Date().toISOString(), cadence: "inbox", source: "dispatcher",
|
|
424
|
-
model: "sonnet", input_tokens: 16, output_tokens: 5000,
|
|
425
|
-
cache_read_input_tokens: 553181, measurement: "measured",
|
|
426
|
-
estimated_usd: 0.241, total_cost_usd: 0.533412, ...over,
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
/** A session that ran but whose usage could not be read. */
|
|
430
|
-
function unmeasuredRow(over = {}) {
|
|
431
|
-
return {
|
|
432
|
-
ts: new Date().toISOString(), cadence: "inbox", source: "dispatcher",
|
|
433
|
-
model: "sonnet", input_tokens: null, output_tokens: null,
|
|
434
|
-
measurement: "unknown", unmeasured_reason: "no-usage-field",
|
|
435
|
-
estimated_usd: null, total_cost_usd: null, ...over,
|
|
436
|
-
};
|
|
437
|
-
}
|
|
438
|
-
/** A message send: no model ran, so $0 is a fact rather than a gap. */
|
|
439
|
-
function nonLlmRow(over = {}) {
|
|
440
|
-
return {
|
|
441
|
-
ts: new Date().toISOString(), source: "messaging", task_class: "messaging.send",
|
|
442
|
-
model: null, input_tokens: 0, output_tokens: 0, estimated_usd: 0, ...over,
|
|
443
|
-
};
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
test("summariseLedgerRows bills the authoritative cost, not the cache-blind estimate", () => {
|
|
447
|
-
const s = summariseLedgerRows([measuredRow(), measuredRow({ total_cost_usd: 1 })]);
|
|
448
|
-
assert.equal(s.rowCount, 2);
|
|
449
|
-
assert.equal(s.totalUsd, 1.533412, "total_cost_usd wins over estimated_usd");
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
test("summariseLedgerRows counts sessions, not attribution rows; tolerates junk", () => {
|
|
453
|
-
const s = summariseLedgerRows([
|
|
454
|
-
measuredRow(), unmeasuredRow(), nonLlmRow(), null, "garbage",
|
|
455
|
-
]);
|
|
456
|
-
assert.equal(s.rowCount, 2, "1 measured + 1 unmeasured; the send is not a session");
|
|
457
|
-
assert.equal(s.measured, 1);
|
|
458
|
-
assert.equal(s.unmeasured, 1);
|
|
459
|
-
assert.equal(s.nonLlmRows, 1);
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
test("summariseLedgerRows on empty input is zeroed", () => {
|
|
463
|
-
assert.equal(summariseLedgerRows([]).rowCount, 0);
|
|
464
|
-
assert.equal(summariseLedgerRows([]).totalUsd, 0);
|
|
465
|
-
assert.equal(summariseLedgerRows(undefined).rowCount, 0);
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
test("evaluateCostTripwire: unmeasured rows → RED (the 0-vs-unknown distinction)", () => {
|
|
469
|
-
// The defect doctor exists to catch: a session ran and nobody priced it.
|
|
470
|
-
const t = evaluateCostTripwire({ rows: [measuredRow(), unmeasuredRow()], sessionCount: 2 });
|
|
471
|
-
assert.equal(t.red, true);
|
|
472
|
-
assert.equal(t.unmeasured, 1);
|
|
473
|
-
assert.match(t.reason, /NO token counts/);
|
|
474
|
-
assert.match(t.fix, /unmeasured_reason/);
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
test("evaluateCostTripwire: a genuine zero-token session is NOT unmeasured", () => {
|
|
478
|
-
// A model ran and emitted nothing. That is a measurement; it must stay GREEN
|
|
479
|
-
// as long as the day bills above $0 — otherwise doctor cries wolf.
|
|
480
|
-
const t = evaluateCostTripwire({
|
|
481
|
-
rows: [measuredRow(), measuredRow({ input_tokens: 40, output_tokens: 0, total_cost_usd: 0 })],
|
|
482
|
-
sessionCount: 2,
|
|
483
|
-
});
|
|
484
|
-
assert.equal(t.red, false);
|
|
485
|
-
assert.equal(t.unmeasured, 0);
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
test("evaluateCostTripwire: sessions logged but no ledger rows → RED (unrecorded)", () => {
|
|
489
|
-
const t = evaluateCostTripwire({ rows: [], sessionCount: 3 });
|
|
490
|
-
assert.equal(t.red, true);
|
|
491
|
-
assert.equal(t.unrecorded, 3);
|
|
492
|
-
assert.match(t.reason, /only 0 dispatcher row\(s\) reached the cost ledger/);
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
test("evaluateCostTripwire: rows from ANOTHER writer do not mask an unrecorded dispatcher", () => {
|
|
496
|
-
// The population bug this replaced. `sessionCount` comes from the dispatcher's
|
|
497
|
-
// own session log; `rowCount` counted rows from every writer. With the
|
|
498
|
-
// responder now writing rows too, a dispatcher that had stopped recording
|
|
499
|
-
// entirely read as unrecorded:0 / GREEN — 35 real sessions unpriced, and the
|
|
500
|
-
// one detector meant to catch exactly that said nothing.
|
|
501
|
-
const responderRows = Array.from({ length: 40 }, () => measuredRow({ source: "responder" }));
|
|
502
|
-
const t = evaluateCostTripwire({ rows: responderRows, sessionCount: 35 });
|
|
503
|
-
assert.equal(t.red, true, "35 dispatcher sessions with 0 dispatcher rows is RED");
|
|
504
|
-
assert.equal(t.unrecorded, 35);
|
|
505
|
-
assert.equal(t.sourceRowCount, 0);
|
|
506
|
-
assert.match(t.reason, /dispatcher logged 35/);
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
test("evaluateCostTripwire: measured rows billing $0 → RED (pricing broken)", () => {
|
|
510
|
-
const t = evaluateCostTripwire({
|
|
511
|
-
rows: [measuredRow({ total_cost_usd: 0, estimated_usd: 0 })],
|
|
512
|
-
sessionCount: 1,
|
|
513
|
-
});
|
|
514
|
-
assert.equal(t.red, true);
|
|
515
|
-
assert.match(t.reason, /bill to \$0/);
|
|
516
|
-
assert.match(t.fix, /--cache-read-tokens/);
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
test("evaluateCostTripwire: no sessions and no rows → GREEN (nothing to measure)", () => {
|
|
520
|
-
const t = evaluateCostTripwire({ rows: [], sessionCount: 0 });
|
|
521
|
-
assert.equal(t.red, false);
|
|
522
|
-
assert.match(t.reason, /nothing to measure/i);
|
|
523
|
-
});
|
|
524
|
-
|
|
525
|
-
test("evaluateCostTripwire: attribution-only day is quiet, not blind", () => {
|
|
526
|
-
// Regression guard for the false alarm this replaced: 41 message-send rows on
|
|
527
|
-
// 2026-08-11 would have armed the old tripwire all by themselves.
|
|
528
|
-
const t = evaluateCostTripwire({ rows: [nonLlmRow(), nonLlmRow()], sessionCount: 0 });
|
|
529
|
-
assert.equal(t.red, false);
|
|
530
|
-
assert.match(t.reason, /nothing to measure/i);
|
|
531
|
-
});
|
|
532
|
-
|
|
533
|
-
test("evaluateCostTripwire: spend > 0 with every session measured → GREEN", () => {
|
|
534
|
-
const t = evaluateCostTripwire({ rows: [measuredRow()], sessionCount: 1 });
|
|
535
|
-
assert.equal(t.red, false);
|
|
536
|
-
assert.equal(t.totalUsd, 0.533412);
|
|
537
|
-
assert.match(t.reason, /live/i);
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
test("evaluateCostTripwire: defaults (no args) are GREEN, not a false alarm", () => {
|
|
541
|
-
const t = evaluateCostTripwire();
|
|
542
|
-
assert.equal(t.red, false);
|
|
543
|
-
assert.equal(t.status, "green");
|
|
544
|
-
});
|
|
545
|
-
|
|
546
|
-
// ---------------------------------------------------------------------------
|
|
547
|
-
// doctor cost-telemetry tripwire — end-to-end against a real ledger file
|
|
548
|
-
// ---------------------------------------------------------------------------
|
|
549
|
-
|
|
550
|
-
/**
|
|
551
|
-
* Minimal agent repo that doctor can run against without exploding before our
|
|
552
|
-
* checks. doctor exits 1 on any issue (a bare repo has many), so these tests
|
|
553
|
-
* assert on the specific lines our checks emit rather than the exit code.
|
|
554
|
-
*/
|
|
555
|
-
async function makeBareAgent() {
|
|
556
|
-
const root = await tmpRoot("maestro-doctor");
|
|
557
|
-
mkdirSync(join(root, "config"), { recursive: true });
|
|
558
|
-
writeFileSync(join(root, "config/agent.json"), JSON.stringify({
|
|
559
|
-
firstName: "test", function: "executive-operator", altitude: "c-suite",
|
|
560
|
-
}, null, 2));
|
|
561
|
-
writeFileSync(join(root, "CLAUDE.md"), "# test agent\n");
|
|
562
|
-
return root;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
function utcDate() { return new Date().toISOString().slice(0, 10); }
|
|
566
|
-
|
|
567
|
-
test("doctor RED when a session ran with no token counts recorded", async () => {
|
|
568
|
-
const root = await makeBareAgent();
|
|
569
|
-
try {
|
|
570
|
-
mkdirSync(join(root, "state/cost-tracking"), { recursive: true });
|
|
571
|
-
const ledger = join(root, "state/cost-tracking", `${utcDate()}.jsonl`);
|
|
572
|
-
writeFileSync(ledger,
|
|
573
|
-
JSON.stringify(unmeasuredRow({ unmeasured_reason: "no-usage-field" })) + "\n" +
|
|
574
|
-
JSON.stringify(unmeasuredRow({ cadence: "backlog", unmeasured_reason: "empty-stdout" })) + "\n"
|
|
575
|
-
);
|
|
576
|
-
const r = runCli(["doctor"], root);
|
|
577
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
578
|
-
assert.match(out, /Cost telemetry BLIND/);
|
|
579
|
-
assert.match(out, /NO token counts/);
|
|
580
|
-
} finally { await rmRoot(root); }
|
|
581
|
-
});
|
|
582
|
-
|
|
583
|
-
test("doctor GREEN when today's ledger records real spend", async () => {
|
|
584
|
-
const root = await makeBareAgent();
|
|
585
|
-
try {
|
|
586
|
-
mkdirSync(join(root, "state/cost-tracking"), { recursive: true });
|
|
587
|
-
const ledger = join(root, "state/cost-tracking", `${utcDate()}.jsonl`);
|
|
588
|
-
writeFileSync(ledger, JSON.stringify(measuredRow()) + "\n");
|
|
589
|
-
const r = runCli(["doctor"], root);
|
|
590
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
591
|
-
assert.match(out, /Cost telemetry: spend telemetry live/);
|
|
592
|
-
assert.ok(!out.includes("Cost telemetry BLIND"), "must not flag blind when spend>0");
|
|
593
|
-
} finally { await rmRoot(root); }
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
test("doctor GREEN (nothing to measure) when no ledger exists today", async () => {
|
|
597
|
-
const root = await makeBareAgent();
|
|
598
|
-
try {
|
|
599
|
-
const r = runCli(["doctor"], root);
|
|
600
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
601
|
-
assert.match(out, /nothing to measure/);
|
|
602
|
-
} finally { await rmRoot(root); }
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
test("doctor does NOT cry wolf on an empty ledger while the daemon is live", async () => {
|
|
606
|
-
// The regression this replaced: doctor keyed "sessions ran" off a fresh
|
|
607
|
-
// heartbeat, so every UTC day went RED between midnight and the first session
|
|
608
|
-
// and printed a fix that had already shipped.
|
|
609
|
-
const root = await makeBareAgent();
|
|
610
|
-
try {
|
|
611
|
-
mkdirSync(join(root, "state/cadence-bus"), { recursive: true });
|
|
612
|
-
writeFileSync(join(root, "state/cadence-bus/health.json"),
|
|
613
|
-
JSON.stringify({ ts: new Date().toISOString() }));
|
|
614
|
-
const r = runCli(["doctor"], root);
|
|
615
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
616
|
-
assert.ok(!out.includes("Cost telemetry BLIND"), "a live daemon with no sessions yet is not blind");
|
|
617
|
-
assert.match(out, /nothing to measure/);
|
|
618
|
-
} finally { await rmRoot(root); }
|
|
619
|
-
});
|
|
620
|
-
|
|
621
|
-
// ---------------------------------------------------------------------------
|
|
622
|
-
// doctor .env permission check (setup-onboarding W4)
|
|
623
|
-
// ---------------------------------------------------------------------------
|
|
624
|
-
|
|
625
|
-
test("doctor flags a group/world-readable .env with the chmod fix", { skip: typeof process.getuid !== "function" }, async () => {
|
|
626
|
-
const root = await makeBareAgent();
|
|
627
|
-
try {
|
|
628
|
-
const envPath = join(root, ".env");
|
|
629
|
-
writeFileSync(envPath, "ANTHROPIC_API_KEY=sk-secret\n");
|
|
630
|
-
chmodSync(envPath, 0o644);
|
|
631
|
-
const r = runCli(["doctor"], root);
|
|
632
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
633
|
-
assert.match(out, /\.env is mode 644/);
|
|
634
|
-
assert.match(out, /chmod 600 \.env/);
|
|
635
|
-
} finally { await rmRoot(root); }
|
|
636
|
-
});
|
|
637
|
-
|
|
638
|
-
test("doctor passes a chmod-600 .env", { skip: typeof process.getuid !== "function" }, async () => {
|
|
639
|
-
const root = await makeBareAgent();
|
|
640
|
-
try {
|
|
641
|
-
const envPath = join(root, ".env");
|
|
642
|
-
writeFileSync(envPath, "ANTHROPIC_API_KEY=sk-secret\n");
|
|
643
|
-
chmodSync(envPath, 0o600);
|
|
644
|
-
const r = runCli(["doctor"], root);
|
|
645
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
646
|
-
assert.match(out, /\.env permissions are owner-only/);
|
|
647
|
-
assert.ok(!out.includes(".env is mode"), "must not flag a 600 .env");
|
|
648
|
-
} finally { await rmRoot(root); }
|
|
649
|
-
});
|
|
650
|
-
|
|
651
|
-
// ---------------------------------------------------------------------------
|
|
652
|
-
// evaluateStaleness (pure) — the router data-file staleness ladder (graft #18)
|
|
653
|
-
// ---------------------------------------------------------------------------
|
|
654
|
-
|
|
655
|
-
test("evaluateStaleness: fresh data is green", () => {
|
|
656
|
-
const now = Date.UTC(2026, 5, 11);
|
|
657
|
-
const v = evaluateStaleness({ fetched: "2026-06-10", now });
|
|
658
|
-
assert.equal(v.status, "green");
|
|
659
|
-
assert.equal(v.ageDays, 1);
|
|
660
|
-
});
|
|
661
|
-
|
|
662
|
-
test("evaluateStaleness: >7d but <30d is a WARN", () => {
|
|
663
|
-
const now = Date.UTC(2026, 5, 30);
|
|
664
|
-
const v = evaluateStaleness({ fetched: "2026-06-10", now });
|
|
665
|
-
assert.equal(v.status, "warn");
|
|
666
|
-
assert.ok(v.ageDays >= 7 && v.ageDays < 30);
|
|
667
|
-
});
|
|
668
|
-
|
|
669
|
-
test("evaluateStaleness: >=30d is a FAIL", () => {
|
|
670
|
-
const now = Date.UTC(2026, 6, 15);
|
|
671
|
-
const v = evaluateStaleness({ fetched: "2026-06-10", now });
|
|
672
|
-
assert.equal(v.status, "fail");
|
|
673
|
-
assert.ok(v.ageDays >= 30);
|
|
674
|
-
});
|
|
675
|
-
|
|
676
|
-
test("evaluateStaleness: a stale volatile catalog escalates the FAIL reason", () => {
|
|
677
|
-
const now = Date.UTC(2026, 6, 15);
|
|
678
|
-
const v = evaluateStaleness({ fetched: "2026-06-10", now, hasVolatile: true, label: "catalog" });
|
|
679
|
-
assert.equal(v.status, "fail");
|
|
680
|
-
assert.match(v.reason, /volatile/);
|
|
681
|
-
});
|
|
682
|
-
|
|
683
|
-
test("evaluateStaleness: a missing fetch date is a WARN (provenance unknown)", () => {
|
|
684
|
-
const v = evaluateStaleness({ fetched: null });
|
|
685
|
-
assert.equal(v.status, "warn");
|
|
686
|
-
assert.match(v.reason, /no fetch date/);
|
|
687
|
-
});
|
|
688
|
-
|
|
689
|
-
// ---------------------------------------------------------------------------
|
|
690
|
-
// router CLI — `maestro router why` / `maestro router validate`
|
|
691
|
-
// ---------------------------------------------------------------------------
|
|
692
|
-
|
|
693
|
-
const V2_ROUTER_YAML = `
|
|
694
|
-
schema_version: 2
|
|
695
|
-
aliases:
|
|
696
|
-
frontier: anthropic/claude-opus-4-8
|
|
697
|
-
default: anthropic/claude-sonnet-4-6
|
|
698
|
-
fast: anthropic/claude-haiku-4-5
|
|
699
|
-
cheap: deepseek/deepseek-v4-flash
|
|
700
|
-
defaults:
|
|
701
|
-
data_class: sensitive
|
|
702
|
-
backends:
|
|
703
|
-
anthropic: { allowed_data_classes: [public, internal, sensitive] }
|
|
704
|
-
deepseek: { allowed_data_classes: [public] }
|
|
705
|
-
routing_policy:
|
|
706
|
-
- match: { task_class: classify.inbox }
|
|
707
|
-
harness: direct
|
|
708
|
-
chain: [fast, cheap, rules]
|
|
709
|
-
needs_tool_use: false
|
|
710
|
-
- default: true
|
|
711
|
-
chain: [default, fast]
|
|
712
|
-
fallback_to_anthropic: true
|
|
713
|
-
`;
|
|
714
|
-
|
|
715
|
-
async function makeV2RouterAgent() {
|
|
716
|
-
const root = await makeBareAgent();
|
|
717
|
-
writeFileSync(join(root, "config/model-routing.yaml"), V2_ROUTER_YAML);
|
|
718
|
-
return root;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
test("router why: prints chosen + explain + chain for a session request", async () => {
|
|
722
|
-
const root = await makeV2RouterAgent();
|
|
723
|
-
try {
|
|
724
|
-
const r = runCli(["router", "why", "session.responder"], root);
|
|
725
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
726
|
-
assert.match(out, /chosen/);
|
|
727
|
-
assert.match(out, /anthropic\/claude-sonnet-4-6/);
|
|
728
|
-
assert.match(out, /explain/);
|
|
729
|
-
assert.match(out, /chain/);
|
|
730
|
-
} finally { await rmRoot(root); }
|
|
731
|
-
});
|
|
732
|
-
|
|
733
|
-
test("router why: accepts inline request JSON and reports tried[] skips", async () => {
|
|
734
|
-
const root = await makeV2RouterAgent();
|
|
735
|
-
try {
|
|
736
|
-
const r = runCli(
|
|
737
|
-
["router", "why", '{"task_class":"classify.inbox","data_class":"public"}'],
|
|
738
|
-
root
|
|
739
|
-
);
|
|
740
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
741
|
-
// No keys present → cheap deepseek + anthropic haiku-direct are unreachable;
|
|
742
|
-
// the deterministic "rules" fallback wins and the skips are listed.
|
|
743
|
-
assert.match(out, /tried/);
|
|
744
|
-
assert.match(out, /missing_credential/);
|
|
745
|
-
} finally { await rmRoot(root); }
|
|
746
|
-
});
|
|
747
|
-
|
|
748
|
-
test("router validate: a clean v2 config validates (exit 0)", async () => {
|
|
749
|
-
const root = await makeV2RouterAgent();
|
|
750
|
-
try {
|
|
751
|
-
const r = runCli(["router", "validate"], root);
|
|
752
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
753
|
-
assert.equal(r.status, 0, out);
|
|
754
|
-
assert.match(out, /valid/);
|
|
755
|
-
} finally { await rmRoot(root); }
|
|
756
|
-
});
|
|
757
|
-
|
|
758
|
-
test("router validate: an unknown match key fails validation (exit 1)", async () => {
|
|
759
|
-
const root = await makeBareAgent();
|
|
760
|
-
try {
|
|
761
|
-
writeFileSync(
|
|
762
|
-
join(root, "config/model-routing.yaml"),
|
|
763
|
-
"schema_version: 2\naliases:\n default: anthropic/claude-sonnet-4-6\nrouting_policy:\n - match: { taks_class: x }\n chain: [default]\n - default: true\n chain: [default]\n"
|
|
764
|
-
);
|
|
765
|
-
const r = runCli(["router", "validate"], root);
|
|
766
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
767
|
-
assert.equal(r.status, 1, out);
|
|
768
|
-
assert.match(out, /unknown match key/);
|
|
769
|
-
} finally { await rmRoot(root); }
|
|
770
|
-
});
|
|
771
|
-
|
|
772
|
-
test("router help: bare `router` prints usage", async () => {
|
|
773
|
-
const root = await makeBareAgent();
|
|
774
|
-
try {
|
|
775
|
-
const r = runCli(["router"], root);
|
|
776
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
777
|
-
assert.match(out, /router why/);
|
|
778
|
-
assert.match(out, /router validate/);
|
|
779
|
-
} finally { await rmRoot(root); }
|
|
780
|
-
});
|
|
781
|
-
|
|
782
|
-
// ---------------------------------------------------------------------------
|
|
783
|
-
// audit — security-posture audit + attestation (G15)
|
|
784
|
-
// ---------------------------------------------------------------------------
|
|
785
|
-
|
|
786
|
-
const GOOD_AUDIT_SETTINGS = JSON.stringify({
|
|
787
|
-
hooks: {
|
|
788
|
-
PreToolUse: [
|
|
789
|
-
{ matcher: "mcp__claude_ai_Slack__slack_send_message", hooks: [{ type: "command", command: "./scripts/hooks/block-mcp-slack-send.sh" }] },
|
|
790
|
-
{ matcher: "mcp__claude_ai_Gmail__gmail_create_draft", hooks: [{ type: "command", command: "./scripts/hooks/pre-send-audit.sh gmail" }] },
|
|
791
|
-
],
|
|
792
|
-
PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: "./scripts/hooks/post-action-log.sh" }] }],
|
|
793
|
-
Stop: [{ matcher: "", hooks: [{ type: "command", command: "./scripts/hooks/session-end-log.sh" }] }],
|
|
794
|
-
},
|
|
795
|
-
}, null, 2);
|
|
796
|
-
|
|
797
|
-
/** A conformant agent repo (every audit check passes when scoped-perms is set). */
|
|
798
|
-
async function makeConformantAgent() {
|
|
799
|
-
const root = await makeBareAgent();
|
|
800
|
-
mkdirSync(join(root, ".claude"), { recursive: true });
|
|
801
|
-
mkdirSync(join(root, "scripts/hooks"), { recursive: true });
|
|
802
|
-
mkdirSync(join(root, ".maestro"), { recursive: true });
|
|
803
|
-
mkdirSync(join(root, "policies"), { recursive: true });
|
|
804
|
-
// config/agent.json already written by makeBareAgent.
|
|
805
|
-
const envPath = join(root, ".env");
|
|
806
|
-
writeFileSync(envPath, "ANTHROPIC_API_KEY=sk-test\nMAESTRO_SCOPED_PERMISSIONS=1\n");
|
|
807
|
-
chmodSync(envPath, 0o600);
|
|
808
|
-
writeFileSync(join(root, ".claude/settings.json"), GOOD_AUDIT_SETTINGS);
|
|
809
|
-
for (const s of ["block-mcp-slack-send.sh", "pre-send-audit.sh", "post-action-log.sh", "session-end-log.sh"]) {
|
|
810
|
-
writeFileSync(join(root, "scripts/hooks", s), "#!/bin/bash\nexit 0\n");
|
|
811
|
-
}
|
|
812
|
-
writeFileSync(join(root, ".maestro/backup-config.yaml"), "enabled: true\nprovider: s3\nbucket: b\n");
|
|
813
|
-
writeFileSync(join(root, "config/validation-rules.yaml"), "ai_disclosure:\n enabled: true\n severity: block\n");
|
|
814
|
-
writeFileSync(join(root, "policies/action-classification.yaml"), "classes: []\n");
|
|
815
|
-
return root;
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
test("audit: a conformant agent exits 0 and reports posture conformant", async () => {
|
|
819
|
-
const root = await makeConformantAgent();
|
|
820
|
-
try {
|
|
821
|
-
const r = runCli(["audit"], root);
|
|
822
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
823
|
-
assert.equal(r.status, 0, out);
|
|
824
|
-
assert.match(out, /checks passed/);
|
|
825
|
-
assert.match(out, /attestation:/);
|
|
826
|
-
assert.match(out, /Posture conformant/);
|
|
827
|
-
} finally { await rmRoot(root); }
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
test("audit: a group-readable .env makes the run exit non-zero with the fix", async () => {
|
|
831
|
-
const root = await makeConformantAgent();
|
|
832
|
-
try {
|
|
833
|
-
chmodSync(join(root, ".env"), 0o644);
|
|
834
|
-
const r = runCli(["audit"], root);
|
|
835
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
836
|
-
assert.equal(r.status, 1, out);
|
|
837
|
-
assert.match(out, /env-mode-600/);
|
|
838
|
-
assert.match(out, /chmod 600/);
|
|
839
|
-
assert.match(out, /unsuppressed failure/);
|
|
840
|
-
} finally { await rmRoot(root); }
|
|
841
|
-
});
|
|
842
|
-
|
|
843
|
-
test("audit --fix: repairs a group-readable .env and then exits 0", async () => {
|
|
844
|
-
const root = await makeConformantAgent();
|
|
845
|
-
try {
|
|
846
|
-
chmodSync(join(root, ".env"), 0o644);
|
|
847
|
-
const r = runCli(["audit", "--fix"], root);
|
|
848
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
849
|
-
assert.equal(r.status, 0, out);
|
|
850
|
-
assert.match(out, /applied 1 safe fix/);
|
|
851
|
-
assert.equal(statSync(join(root, ".env")).mode & 0o777, 0o600);
|
|
852
|
-
} finally { await rmRoot(root); }
|
|
853
|
-
});
|
|
854
|
-
|
|
855
|
-
test("audit: a suppressed-with-reason failure exits 0", async () => {
|
|
856
|
-
const root = await makeConformantAgent();
|
|
857
|
-
try {
|
|
858
|
-
chmodSync(join(root, ".env"), 0o644); // would fail env-mode-600
|
|
859
|
-
writeFileSync(join(root, "config/audit-suppressions.yaml"),
|
|
860
|
-
"- id: env-mode-600\n reason: ephemeral CI runner\n");
|
|
861
|
-
const r = runCli(["audit"], root);
|
|
862
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
863
|
-
assert.equal(r.status, 0, out);
|
|
864
|
-
assert.match(out, /SUPPRESSED/);
|
|
865
|
-
assert.match(out, /ephemeral CI runner/);
|
|
866
|
-
} finally { await rmRoot(root); }
|
|
867
|
-
});
|
|
868
|
-
|
|
869
|
-
test("audit --json: emits structured results + a hash-locked attestation", async () => {
|
|
870
|
-
const root = await makeConformantAgent();
|
|
871
|
-
try {
|
|
872
|
-
const r = runCli(["audit", "--json"], root);
|
|
873
|
-
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
874
|
-
const doc = JSON.parse(r.stdout);
|
|
875
|
-
assert.equal(doc.ok, true);
|
|
876
|
-
assert.ok(Array.isArray(doc.results) && doc.results.length >= 8);
|
|
877
|
-
assert.equal(typeof doc.attestation.hash, "string");
|
|
878
|
-
assert.equal(doc.attestation.schema, "maestro.audit.attestation.v1");
|
|
879
|
-
assert.equal(doc.attestation.identity.function, "executive-operator");
|
|
880
|
-
} finally { await rmRoot(root); }
|
|
881
|
-
});
|
|
882
|
-
|
|
883
|
-
test("audit --json: missing scoped-permissions makes ok:false and exit 1", async () => {
|
|
884
|
-
const root = await makeConformantAgent();
|
|
885
|
-
try {
|
|
886
|
-
writeFileSync(join(root, ".env"), "ANTHROPIC_API_KEY=sk-test\n"); // drop opt-in
|
|
887
|
-
chmodSync(join(root, ".env"), 0o600);
|
|
888
|
-
const r = runCli(["audit", "--json"], root);
|
|
889
|
-
assert.equal(r.status, 1);
|
|
890
|
-
const doc = JSON.parse(r.stdout);
|
|
891
|
-
assert.equal(doc.ok, false);
|
|
892
|
-
const sp = doc.results.find((x) => x.id === "scoped-permissions-configured");
|
|
893
|
-
assert.equal(sp.ok, false);
|
|
894
|
-
assert.equal(sp.severity, "critical");
|
|
895
|
-
} finally { await rmRoot(root); }
|
|
896
|
-
});
|
|
897
|
-
|
|
898
|
-
test("audit: exits 1 with a clear message outside a Maestro agent dir", async () => {
|
|
899
|
-
const dir = await tmpRoot("maestro-audit-nonagent");
|
|
900
|
-
try {
|
|
901
|
-
const r = runCli(["audit"], dir);
|
|
902
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
903
|
-
assert.equal(r.status, 1);
|
|
904
|
-
assert.match(out, /Not a Maestro agent directory/);
|
|
905
|
-
} finally { await rmRoot(dir); }
|
|
906
|
-
});
|
|
907
|
-
|
|
908
|
-
test("audit --help: prints usage and exits 0", async () => {
|
|
909
|
-
const dir = await tmpRoot("maestro-audit-help");
|
|
910
|
-
try {
|
|
911
|
-
const r = runCli(["audit", "--help"], dir);
|
|
912
|
-
assert.equal(r.status, 0, r.stderr);
|
|
913
|
-
assert.match(r.stdout, /maestro audit/);
|
|
914
|
-
assert.match(r.stdout, /--fix/);
|
|
915
|
-
} finally { await rmRoot(dir); }
|
|
916
|
-
});
|
|
917
|
-
|
|
918
|
-
// ---------------------------------------------------------------------------
|
|
919
|
-
// secrets — list / rotate (subcommand, gaps-enterprise-ops G12)
|
|
920
|
-
// ---------------------------------------------------------------------------
|
|
921
|
-
|
|
922
|
-
test("secrets --help prints usage and exits 0", async () => {
|
|
923
|
-
const dir = await tmpRoot("maestro-secrets-help");
|
|
924
|
-
try {
|
|
925
|
-
const r = runCli(["secrets", "--help"], dir);
|
|
926
|
-
assert.equal(r.status, 0, r.stderr);
|
|
927
|
-
assert.match(r.stdout, /maestro secrets/);
|
|
928
|
-
assert.match(r.stdout, /names only/);
|
|
929
|
-
} finally { await rmRoot(dir); }
|
|
930
|
-
});
|
|
931
|
-
|
|
932
|
-
test("secrets: exits 1 outside a Maestro agent dir", async () => {
|
|
933
|
-
const dir = await tmpRoot("maestro-secrets-nonagent");
|
|
934
|
-
try {
|
|
935
|
-
const r = runCli(["secrets", "list"], dir);
|
|
936
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
937
|
-
assert.equal(r.status, 1);
|
|
938
|
-
assert.match(out, /Not a Maestro agent directory/);
|
|
939
|
-
} finally { await rmRoot(dir); }
|
|
940
|
-
});
|
|
941
|
-
|
|
942
|
-
test("secrets rotate then list shows the NAME but never the value", async () => {
|
|
943
|
-
const root = await makeBareAgent();
|
|
944
|
-
try {
|
|
945
|
-
// config/secrets.yaml selects the local file provider.
|
|
946
|
-
writeFileSync(join(root, "config/secrets.yaml"), "provider: file\n");
|
|
947
|
-
|
|
948
|
-
// Rotate a secret with the value supplied on stdin (never in argv).
|
|
949
|
-
const rot = runCliStdin(["secrets", "rotate", "--name", "DB_PASSWORD"], root, "p@ss-SECRET-123");
|
|
950
|
-
const rotOut = (rot.stdout || "") + (rot.stderr || "");
|
|
951
|
-
assert.equal(rot.status, 0, rotOut);
|
|
952
|
-
assert.match(rotOut, /Rotated "DB_PASSWORD"/);
|
|
953
|
-
|
|
954
|
-
// list shows the name...
|
|
955
|
-
const ls = runCli(["secrets", "list"], root);
|
|
956
|
-
const lsOut = (ls.stdout || "") + (ls.stderr || "");
|
|
957
|
-
assert.equal(ls.status, 0, lsOut);
|
|
958
|
-
assert.match(lsOut, /DB_PASSWORD/);
|
|
959
|
-
// ...but never the value, in either command's output.
|
|
960
|
-
assert.ok(!lsOut.includes("p@ss-SECRET-123"), "list must not print the value");
|
|
961
|
-
assert.ok(!rotOut.includes("p@ss-SECRET-123"), "rotate must not echo the value");
|
|
962
|
-
|
|
963
|
-
// The on-disk store is sealed (ciphertext != plaintext).
|
|
964
|
-
const store = readFileSync(join(root, "state/secrets/.secrets.json"), "utf-8");
|
|
965
|
-
assert.ok(!store.includes("p@ss-SECRET-123"), "value sealed at rest");
|
|
966
|
-
|
|
967
|
-
// The audit log records the name, not the value.
|
|
968
|
-
const audit = readFileSync(join(root, "logs/audit/secrets.jsonl"), "utf-8");
|
|
969
|
-
assert.match(audit, /DB_PASSWORD/);
|
|
970
|
-
assert.ok(!audit.includes("p@ss-SECRET-123"), "audit is names-only");
|
|
971
|
-
} finally { await rmRoot(root); }
|
|
972
|
-
});
|
|
973
|
-
|
|
974
|
-
test("secrets rotate without --name exits 1 with usage", async () => {
|
|
975
|
-
const root = await makeBareAgent();
|
|
976
|
-
try {
|
|
977
|
-
const r = runCliStdin(["secrets", "rotate"], root, "v");
|
|
978
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
979
|
-
assert.equal(r.status, 1);
|
|
980
|
-
assert.match(out, /--name/);
|
|
981
|
-
} finally { await rmRoot(root); }
|
|
982
|
-
});
|
|
983
|
-
|
|
984
|
-
test("secrets sync without a configured broker exits 1", async () => {
|
|
985
|
-
const root = await makeBareAgent();
|
|
986
|
-
try {
|
|
987
|
-
writeFileSync(join(root, "config/secrets.yaml"), "provider: file\n");
|
|
988
|
-
const r = runCli(["secrets", "sync"], root);
|
|
989
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
990
|
-
assert.equal(r.status, 1);
|
|
991
|
-
assert.match(out, /No broker configured/);
|
|
992
|
-
} finally { await rmRoot(root); }
|
|
993
|
-
});
|
|
994
|
-
|
|
995
|
-
// ---------------------------------------------------------------------------
|
|
996
|
-
// doctor — backup-config DR check (gaps-enterprise-ops G16)
|
|
997
|
-
// ---------------------------------------------------------------------------
|
|
998
|
-
|
|
999
|
-
test("doctor RED when there is no DR posture at all, and names the one command that fixes it", async () => {
|
|
1000
|
-
const root = await makeBareAgent();
|
|
1001
|
-
try {
|
|
1002
|
-
const r = runCli(["doctor"], root);
|
|
1003
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
1004
|
-
assert.match(out, /No DR posture at all/);
|
|
1005
|
-
assert.match(out, /backup-config\.yaml/);
|
|
1006
|
-
assert.match(out, /maestro init backup-replication --apply/);
|
|
1007
|
-
// doctor exits 1 whenever any issue is found; the missing backup is one.
|
|
1008
|
-
assert.equal(r.status, 1, out);
|
|
1009
|
-
} finally { await rmRoot(root); }
|
|
1010
|
-
});
|
|
1011
|
-
|
|
1012
|
-
test("doctor RED when backup is enabled but has never actually run", async () => {
|
|
1013
|
-
const root = await makeBareAgent();
|
|
1014
|
-
try {
|
|
1015
|
-
mkdirSync(join(root, ".maestro"), { recursive: true });
|
|
1016
|
-
writeFileSync(join(root, ".maestro/backup-config.yaml"), "enabled: true\nprefix: t\n");
|
|
1017
|
-
const r = runCli(["doctor"], root);
|
|
1018
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
1019
|
-
assert.match(out, /NEVER completed a run/);
|
|
1020
|
-
assert.match(out, /backup-run\.mjs/);
|
|
1021
|
-
} finally { await rmRoot(root); }
|
|
1022
|
-
});
|
|
1023
|
-
|
|
1024
|
-
test("doctor WARNS (not GREEN) when restore points are fresh but local-only", async () => {
|
|
1025
|
-
const root = await makeBareAgent();
|
|
1026
|
-
try {
|
|
1027
|
-
mkdirSync(join(root, ".maestro"), { recursive: true });
|
|
1028
|
-
writeFileSync(join(root, ".maestro/backup-config.yaml"), "enabled: true\nprefix: t\nlocal:\n enabled: true\n");
|
|
1029
|
-
writeFileSync(join(root, ".maestro/last-backup.json"), JSON.stringify({
|
|
1030
|
-
completed_at: new Date().toISOString(), tier: "local", provider: "local",
|
|
1031
|
-
}));
|
|
1032
|
-
const r = runCli(["doctor"], root);
|
|
1033
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
1034
|
-
assert.match(out, /LOCAL-ONLY/);
|
|
1035
|
-
assert.match(out, /offsite\.provider/);
|
|
1036
|
-
assert.ok(!out.includes("No DR posture at all"), "must not claim the config is missing");
|
|
1037
|
-
} finally { await rmRoot(root); }
|
|
1038
|
-
});
|
|
1039
|
-
|
|
1040
|
-
test("doctor GREEN for backup only when it is fresh AND off-machine", async () => {
|
|
1041
|
-
const root = await makeBareAgent();
|
|
1042
|
-
try {
|
|
1043
|
-
mkdirSync(join(root, ".maestro"), { recursive: true });
|
|
1044
|
-
writeFileSync(
|
|
1045
|
-
join(root, ".maestro/backup-config.yaml"),
|
|
1046
|
-
"enabled: true\nprefix: t\noffsite:\n provider: s3\n bucket: agent-backups\n"
|
|
1047
|
-
);
|
|
1048
|
-
writeFileSync(join(root, ".maestro/last-backup.json"), JSON.stringify({
|
|
1049
|
-
completed_at: new Date().toISOString(), tier: "offsite", provider: "s3", bucket: "agent-backups",
|
|
1050
|
-
}));
|
|
1051
|
-
const r = runCli(["doctor"], root);
|
|
1052
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
1053
|
-
assert.match(out, /Backup fresh \+ off-machine/);
|
|
1054
|
-
assert.ok(!out.includes("No DR posture at all"), "must not flag missing backup when fresh");
|
|
1055
|
-
assert.ok(!out.includes("LOCAL-ONLY"), "offsite is configured");
|
|
1056
|
-
} finally { await rmRoot(root); }
|
|
1057
|
-
});
|
|
1058
|
-
|
|
1059
|
-
test("doctor reports an include path that the credential deny-list drops", async () => {
|
|
1060
|
-
const root = await makeBareAgent();
|
|
1061
|
-
try {
|
|
1062
|
-
mkdirSync(join(root, ".maestro"), { recursive: true });
|
|
1063
|
-
writeFileSync(
|
|
1064
|
-
join(root, ".maestro/backup-config.yaml"),
|
|
1065
|
-
"enabled: true\nprefix: t\ninclude:\n - state\n - .env\n"
|
|
1066
|
-
);
|
|
1067
|
-
writeFileSync(join(root, ".maestro/last-backup.json"), JSON.stringify({
|
|
1068
|
-
completed_at: new Date().toISOString(), tier: "local",
|
|
1069
|
-
}));
|
|
1070
|
-
const r = runCli(["doctor"], root);
|
|
1071
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
1072
|
-
assert.match(out, /DROPPED by the credential deny-list/);
|
|
1073
|
-
} finally { await rmRoot(root); }
|
|
1074
|
-
});
|
|
1075
|
-
|
|
1076
|
-
// ---------------------------------------------------------------------------
|
|
1077
|
-
// framework-features.json — registry validity + new-system enrollment
|
|
1078
|
-
// (registry-scaffold-exports integration lane)
|
|
1079
|
-
// ---------------------------------------------------------------------------
|
|
1080
|
-
|
|
1081
|
-
test("framework-features.json is valid JSON and registers the new systems", async () => {
|
|
1082
|
-
const raw = readFileSync(join(MAESTRO_ROOT, "framework-features.json"), "utf-8");
|
|
1083
|
-
const reg = JSON.parse(raw); // throws if invalid → test fails
|
|
1084
|
-
assert.ok(reg.features && typeof reg.features === "object", "features map present");
|
|
1085
|
-
const ids = Object.keys(reg.features);
|
|
1086
|
-
// Every new system added by this lane must be enrolled in the registry.
|
|
1087
|
-
for (const id of [
|
|
1088
|
-
"org-cohort", "org-credential-broker", "secrets-lifecycle",
|
|
1089
|
-
"diagnostics-spine", "alerting", "hook-bus", "dynamic-scheduling",
|
|
1090
|
-
"send-gate", "knowledge-plane",
|
|
1091
|
-
]) {
|
|
1092
|
-
assert.ok(ids.includes(id), `framework-features.json missing feature id: ${id}`);
|
|
1093
|
-
}
|
|
1094
|
-
// Pre-existing core systems still enrolled (no regression).
|
|
1095
|
-
for (const id of ["model-router", "cadence-bus"]) {
|
|
1096
|
-
assert.ok(ids.includes(id), `framework-features.json lost feature id: ${id}`);
|
|
1097
|
-
}
|
|
1098
|
-
// Schema fidelity: each new entry matches the existing feature schema.
|
|
1099
|
-
for (const id of ["org-cohort", "secrets-lifecycle", "diagnostics-spine"]) {
|
|
1100
|
-
const f = reg.features[id];
|
|
1101
|
-
assert.equal(typeof f.version, "string", `${id}.version`);
|
|
1102
|
-
assert.equal(typeof f.since, "string", `${id}.since`);
|
|
1103
|
-
assert.equal(typeof f.title, "string", `${id}.title`);
|
|
1104
|
-
assert.ok(f.init && typeof f.init === "object", `${id}.init`);
|
|
1105
|
-
assert.equal(typeof f.init.auto, "boolean", `${id}.init.auto`);
|
|
1106
|
-
assert.equal(typeof f.init.command, "string", `${id}.init.command`);
|
|
1107
|
-
assert.equal(typeof f.init.description, "string", `${id}.init.description`);
|
|
1108
|
-
// init.command must be a single-line string (feature-init trust-boundary guard).
|
|
1109
|
-
assert.ok(!/[\0\r\n]/.test(f.init.command), `${id}.init.command must be single-line`);
|
|
1110
|
-
}
|
|
1111
|
-
// Honest enablement: org/cohort + its broker default OFF until configured.
|
|
1112
|
-
assert.equal(reg.features["org-cohort"].init.auto, false, "org-cohort must not auto-enroll");
|
|
1113
|
-
assert.equal(reg.features["org-credential-broker"].init.auto, false, "broker must not auto-enable");
|
|
1114
|
-
});
|
|
1115
|
-
|
|
1116
|
-
// ---------------------------------------------------------------------------
|
|
1117
|
-
// create — ships the new disabled-by-default scaffold configs
|
|
1118
|
-
// ---------------------------------------------------------------------------
|
|
1119
|
-
|
|
1120
|
-
test("create ships disabled-by-default org.yaml + secrets.yaml templates", async () => {
|
|
1121
|
-
const parent = await tmpRoot("maestro-create-cfg");
|
|
1122
|
-
const { env, dir: shim } = await withShim();
|
|
1123
|
-
try {
|
|
1124
|
-
const r = runCli(["create", "cfg-agent"], parent, env);
|
|
1125
|
-
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
1126
|
-
const agent = join(parent, "cfg-agent");
|
|
1127
|
-
|
|
1128
|
-
const orgYaml = join(agent, "config/org.yaml");
|
|
1129
|
-
const secretsYaml = join(agent, "config/secrets.yaml");
|
|
1130
|
-
assert.ok(existsSync(orgYaml), "config/org.yaml must be scaffolded");
|
|
1131
|
-
assert.ok(existsSync(secretsYaml), "config/secrets.yaml must be scaffolded");
|
|
1132
|
-
|
|
1133
|
-
// org.yaml is disabled by default (the agent runs standalone).
|
|
1134
|
-
const orgBody = readFileSync(orgYaml, "utf-8");
|
|
1135
|
-
assert.match(orgBody, /enabled:\s*false/, "org enrollment must default OFF");
|
|
1136
|
-
assert.match(orgBody, /cohort/);
|
|
1137
|
-
|
|
1138
|
-
// secrets.yaml defaults to the local file provider.
|
|
1139
|
-
const secBody = readFileSync(secretsYaml, "utf-8");
|
|
1140
|
-
assert.match(secBody, /provider:\s*file/, "secrets must default to the local file provider");
|
|
1141
|
-
} finally {
|
|
1142
|
-
await rmRoot(parent);
|
|
1143
|
-
await rmRoot(shim);
|
|
1144
|
-
}
|
|
1145
|
-
});
|
|
1146
|
-
|
|
1147
|
-
// ---------------------------------------------------------------------------
|
|
1148
|
-
// upgrade — propagates new config templates to existing agents (add-only)
|
|
1149
|
-
// ---------------------------------------------------------------------------
|
|
1150
|
-
|
|
1151
|
-
test("upgrade adds new scaffold config templates without clobbering existing ones", async () => {
|
|
1152
|
-
const root = await makeLegacyAgent();
|
|
1153
|
-
try {
|
|
1154
|
-
// Seed an operator-customised alerts.yaml that upgrade must NOT overwrite.
|
|
1155
|
-
mkdirSync(join(root, "config"), { recursive: true });
|
|
1156
|
-
const customAlerts = "alerts:\n webhook: https://hooks.example/custom\n";
|
|
1157
|
-
writeFileSync(join(root, "config/alerts.yaml"), customAlerts);
|
|
1158
|
-
execFileSync("git", ["add", "-A"], { cwd: root });
|
|
1159
|
-
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "seed alerts"], { cwd: root });
|
|
1160
|
-
|
|
1161
|
-
const r = runCli(["upgrade"], root);
|
|
1162
|
-
assert.equal(r.status, 0, r.stderr);
|
|
1163
|
-
|
|
1164
|
-
// New templates landed.
|
|
1165
|
-
assert.ok(existsSync(join(root, "config/org.yaml")), "org.yaml must be delivered on upgrade");
|
|
1166
|
-
assert.ok(existsSync(join(root, "config/secrets.yaml")), "secrets.yaml must be delivered on upgrade");
|
|
1167
|
-
assert.match(readFileSync(join(root, "config/org.yaml"), "utf-8"), /enabled:\s*false/);
|
|
1168
|
-
|
|
1169
|
-
// Existing operator config preserved byte-for-byte.
|
|
1170
|
-
assert.equal(readFileSync(join(root, "config/alerts.yaml"), "utf-8"), customAlerts,
|
|
1171
|
-
"upgrade must not clobber an existing operator config");
|
|
1172
|
-
} finally { await rmRoot(root); }
|
|
1173
|
-
});
|
|
1174
|
-
|
|
1175
|
-
// ---------------------------------------------------------------------------
|
|
1176
|
-
// doctor — surfaces the new framework systems
|
|
1177
|
-
// ---------------------------------------------------------------------------
|
|
1178
|
-
|
|
1179
|
-
test("doctor surfaces the new framework systems (org / secrets / diagnostics)", async () => {
|
|
1180
|
-
// Use a freshly-created agent so lib/* + the scaffold configs are present.
|
|
1181
|
-
const parent = await tmpRoot("maestro-doctor-systems");
|
|
1182
|
-
const { env, dir: shim } = await withShim();
|
|
1183
|
-
try {
|
|
1184
|
-
const cr = runCli(["create", "doc-agent"], parent, env);
|
|
1185
|
-
assert.equal(cr.status, 0, cr.stderr || cr.stdout);
|
|
1186
|
-
const agent = join(parent, "doc-agent");
|
|
1187
|
-
const r = runCli(["doctor"], agent);
|
|
1188
|
-
const out = (r.stdout || "") + (r.stderr || "");
|
|
1189
|
-
// 2.0: the presence-only org line became real connectivity probes; a fresh
|
|
1190
|
-
// (unenrolled) create short-circuits on the single "not enrolled" line.
|
|
1191
|
-
assert.match(out, /Org plane: not enrolled|Org enrollment resolved/);
|
|
1192
|
-
assert.match(out, /Secret lifecycle/);
|
|
1193
|
-
assert.match(out, /Diagnostics spine present/);
|
|
1194
|
-
assert.match(out, /Alerting configured/);
|
|
1195
|
-
// None of the new lib modules should be flagged missing in a fresh create.
|
|
1196
|
-
assert.ok(!/module missing/.test(out), "fresh create must ship every new lib module");
|
|
1197
|
-
} finally {
|
|
1198
|
-
await rmRoot(parent);
|
|
1199
|
-
await rmRoot(shim);
|
|
1200
|
-
}
|
|
1201
|
-
});
|
|
1202
|
-
|
|
1203
|
-
// ---------------------------------------------------------------------------
|
|
1204
|
-
// upgrade — prune (framework files deleted upstream)
|
|
1205
|
-
//
|
|
1206
|
-
// The copy loop only ever adds and overwrites, so before this every file the
|
|
1207
|
-
// SDK had ever shipped stayed resident on an agent machine forever. Orphaned
|
|
1208
|
-
// *modules* keep importing symbols their collaborators no longer export, so
|
|
1209
|
-
// they fail permanently and drag the agent's own suite red — found in the
|
|
1210
|
-
// field on Isla's machine, where a stale scripts/daemon/responder.test.mjs
|
|
1211
|
-
// survived the transport refactor still importing a deleted export.
|
|
1212
|
-
//
|
|
1213
|
-
// Prune's contract is narrow on purpose: it removes a file only when git
|
|
1214
|
-
// proves it is tracked-and-clean, i.e. pristine framework material the
|
|
1215
|
-
// operator never touched. Everything else is kept and reported.
|
|
1216
|
-
// ---------------------------------------------------------------------------
|
|
1217
|
-
|
|
1218
|
-
/**
|
|
1219
|
-
* Agent repo carrying `orphans` — paths upstream does not ship. Written before
|
|
1220
|
-
* the commit so git sees them as tracked and clean (prune's precondition).
|
|
1221
|
-
*/
|
|
1222
|
-
async function makeAgentWithOrphans(orphans, opts = {}) {
|
|
1223
|
-
const root = await tmpRoot("maestro-orphan");
|
|
1224
|
-
mkdirSync(join(root, "config"), { recursive: true });
|
|
1225
|
-
writeFileSync(join(root, "CLAUDE.md"), "# agent\n");
|
|
1226
|
-
writeFileSync(join(root, "config/agent.ts"), "export const AGENT = { firstName: 'orphan' };\n");
|
|
1227
|
-
writeFileSync(join(root, "package.json"), '{"name":"orphan-agent","version":"1.0.0"}\n');
|
|
1228
|
-
for (const [rel, body] of Object.entries(orphans)) {
|
|
1229
|
-
mkdirSync(dirname(join(root, rel)), { recursive: true });
|
|
1230
|
-
writeFileSync(join(root, rel), body);
|
|
1231
|
-
}
|
|
1232
|
-
// Provenance: prune only touches files a PREVIOUS upgrade recorded shipping.
|
|
1233
|
-
// Without this the orphans are indistinguishable from operator-authored work
|
|
1234
|
-
// and prune correctly refuses to act — so every prune test must seed it.
|
|
1235
|
-
if (opts.seedManifest !== false) {
|
|
1236
|
-
mkdirSync(join(root, ".maestro"), { recursive: true });
|
|
1237
|
-
writeFileSync(
|
|
1238
|
-
join(root, ".maestro/shipped-manifest.json"),
|
|
1239
|
-
JSON.stringify({ version: 1, files: Object.keys(orphans).sort() }, null, 2) + "\n",
|
|
1240
|
-
);
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
execFileSync("git", ["init", "-q"], { cwd: root });
|
|
1244
|
-
execFileSync("git", ["add", "-A"], { cwd: root });
|
|
1245
|
-
execFileSync(
|
|
1246
|
-
"git",
|
|
1247
|
-
["-c", "user.email=test@test", "-c", "user.name=test", "commit", "-q", "-m", "init"],
|
|
1248
|
-
{ cwd: root },
|
|
1249
|
-
);
|
|
1250
|
-
return root;
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
const ORPHAN = "lib/zz-deleted-upstream-fixture.mjs";
|
|
1254
|
-
|
|
1255
|
-
test("upgrade prunes a pristine framework file deleted upstream, with a backup", async () => {
|
|
1256
|
-
const root = await makeAgentWithOrphans({ [ORPHAN]: "export const gone = 1;\n" });
|
|
1257
|
-
try {
|
|
1258
|
-
const r = runCli(["upgrade"], root);
|
|
1259
|
-
assert.equal(r.status, 0, r.stderr);
|
|
1260
|
-
assert.ok(!existsSync(join(root, ORPHAN)), "orphan should have been pruned");
|
|
1261
|
-
assert.ok(
|
|
1262
|
-
existsSync(join(root, ".maestro/backup", ORPHAN)),
|
|
1263
|
-
"prune must be reversible — a backup is required",
|
|
1264
|
-
);
|
|
1265
|
-
assert.equal(
|
|
1266
|
-
readFileSync(join(root, ".maestro/backup", ORPHAN), "utf-8"),
|
|
1267
|
-
"export const gone = 1;\n",
|
|
1268
|
-
"backup must hold the original bytes",
|
|
1269
|
-
);
|
|
1270
|
-
} finally {
|
|
1271
|
-
await fsp.rm(root, { recursive: true, force: true });
|
|
1272
|
-
}
|
|
1273
|
-
});
|
|
1274
|
-
|
|
1275
|
-
test("upgrade never prunes a file the operator edited", async () => {
|
|
1276
|
-
const root = await makeAgentWithOrphans({ [ORPHAN]: "export const gone = 1;\n" });
|
|
1277
|
-
try {
|
|
1278
|
-
writeFileSync(join(root, ORPHAN), "export const gone = 2; // my change\n");
|
|
1279
|
-
const r = runCli(["upgrade"], root);
|
|
1280
|
-
assert.equal(r.status, 0, r.stderr);
|
|
1281
|
-
assert.ok(existsSync(join(root, ORPHAN)), "an edited orphan is the operator's, not ours to delete");
|
|
1282
|
-
assert.match(
|
|
1283
|
-
readFileSync(join(root, ORPHAN), "utf-8"),
|
|
1284
|
-
/my change/,
|
|
1285
|
-
"the operator's bytes must survive verbatim",
|
|
1286
|
-
);
|
|
1287
|
-
assert.match(r.stdout, /kept/i, "the kept orphan must be reported, not silently left");
|
|
1288
|
-
} finally {
|
|
1289
|
-
await fsp.rm(root, { recursive: true, force: true });
|
|
1290
|
-
}
|
|
1291
|
-
});
|
|
1292
|
-
|
|
1293
|
-
test("upgrade never prunes an untracked file", async () => {
|
|
1294
|
-
const root = await makeAgentWithOrphans({});
|
|
1295
|
-
try {
|
|
1296
|
-
// Written after the commit → untracked → indistinguishable from operator work.
|
|
1297
|
-
mkdirSync(join(root, "lib"), { recursive: true });
|
|
1298
|
-
writeFileSync(join(root, ORPHAN), "export const mine = 1;\n");
|
|
1299
|
-
const r = runCli(["upgrade"], root);
|
|
1300
|
-
assert.equal(r.status, 0, r.stderr);
|
|
1301
|
-
assert.ok(existsSync(join(root, ORPHAN)), "untracked files are never prune candidates");
|
|
1302
|
-
} finally {
|
|
1303
|
-
await fsp.rm(root, { recursive: true, force: true });
|
|
1304
|
-
}
|
|
1305
|
-
});
|
|
1306
|
-
|
|
1307
|
-
test("upgrade --no-prune leaves upstream-deleted files in place", async () => {
|
|
1308
|
-
const root = await makeAgentWithOrphans({ [ORPHAN]: "export const gone = 1;\n" });
|
|
1309
|
-
try {
|
|
1310
|
-
const r = runCli(["upgrade", "--no-prune"], root);
|
|
1311
|
-
assert.equal(r.status, 0, r.stderr);
|
|
1312
|
-
assert.ok(existsSync(join(root, ORPHAN)), "--no-prune must suppress removal entirely");
|
|
1313
|
-
} finally {
|
|
1314
|
-
await fsp.rm(root, { recursive: true, force: true });
|
|
1315
|
-
}
|
|
1316
|
-
});
|
|
1317
|
-
|
|
1318
|
-
test("upgrade --dry-run reports a prune without performing it", async () => {
|
|
1319
|
-
const root = await makeAgentWithOrphans({ [ORPHAN]: "export const gone = 1;\n" });
|
|
1320
|
-
try {
|
|
1321
|
-
const r = runCli(["upgrade", "--dry-run"], root);
|
|
1322
|
-
assert.equal(r.status, 0, r.stderr);
|
|
1323
|
-
assert.ok(existsSync(join(root, ORPHAN)), "dry run must not delete");
|
|
1324
|
-
assert.ok(!existsSync(join(root, ".maestro/backup", ORPHAN)), "dry run must not write backups");
|
|
1325
|
-
assert.match(r.stdout, /prune/i, "dry run must still tell the operator what it would remove");
|
|
1326
|
-
} finally {
|
|
1327
|
-
await fsp.rm(root, { recursive: true, force: true });
|
|
1328
|
-
}
|
|
1329
|
-
});
|
|
1330
|
-
|
|
1331
|
-
test("upgrade honours .maestroignore over prune", async () => {
|
|
1332
|
-
const root = await makeAgentWithOrphans({
|
|
1333
|
-
[ORPHAN]: "export const gone = 1;\n",
|
|
1334
|
-
".maestroignore": `${ORPHAN}\n`,
|
|
1335
|
-
});
|
|
1336
|
-
try {
|
|
1337
|
-
const r = runCli(["upgrade"], root);
|
|
1338
|
-
assert.equal(r.status, 0, r.stderr);
|
|
1339
|
-
assert.ok(existsSync(join(root, ORPHAN)), ".maestroignore must win over prune, as it does over overwrite");
|
|
1340
|
-
} finally {
|
|
1341
|
-
await fsp.rm(root, { recursive: true, force: true });
|
|
1342
|
-
}
|
|
1343
|
-
});
|
|
1344
|
-
|
|
1345
|
-
test("upgrade prune leaves files that upstream still ships", async () => {
|
|
1346
|
-
const root = await makeAgentWithOrphans({});
|
|
1347
|
-
try {
|
|
1348
|
-
const r = runCli(["upgrade"], root);
|
|
1349
|
-
assert.equal(r.status, 0, r.stderr);
|
|
1350
|
-
// A file the SDK genuinely ships must survive — the guard against a prune
|
|
1351
|
-
// that mistakes "present upstream" for "absent" and empties the repo.
|
|
1352
|
-
assert.ok(
|
|
1353
|
-
existsSync(join(root, "lib/identity/persona.mjs")),
|
|
1354
|
-
"currently-shipped framework files must never be pruned",
|
|
1355
|
-
);
|
|
1356
|
-
assert.ok(existsSync(join(root, "scripts/daemon/responder.mjs")));
|
|
1357
|
-
} finally {
|
|
1358
|
-
await fsp.rm(root, { recursive: true, force: true });
|
|
1359
|
-
}
|
|
1360
|
-
});
|
|
1361
|
-
|
|
1362
|
-
test("upgrade prune never touches merge-mode agents/", async () => {
|
|
1363
|
-
const custom = "agents/my-private-agent.md";
|
|
1364
|
-
const root = await makeAgentWithOrphans({ [custom]: "# mine\n" });
|
|
1365
|
-
try {
|
|
1366
|
-
const r = runCli(["upgrade"], root);
|
|
1367
|
-
assert.equal(r.status, 0, r.stderr);
|
|
1368
|
-
assert.ok(
|
|
1369
|
-
existsSync(join(root, custom)),
|
|
1370
|
-
"agents/ is merge-mode: local-only files there are the point, not orphans",
|
|
1371
|
-
);
|
|
1372
|
-
} finally {
|
|
1373
|
-
await fsp.rm(root, { recursive: true, force: true });
|
|
1374
|
-
}
|
|
1375
|
-
});
|
|
1376
|
-
|
|
1377
|
-
test("upgrade prune never removes machine-generated launchd plists", async () => {
|
|
1378
|
-
// A committed plist is tracked, clean, and absent upstream — it satisfies
|
|
1379
|
-
// every safety check prune has, yet it is this machine's schedule, not
|
|
1380
|
-
// framework material. Regression: an early prune deleted the very plist the
|
|
1381
|
-
// cadence-bus migration was about to back up and regenerate.
|
|
1382
|
-
const plist = "scripts/local-triggers/plists/ai.adaptic.some-trigger.plist";
|
|
1383
|
-
const root = await makeAgentWithOrphans({ [plist]: "<?xml version=\"1.0\"?><plist/>" });
|
|
1384
|
-
try {
|
|
1385
|
-
const r = runCli(["upgrade"], root);
|
|
1386
|
-
assert.equal(r.status, 0, r.stderr);
|
|
1387
|
-
// Prune backs a file up under .maestro/backup/<path> before unlinking it
|
|
1388
|
-
// and lists it as "pruned"; neither may happen here. (The file itself is
|
|
1389
|
-
// gone after the run since WP-M6 — generate-plists.sh runs unconditionally
|
|
1390
|
-
// as the first post-step and, as it always has, clears scripts/local-
|
|
1391
|
-
// triggers/plists/ before rendering this machine's set. That is the
|
|
1392
|
-
// generator owning its directory, not prune eating machine config.)
|
|
1393
|
-
assert.ok(
|
|
1394
|
-
!existsSync(join(root, ".maestro", "backup", plist)),
|
|
1395
|
-
"generated plists are machine config; prune must leave them alone (no prune backup)",
|
|
1396
|
-
);
|
|
1397
|
-
assert.ok(!/pruned/.test(r.stdout), `prune must not report the plist:\n${r.stdout}`);
|
|
1398
|
-
} finally {
|
|
1399
|
-
await fsp.rm(root, { recursive: true, force: true });
|
|
1400
|
-
}
|
|
1401
|
-
});
|
|
1402
|
-
|
|
1403
|
-
test("upgrade prune spares operator-authored files the framework never shipped", async () => {
|
|
1404
|
-
// The hazard that forced the provenance manifest. scripts/ is a smart root,
|
|
1405
|
-
// so an operator's own committed script there is tracked, clean and absent
|
|
1406
|
-
// upstream — every signal git can offer says "orphan". On a real agent
|
|
1407
|
-
// machine this set included the pre-commit hook guarding a live org
|
|
1408
|
-
// credential. Provenance, not cleanliness, is what makes prune safe.
|
|
1409
|
-
const mine = "scripts/pre-commit-secret-scan.sh";
|
|
1410
|
-
const root = await makeAgentWithOrphans({
|
|
1411
|
-
[mine]: "#!/bin/sh\nexit 0\n",
|
|
1412
|
-
[ORPHAN]: "export const gone = 1;\n",
|
|
1413
|
-
});
|
|
1414
|
-
try {
|
|
1415
|
-
// Manifest records ONLY the framework file — the operator's script was
|
|
1416
|
-
// never delivered by the SDK, so it must not be a prune candidate.
|
|
1417
|
-
writeFileSync(
|
|
1418
|
-
join(root, ".maestro/shipped-manifest.json"),
|
|
1419
|
-
JSON.stringify({ version: 1, files: [ORPHAN] }, null, 2) + "\n",
|
|
1420
|
-
);
|
|
1421
|
-
execFileSync("git", ["add", "-A"], { cwd: root });
|
|
1422
|
-
execFileSync(
|
|
1423
|
-
"git",
|
|
1424
|
-
["-c", "user.email=test@test", "-c", "user.name=test", "commit", "-q", "-m", "manifest"],
|
|
1425
|
-
{ cwd: root },
|
|
1426
|
-
);
|
|
1427
|
-
|
|
1428
|
-
const r = runCli(["upgrade"], root);
|
|
1429
|
-
assert.equal(r.status, 0, r.stderr);
|
|
1430
|
-
assert.ok(existsSync(join(root, mine)), "a file the framework never shipped is the operator's");
|
|
1431
|
-
assert.ok(!existsSync(join(root, ORPHAN)), "the genuinely-shipped orphan should still prune");
|
|
1432
|
-
} finally {
|
|
1433
|
-
await fsp.rm(root, { recursive: true, force: true });
|
|
1434
|
-
}
|
|
1435
|
-
});
|
|
1436
|
-
|
|
1437
|
-
test("upgrade prunes nothing on a machine with no shipped-manifest, then records one", async () => {
|
|
1438
|
-
const root = await makeAgentWithOrphans({ [ORPHAN]: "export const gone = 1;\n" }, { seedManifest: false });
|
|
1439
|
-
try {
|
|
1440
|
-
const first = runCli(["upgrade"], root);
|
|
1441
|
-
assert.equal(first.status, 0, first.stderr);
|
|
1442
|
-
assert.ok(
|
|
1443
|
-
existsSync(join(root, ORPHAN)),
|
|
1444
|
-
"with no provenance record, prune must do nothing rather than guess",
|
|
1445
|
-
);
|
|
1446
|
-
assert.ok(
|
|
1447
|
-
existsSync(join(root, ".maestro/shipped-manifest.json")),
|
|
1448
|
-
"the first run must record provenance so the next one can prune",
|
|
1449
|
-
);
|
|
1450
|
-
const manifest = JSON.parse(readFileSync(join(root, ".maestro/shipped-manifest.json"), "utf-8"));
|
|
1451
|
-
assert.ok(manifest.files.includes("scripts/daemon/responder.mjs"), "manifest lists shipped files");
|
|
1452
|
-
assert.ok(!manifest.files.includes(ORPHAN), "manifest must not list what upstream doesn't ship");
|
|
1453
|
-
} finally {
|
|
1454
|
-
await fsp.rm(root, { recursive: true, force: true });
|
|
1455
|
-
}
|
|
1456
|
-
});
|
|
1457
|
-
|
|
1458
|
-
test("upgrade --dry-run writes no shipped-manifest", async () => {
|
|
1459
|
-
const root = await makeAgentWithOrphans({}, { seedManifest: false });
|
|
1460
|
-
try {
|
|
1461
|
-
const r = runCli(["upgrade", "--dry-run"], root);
|
|
1462
|
-
assert.equal(r.status, 0, r.stderr);
|
|
1463
|
-
assert.ok(
|
|
1464
|
-
!existsSync(join(root, ".maestro/shipped-manifest.json")),
|
|
1465
|
-
"--dry-run must stay side-effect-free, including provenance",
|
|
1466
|
-
);
|
|
1467
|
-
} finally {
|
|
1468
|
-
await fsp.rm(root, { recursive: true, force: true });
|
|
1469
|
-
}
|
|
1470
|
-
});
|
|
1471
|
-
|
|
1472
|
-
// ---------------------------------------------------------------------------
|
|
1473
|
-
// upgrade — seat reconcile post-steps (WP-M6): the first hop from an old
|
|
1474
|
-
// version must leave the machine wholly on the new architecture by itself.
|
|
1475
|
-
// ---------------------------------------------------------------------------
|
|
1476
|
-
|
|
1477
|
-
/** makeLegacyAgent + a NAMED config/agent.json, so plist labels and the identity block use "legacy". */
|
|
1478
|
-
async function makeNamedLegacyAgent() {
|
|
1479
|
-
const root = await makeLegacyAgent();
|
|
1480
|
-
writeFileSync(join(root, "config/agent.json"), JSON.stringify({ firstName: "Legacy", lastName: "Tester", fullName: "Legacy Tester" }, null, 2));
|
|
1481
|
-
execFileSync("git", ["add", "-A"], { cwd: root });
|
|
1482
|
-
execFileSync("git", ["-c", "user.email=test@test", "-c", "user.name=test", "commit", "-q", "-m", "name"], { cwd: root });
|
|
1483
|
-
return root;
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
test("upgrade installs the -session plist into $HOME/Library/LaunchAgents (600, bootstrapped), never kickstarts daemon/session, records sdkVersion in the manifest and writes .maestro/upgrade-result.json", async () => {
|
|
1487
|
-
const root = await makeNamedLegacyAgent();
|
|
1488
|
-
const home = await freshHome();
|
|
1489
|
-
try {
|
|
1490
|
-
// An existing seat: the daemon job is installed (old content) and loaded.
|
|
1491
|
-
writeFileSync(join(home, "Library", "LaunchAgents", "ai.maestro.legacy-daemon.plist"), "<plist>old daemon</plist>");
|
|
1492
|
-
const r = runCli(["upgrade"], root, baseEnv({ HOME: home }));
|
|
1493
|
-
assert.equal(r.status, 0, r.stderr + r.stdout);
|
|
1494
|
-
|
|
1495
|
-
const la = join(home, "Library", "LaunchAgents");
|
|
1496
|
-
const sessionPlist = join(la, "ai.maestro.legacy-session.plist");
|
|
1497
|
-
assert.ok(existsSync(sessionPlist), `session plist must be installed; stdout:\n${r.stdout}`);
|
|
1498
|
-
assert.equal(statSync(sessionPlist).mode & 0o777, 0o600);
|
|
1499
|
-
assert.match(readFileSync(sessionPlist, "utf-8"), /scripts\/session\/supervisor\.sh/);
|
|
1500
|
-
assert.match(readFileSync(join(la, "ai.maestro.legacy-daemon.plist"), "utf-8"), /launchd-wrapper\.sh/, "daemon plist rewritten to the generated one");
|
|
1501
|
-
|
|
1502
|
-
const calls = readFileSync(join(home, "launchctl.log"), "utf-8");
|
|
1503
|
-
assert.match(calls, new RegExp(`^launchctl bootstrap gui/\\d+ ${sessionPlist.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "m"), calls);
|
|
1504
|
-
assert.ok(!/kickstart/.test(calls), `nothing is kickstarted by upgrade (the -autoupdate job is the one it runs inside):\n${calls}`);
|
|
1505
|
-
assert.ok(!/bootout|unload/.test(calls), calls);
|
|
1506
|
-
assert.match(calls, /^launchctl print gui\/\d+$/m, "loaded-ness is read from the gui domain");
|
|
1507
|
-
|
|
1508
|
-
// The claude STUB handled `mcp add-json` — not the developer's real CLI.
|
|
1509
|
-
// lib/claude-bin.mjs would otherwise pick /usr/local/bin/claude over PATH;
|
|
1510
|
-
// CLAUDE_BIN in baseEnv is what makes this isolation real.
|
|
1511
|
-
assert.ok(existsSync(join(home, "claude.log")), "the claude stub must be the binary global-setup reached");
|
|
1512
|
-
assert.match(readFileSync(join(home, "claude.log"), "utf-8"), /^claude mcp add-json cohort .* --scope user$/m);
|
|
1513
|
-
const claudeJson = JSON.parse(readFileSync(join(home, ".claude.json"), "utf-8"));
|
|
1514
|
-
assert.equal(claudeJson.mcpServers.cohort.env.COHORT_AGENT_ROOT.replace(/^\/private/, ""), root.replace(/^\/private/, ""), "stub exit 1 → the JSON merge fallback registered the server");
|
|
1515
|
-
assert.ok(!("firstStartVersion" in claudeJson), "a real `claude` would have stamped its own keys into ~/.claude.json");
|
|
1516
|
-
assert.ok(!existsSync(join(home, "npm.log")) || !/npm install -g/.test(readFileSync(join(home, "npm.log"), "utf-8")), "no global install under MAESTRO_SKIP_GLOBAL_INSTALL=1");
|
|
1517
|
-
|
|
1518
|
-
const manifest = JSON.parse(readFileSync(join(root, ".maestro/shipped-manifest.json"), "utf-8"));
|
|
1519
|
-
const sdkVersion = JSON.parse(readFileSync(join(MAESTRO_ROOT, "package.json"), "utf-8")).version;
|
|
1520
|
-
assert.equal(manifest.sdkVersion, sdkVersion);
|
|
1521
|
-
assert.equal(manifest.version, 1, "the manifest FORMAT version is untouched");
|
|
1522
|
-
|
|
1523
|
-
const result = JSON.parse(readFileSync(join(root, ".maestro/upgrade-result.json"), "utf-8"));
|
|
1524
|
-
assert.equal(result.to, sdkVersion);
|
|
1525
|
-
assert.equal(result.from, null, "no prior manifest → from is unknown");
|
|
1526
|
-
assert.match(result.at, /^\d{4}-\d{2}-\d{2}T/);
|
|
1527
|
-
assert.deepEqual(Object.keys(result.steps), ["plists", "launchd", "globalSetup", "globalInstall", "verify"]);
|
|
1528
|
-
assert.equal(result.steps.plists.ok, true, result.steps.plists.detail);
|
|
1529
|
-
assert.equal(result.steps.launchd.ok, true, result.steps.launchd.detail);
|
|
1530
|
-
assert.equal(result.steps.globalSetup.ok, true, result.steps.globalSetup.detail);
|
|
1531
|
-
assert.match(result.steps.globalInstall.detail, /MAESTRO_SKIP_GLOBAL_INSTALL/);
|
|
1532
|
-
assert.match(r.stdout, /Seat reconcile/);
|
|
1533
|
-
|
|
1534
|
-
// global-setup ran for real against the throwaway HOME.
|
|
1535
|
-
assert.match(readFileSync(join(home, ".claude", "CLAUDE.md"), "utf-8"), /BEGIN maestro:identity/);
|
|
1536
|
-
assert.ok(existsSync(join(home, ".claude", "skills", "maestro-main-session", "SKILL.md")));
|
|
1537
|
-
|
|
1538
|
-
// Second run: from is the recorded version, the session job is unchanged.
|
|
1539
|
-
const again = runCli(["upgrade"], root, baseEnv({ HOME: home }));
|
|
1540
|
-
assert.equal(again.status, 0, again.stderr);
|
|
1541
|
-
const result2 = JSON.parse(readFileSync(join(root, ".maestro/upgrade-result.json"), "utf-8"));
|
|
1542
|
-
assert.equal(result2.from, sdkVersion);
|
|
1543
|
-
assert.match(result2.steps.launchd.detail, /0 installed, 0 replaced/);
|
|
1544
|
-
assert.match(result.steps.launchd.detail, /1 replaced/, "the pre-installed daemon plist is rewritten in place");
|
|
1545
|
-
} finally { await rmRoot(root); await rmRoot(home); }
|
|
1546
|
-
});
|
|
1547
|
-
|
|
1548
|
-
test("upgrade --verify exits 0 on a reconciled seat and 1 once a generated plist is missing; doctor prints the same rows; --dry-run runs no post-steps", async () => {
|
|
1549
|
-
const root = await makeNamedLegacyAgent();
|
|
1550
|
-
const home = await freshHome();
|
|
1551
|
-
try {
|
|
1552
|
-
const dry = runCli(["upgrade", "--dry-run"], root, baseEnv({ HOME: home }));
|
|
1553
|
-
assert.equal(dry.status, 0, dry.stderr);
|
|
1554
|
-
assert.ok(!existsSync(join(root, ".maestro/upgrade-result.json")), "--dry-run writes no result");
|
|
1555
|
-
assert.ok(!existsSync(join(home, "Library", "LaunchAgents", "ai.maestro.legacy-session.plist")), "--dry-run installs nothing");
|
|
1556
|
-
|
|
1557
|
-
assert.equal(runCli(["upgrade"], root, baseEnv({ HOME: home })).status, 0);
|
|
1558
|
-
const v = runCli(["upgrade", "--verify"], root, baseEnv({ HOME: home }));
|
|
1559
|
-
assert.equal(v.status, 0, `verify should pass on a reconciled seat:\n${v.stdout}${v.stderr}`);
|
|
1560
|
-
assert.match(v.stdout, /generated plist\(s\) installed and loaded/);
|
|
1561
|
-
assert.match(v.stdout, /identity, MCP, settings, pointer and skills present/);
|
|
1562
|
-
assert.match(v.stdout, /verify: seat is on/);
|
|
1563
|
-
|
|
1564
|
-
execFileSync("rm", [join(home, "Library", "LaunchAgents", "ai.maestro.legacy-session.plist")]);
|
|
1565
|
-
const bad = runCli(["upgrade", "--verify"], root, baseEnv({ HOME: home }));
|
|
1566
|
-
assert.equal(bad.status, 1);
|
|
1567
|
-
assert.match(bad.stdout, /not installed: ai\.maestro\.legacy-session/);
|
|
1568
|
-
assert.match(bad.stdout, /verify: MISMATCH \(plists\)/);
|
|
1569
|
-
|
|
1570
|
-
const d = runCli(["doctor"], root, baseEnv({ HOME: home }));
|
|
1571
|
-
// doctor's fail() rows go to stderr; ok/warn to stdout.
|
|
1572
|
-
assert.match(d.stdout + d.stderr, /not installed: ai\.maestro\.legacy-session/, `doctor carries the verify rows:\n${d.stdout.slice(-1500)}\n${d.stderr.slice(-1500)}`);
|
|
1573
|
-
} finally { await rmRoot(root); await rmRoot(home); }
|
|
1574
|
-
});
|