@cohortapp/agent-sdk 2.11.15 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +37 -22
- package/README.md +2 -0
- package/bin/maestro.mjs +117 -39
- package/bin/maestro.test.mjs +175 -5
- package/docs/guides/front-door-session.md +313 -0
- package/docs/guides/mac-mini.md +100 -28
- package/docs/guides/org-onboarding.md +1 -1
- package/docs/guides/setup-wizard.md +9 -5
- package/docs/runbooks/cohort-cutover.md +11 -1
- package/docs/runbooks/mac-mini-bootstrap.md +38 -63
- package/lib/cadence-bus-requeue.test.mjs +83 -0
- package/lib/cadence-bus.mjs +43 -7
- package/lib/channels/inbox-item.mjs +59 -2
- package/lib/cli/board.mjs +285 -0
- package/lib/cli/board.test.mjs +227 -0
- package/lib/cli/design.mjs +185 -0
- package/lib/cli/design.test.mjs +270 -0
- package/lib/cli/doctor-checks.mjs +441 -0
- package/lib/cli/doctor-checks.test.mjs +336 -0
- package/lib/cli/global-setup-extras.mjs +454 -0
- package/lib/cli/global-setup-extras.test.mjs +462 -0
- package/lib/cli/inbox.mjs +304 -0
- package/lib/cli/inbox.test.mjs +230 -0
- package/lib/cli/session-ack.mjs +63 -0
- package/lib/cli/session-ack.test.mjs +63 -0
- package/lib/cli/session.mjs +760 -0
- package/lib/cli/session.test.mjs +613 -0
- package/lib/collective/global-config.mjs +209 -6
- package/lib/collective/global-config.test.mjs +145 -0
- package/lib/collective/global-skills.mjs +145 -0
- package/lib/collective/global-skills.test.mjs +126 -0
- package/lib/collective/presence.mjs +4 -3
- package/lib/collective/vendor-skills.mjs +305 -0
- package/lib/collective/vendor-skills.test.mjs +306 -0
- package/lib/comms/send-gate.mjs +115 -0
- package/lib/comms/send-gate.test.mjs +113 -0
- package/lib/design/design-md.mjs +793 -0
- package/lib/design/design-md.test.mjs +318 -0
- package/lib/design/fixtures/DESIGN.golden.md +238 -0
- package/lib/design/fixtures/PRODUCT.golden.md +67 -0
- package/lib/design/fixtures/foundation.json +133 -0
- package/lib/design/refresh-gate.mjs +154 -0
- package/lib/design/refresh-gate.test.mjs +144 -0
- package/lib/design/write.mjs +275 -0
- package/lib/design/write.test.mjs +241 -0
- package/lib/feature-init.mjs +2 -2
- package/lib/mcp/server.test.mjs +9 -4
- package/lib/model-router/spawn.test.mjs +21 -0
- package/lib/org/board-mine-cache.mjs +99 -0
- package/lib/org/board-mine-cache.test.mjs +53 -0
- package/lib/org/board.mjs +11 -0
- package/lib/org/board.test.mjs +11 -1
- package/lib/org/client.mjs +36 -0
- package/lib/org/client.test.mjs +46 -0
- package/lib/org/inbound/directedness.mjs +18 -2
- package/lib/org/inbound/directedness.test.mjs +58 -0
- package/lib/org/inbound/index.mjs +8 -1
- package/lib/org/inbound/index.test.mjs +22 -0
- package/lib/org/mesh-directives.test.mjs +110 -0
- package/lib/org/mesh.mjs +61 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +52 -0
- package/lib/org/protocol.test.mjs +12 -1
- package/lib/org/registry.mjs +3 -2
- package/lib/org/tool-surface.mjs +120 -0
- package/lib/org/tool-surface.test.mjs +118 -5
- package/lib/prompts/parallelism.mjs +79 -0
- package/lib/prompts/parallelism.test.mjs +177 -0
- package/lib/security/external-content.mjs +1 -1
- package/lib/security/external-content.test.mjs +17 -0
- package/lib/session/config.mjs +137 -0
- package/lib/session/config.test.mjs +92 -0
- package/lib/session/feed-core.mjs +229 -0
- package/lib/session/feed-core.test.mjs +198 -0
- package/lib/session/first-run.mjs +126 -0
- package/lib/session/first-run.test.mjs +121 -0
- package/lib/session/frontdoor.mjs +266 -0
- package/lib/session/frontdoor.test.mjs +205 -0
- package/lib/session/handoffs.mjs +295 -0
- package/lib/session/handoffs.test.mjs +183 -0
- package/lib/session/identity.mjs +220 -0
- package/lib/session/identity.test.mjs +180 -0
- package/lib/session/inbox-claims.mjs +434 -0
- package/lib/session/inbox-claims.test.mjs +286 -0
- package/lib/session/launch-args.mjs +161 -0
- package/lib/session/launch-args.test.mjs +157 -0
- package/lib/session/liveness.mjs +174 -0
- package/lib/session/liveness.test.mjs +100 -0
- package/lib/session/status-summary.mjs +172 -0
- package/lib/session/status-summary.test.mjs +118 -0
- package/lib/session-permissions.mjs +39 -3
- package/lib/session-permissions.test.mjs +20 -0
- package/lib/setup/claude-probe.mjs +161 -24
- package/lib/setup/claude-probe.test.mjs +187 -0
- package/lib/setup/sections/learning.mjs +2 -1
- package/lib/setup/sections/model.mjs +104 -24
- package/lib/setup/sections/model.test.mjs +240 -0
- package/lib/setup/sections/org.mjs +27 -2
- package/lib/setup/sections/org.test.mjs +35 -2
- package/lib/setup/sections/verify.mjs +5 -0
- package/lib/setup/state.mjs +30 -10
- package/lib/setup/state.test.mjs +24 -1
- package/lib/singleton.js +11 -3
- package/lib/singleton.test.mjs +16 -0
- package/lib/subagents/lock.mjs +1 -1
- package/lib/telemetry/collect.mjs +270 -6
- package/lib/telemetry/collect.test.mjs +196 -1
- package/lib/upgrade/global-refresh.mjs +108 -0
- package/lib/upgrade/global-refresh.test.mjs +65 -0
- package/lib/upgrade/launchd-reconcile.mjs +327 -0
- package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
- package/lib/upgrade/post-steps.mjs +151 -0
- package/lib/upgrade/post-steps.test.mjs +200 -0
- package/lib/upgrade/verify.mjs +215 -0
- package/lib/upgrade/verify.test.mjs +164 -0
- package/lib/voice/outbound.mjs +3 -2
- package/lib/voice/post-call-brief.mjs +2 -1
- package/lib/voice/session-rotation.mjs +6 -1
- package/lib/voice/session-rotation.test.mjs +114 -0
- package/package.json +3 -3
- package/plugins/maestro-skills/plugin.json +25 -1
- package/plugins/maestro-skills/skills/board-work.md +63 -0
- package/plugins/maestro-skills/skills/cohort-design.md +153 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
- package/plugins/maestro-skills/skills/main-session.md +102 -0
- package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
- package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
- package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
- package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
- package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
- package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
- package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
- package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
- package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
- package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
- package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
- package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
- package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
- package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
- package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
- package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
- package/scaffold/CLAUDE.md +24 -0
- package/scripts/ci/check-durable-write-seam.mjs +147 -0
- package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
- package/scripts/ci/check-skill-packs.mjs +388 -0
- package/scripts/ci/check-skill-packs.test.mjs +495 -0
- package/scripts/ci/check.mjs +6 -0
- package/scripts/collective/hook-runner.mjs +39 -4
- package/scripts/collective/hook-runner.test.mjs +85 -2
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
- package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
- package/scripts/daemon/agent-daemon.mjs +249 -10
- package/scripts/daemon/agent-daemon.test.mjs +73 -0
- package/scripts/daemon/assurance-e2e.test.mjs +141 -6
- package/scripts/daemon/assurance.mjs +461 -37
- package/scripts/daemon/assurance.test.mjs +408 -43
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +393 -0
- package/scripts/daemon/cadence-consumer.mjs +289 -89
- package/scripts/daemon/cadence-handlers.mjs +53 -0
- package/scripts/daemon/classifier.mjs +1 -1
- package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
- package/scripts/daemon/dispatcher.mjs +127 -19
- package/scripts/daemon/health.mjs +12 -1
- package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
- package/scripts/daemon/inbox-deferral.mjs +6 -0
- package/scripts/daemon/lib/self-echo.mjs +201 -0
- package/scripts/daemon/lib/self-echo.test.mjs +153 -0
- package/scripts/daemon/maestro-daemon.mjs +3 -0
- package/scripts/daemon/prompt-builder.mjs +19 -3
- package/scripts/daemon/responder.mjs +51 -40
- package/scripts/daemon/sdk-version.mjs +51 -0
- package/scripts/daemon/sdk-version.test.mjs +31 -0
- package/scripts/hooks/pre-send-audit.sh +97 -4
- package/scripts/hooks/pre-send-audit.test.mjs +140 -1
- package/scripts/local-triggers/autoupdate.sh +243 -19
- package/scripts/local-triggers/autoupdate.test.mjs +518 -0
- package/scripts/local-triggers/generate-plists.sh +24 -1
- package/scripts/local-triggers/generate-plists.test.mjs +49 -11
- package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
- package/scripts/org/send-orgmail.mjs +27 -3
- package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
- package/scripts/poller/slack-poller.mjs +13 -1
- package/scripts/poller/utils.mjs +46 -1
- package/scripts/poller-launchd/install.sh +19 -11
- package/scripts/poller-launchd/install.test.mjs +243 -0
- package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
- package/scripts/poller-launchd/migrate.sh +66 -0
- package/scripts/poller-launchd/poller.plist.template +4 -2
- package/scripts/session/feed.mjs +237 -0
- package/scripts/session/feed.test.mjs +196 -0
- package/scripts/session/supervisor-sh.test.mjs +218 -0
- package/scripts/session/supervisor.mjs +328 -0
- package/scripts/session/supervisor.sh +141 -0
- package/scripts/session/supervisor.test.mjs +482 -0
- package/scripts/setup/configure-macos.sh +250 -55
- package/scripts/setup/configure-macos.test.mjs +306 -0
- package/scripts/setup/init-agent.sh +112 -7
- package/scripts/setup/init-agent.test.mjs +220 -1
- package/scripts/vendor/skill-packs.mjs +354 -0
- package/scripts/vendor/sync-skill-packs.mjs +242 -0
- package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
- package/scripts/watchdog/memory-watchdog.sh +37 -1
- package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
- package/scripts/setup/boot-claude-session.sh +0 -94
|
@@ -13,7 +13,7 @@ import assert from "node:assert/strict";
|
|
|
13
13
|
import { execFile, execFileSync } from "node:child_process";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { dirname, join } from "node:path";
|
|
16
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { tmpdir } from "node:os";
|
|
18
18
|
|
|
19
19
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -213,3 +213,142 @@ test("daily cap blocks once exceeded (exit 2)", { skip: JQ ? false : "jq not on
|
|
|
213
213
|
assert.equal(r2.code, 2, `send 2 expected 2 (daily cap), got ${r2.code}`);
|
|
214
214
|
assert.match(r2.stderr, /Daily send limit/);
|
|
215
215
|
});
|
|
216
|
+
|
|
217
|
+
// ── WP-M4: persona-leak patterns (front-door session §3.6) ─────────────────
|
|
218
|
+
//
|
|
219
|
+
// Outbound text may never expose the machinery: "Claude Code", "Claude
|
|
220
|
+
// session", "sub-session", a subagent that "said/reported", "workflow agent",
|
|
221
|
+
// "as an AI" / "language model", or an internal session name (<first>-main,
|
|
222
|
+
// <first>-<purpose>). The hook returns the offending phrase so the session can
|
|
223
|
+
// rewrite in its own voice.
|
|
224
|
+
|
|
225
|
+
const PERSONA_ENV = { MAESTRO_AGENT_FIRST: "alex" };
|
|
226
|
+
|
|
227
|
+
for (const [label, text] of [
|
|
228
|
+
["Claude Code", "I'll have Claude Code pick that up this afternoon."],
|
|
229
|
+
["Claude session", "A separate claude session is running the numbers."],
|
|
230
|
+
["sub-session", "I kicked off a sub-session for the deck."],
|
|
231
|
+
["subagent said", "The subagent said the deploy finished."],
|
|
232
|
+
["sub-agent reported", "My sub-agent reported two blockers."],
|
|
233
|
+
["subagent session", "The subagent session is still going."],
|
|
234
|
+
["workflow agent", "The workflow agent will file the summary."],
|
|
235
|
+
["language model", "Every language model has limits, mine included."],
|
|
236
|
+
["<first>-main", "Check with alex-main for the latest."],
|
|
237
|
+
]) {
|
|
238
|
+
test(`persona leak "${label}" blocks (exit 2) and names the phrase`, { skip: JQ ? false : "jq not on PATH" }, async () => {
|
|
239
|
+
const payload = JSON.stringify({ tool_name: "mcp__cohort__messaging_send", tool_input: { channelId: "C1", body: text } });
|
|
240
|
+
const { code, stderr } = await runHook(payload, { channel: "cohort", env: PERSONA_ENV });
|
|
241
|
+
assert.equal(code, 2, `expected exit 2 for "${label}", got ${code}`);
|
|
242
|
+
assert.match(stderr, /BLOCKED/);
|
|
243
|
+
assert.match(stderr, /persona/i);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
test("persona: the leak line names the offending phrase so the session can rewrite", { skip: JQ ? false : "jq not on PATH" }, async () => {
|
|
248
|
+
const payload = JSON.stringify({ tool_input: { channelId: "C1", body: "I'll have Claude Code pick that up." } });
|
|
249
|
+
const { stderr } = await runHook(payload, { channel: "cohort", env: PERSONA_ENV });
|
|
250
|
+
assert.match(stderr, /claude code/i);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("persona: colleague phrasing passes; bare 'subagent' without said/reported/session passes; mid-word matches ignored", { skip: JQ ? false : "jq not on PATH" }, async () => {
|
|
254
|
+
for (const text of [
|
|
255
|
+
"One of my analysts is on it; I'll have the numbers by 3.",
|
|
256
|
+
"My team has the deck in review — sending the draft after lunch.",
|
|
257
|
+
"We use subagents internally for the heavy lifting.",
|
|
258
|
+
"Alexandra-Maine sent the invoice.",
|
|
259
|
+
"The session on Thursday (the board session) starts at 9.",
|
|
260
|
+
]) {
|
|
261
|
+
const payload = JSON.stringify({ tool_input: { channelId: "C1", body: text } });
|
|
262
|
+
const { code, stderr } = await runHook(payload, { channel: "cohort", env: PERSONA_ENV });
|
|
263
|
+
assert.equal(code, 0, `expected exit 0 for "${text}", got ${code}: ${stderr}`);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("persona: session-name pattern reads the first name from AGENT_ROOT/config/agent.json when the env is unset", { skip: JQ ? false : "jq not on PATH" }, async () => {
|
|
268
|
+
const dir = mkdtempSync(join(tmpdir(), "presend-agent-"));
|
|
269
|
+
try {
|
|
270
|
+
const cfg = join(dir, "config");
|
|
271
|
+
mkdirSyncP(cfg);
|
|
272
|
+
writeFileSync(join(cfg, "agent.json"), JSON.stringify({ firstName: "Robin", fullName: "Robin Park" }));
|
|
273
|
+
const payload = JSON.stringify({ tool_input: { channelId: "C1", body: "robin-main will chase it." } });
|
|
274
|
+
const { code, stderr } = await runHook(payload, { channel: "cohort", env: { AGENT_ROOT: dir } });
|
|
275
|
+
assert.equal(code, 2, `expected exit 2, got ${code}: ${stderr}`);
|
|
276
|
+
assert.match(stderr, /robin-main/);
|
|
277
|
+
// No first name resolvable anywhere → the name pattern is skipped (fail-open on that one check), the rest still applies.
|
|
278
|
+
const { code: c2 } = await runHook(payload, { channel: "cohort", env: {} });
|
|
279
|
+
assert.equal(c2, 0);
|
|
280
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ── Review fix: session names are BOUNDED (spec §3.6 gap) ──────────────────
|
|
284
|
+
// `<first>-main` is always internal; any other `<first>-<x>` is internal only
|
|
285
|
+
// when it is a registered peer (state/session/peers.json under AGENT_ROOT) or
|
|
286
|
+
// listed in MAESTRO_SESSION_NAMES. A colleague's hyphenated name, a URL slug or
|
|
287
|
+
// an email local part that happens to start with the first name must pass.
|
|
288
|
+
|
|
289
|
+
test("persona: an unregistered <first>-<x> is ordinary text; a registered peer name (peers.json) or MAESTRO_SESSION_NAMES entry blocks", { skip: JQ ? false : "jq not on PATH" }, async () => {
|
|
290
|
+
const dir = mkdtempSync(join(tmpdir(), "presend-peers-"));
|
|
291
|
+
try {
|
|
292
|
+
const payload = JSON.stringify({ tool_input: { channelId: "C1", body: "alex-research has the draft ready." } });
|
|
293
|
+
const { code: c0, stderr: e0 } = await runHook(payload, { channel: "cohort", env: PERSONA_ENV });
|
|
294
|
+
assert.equal(c0, 0, `unregistered alex-research must pass: ${e0}`);
|
|
295
|
+
|
|
296
|
+
mkdirSyncP(join(dir, "state", "session"));
|
|
297
|
+
writeFileSync(join(dir, "state", "session", "peers.json"), JSON.stringify([{ name: "alex-research", muxName: "maestro-alex-research", purpose: "research" }]));
|
|
298
|
+
const { code: c1, stderr: e1 } = await runHook(payload, { channel: "cohort", env: { ...PERSONA_ENV, AGENT_ROOT: dir } });
|
|
299
|
+
assert.equal(c1, 2, `registered peer must block, got ${c1}: ${e1}`);
|
|
300
|
+
assert.match(e1, /alex-research/);
|
|
301
|
+
|
|
302
|
+
// {peers:[…]} shape is accepted too.
|
|
303
|
+
writeFileSync(join(dir, "state", "session", "peers.json"), JSON.stringify({ peers: [{ name: "alex-deck" }] }));
|
|
304
|
+
const deck = JSON.stringify({ tool_input: { channelId: "C1", body: "alex-deck is nearly done." } });
|
|
305
|
+
assert.equal((await runHook(deck, { channel: "cohort", env: { ...PERSONA_ENV, AGENT_ROOT: dir } })).code, 2);
|
|
306
|
+
assert.equal((await runHook(payload, { channel: "cohort", env: { ...PERSONA_ENV, AGENT_ROOT: dir } })).code, 0, "alex-research is no longer registered");
|
|
307
|
+
|
|
308
|
+
// A corrupt registry is an empty one — never a fail-closed on the whole send.
|
|
309
|
+
writeFileSync(join(dir, "state", "session", "peers.json"), "{nope");
|
|
310
|
+
assert.equal((await runHook(deck, { channel: "cohort", env: { ...PERSONA_ENV, AGENT_ROOT: dir } })).code, 0);
|
|
311
|
+
|
|
312
|
+
// Env override (comma-separated), no files needed.
|
|
313
|
+
const { code: c2, stderr: e2 } = await runHook(payload, { channel: "cohort", env: { ...PERSONA_ENV, MAESTRO_SESSION_NAMES: "alex-research, alex-ops" } });
|
|
314
|
+
assert.equal(c2, 2, `MAESTRO_SESSION_NAMES must block, got ${c2}: ${e2}`);
|
|
315
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("persona: a colleague's hyphenated name, a URL slug and an email local part starting with the first name all pass", { skip: JQ ? false : "jq not on PATH" }, async () => {
|
|
319
|
+
for (const [first, text] of [
|
|
320
|
+
["marc", "Marc-Antoine confirmed the numbers."],
|
|
321
|
+
["james", "See https://os.cohortapp.com/spaces/james-kirkland for the thread."],
|
|
322
|
+
["ethan", "Loop in ethan-miller@example.com on the invoice."],
|
|
323
|
+
["alex", "alex-mainland is the venue; alex-main-street too."],
|
|
324
|
+
]) {
|
|
325
|
+
const payload = JSON.stringify({ tool_input: { channelId: "C1", body: text } });
|
|
326
|
+
const { code, stderr } = await runHook(payload, { channel: "cohort", env: { MAESTRO_AGENT_FIRST: first } });
|
|
327
|
+
assert.equal(code, 0, `expected exit 0 for "${text}" (first=${first}), got ${code}: ${stderr}`);
|
|
328
|
+
}
|
|
329
|
+
// …while the real session name still blocks, whatever the case.
|
|
330
|
+
const { code } = await runHook(JSON.stringify({ tool_input: { channelId: "C1", body: "Ask ALEX-MAIN." } }), { channel: "cohort", env: PERSONA_ENV });
|
|
331
|
+
assert.equal(code, 2);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ── Review fix: the hook now fronts every mcp__cohort outbound tool ──────────
|
|
335
|
+
// org_rpc carries the body under tool_input.params; email_send under text (+
|
|
336
|
+
// subject); email_draft_send under bodyText (+ subject). All must be screened.
|
|
337
|
+
|
|
338
|
+
test("persona: org_rpc (params.body), email_send (subject+text) and email_draft_send (bodyText) payload shapes are screened", { skip: JQ ? false : "jq not on PATH" }, async () => {
|
|
339
|
+
const cases = [
|
|
340
|
+
["org_rpc", { tool_name: "mcp__cohort__org_rpc", tool_input: { method: "messaging.send", params: { channelId: "C1", body: "My sub-session is on it." } } }],
|
|
341
|
+
["email_send text", { tool_name: "mcp__cohort__email_send", tool_input: { to: ["a@b.com"], subject: "Update", text: "Claude Code finished the deploy." } }],
|
|
342
|
+
["email_send subject", { tool_name: "mcp__cohort__email_send", tool_input: { to: ["a@b.com"], subject: "From alex-main", text: "Deploy finished." } }],
|
|
343
|
+
["email_draft_send bodyText", { tool_name: "mcp__cohort__email_draft_send", tool_input: { to: ["a@b.com"], subject: "Update", bodyText: "The workflow agent will send the summary." } }],
|
|
344
|
+
];
|
|
345
|
+
for (const [label, payload] of cases) {
|
|
346
|
+
const { code, stderr } = await runHook(JSON.stringify(payload), { channel: "cohort", env: PERSONA_ENV });
|
|
347
|
+
assert.equal(code, 2, `expected exit 2 for ${label}, got ${code}: ${stderr}`);
|
|
348
|
+
assert.match(stderr, /persona/i);
|
|
349
|
+
}
|
|
350
|
+
const clean = { tool_name: "mcp__cohort__org_rpc", tool_input: { method: "messaging.send", params: { channelId: "C1", body: "Numbers are in; deck by 3." } } };
|
|
351
|
+
assert.equal((await runHook(JSON.stringify(clean), { channel: "cohort", env: PERSONA_ENV })).code, 0);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
function mkdirSyncP(p) { mkdirSync(p, { recursive: true }); }
|
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
# =============================================================================
|
|
5
5
|
# Runs hourly via the ai.maestro.<first>-autoupdate launchd job. Policy is
|
|
6
6
|
# "auto to @latest": whenever npm's latest is a strictly-greater semver than the
|
|
7
|
-
# installed SDK, this installs it,
|
|
8
|
-
# (
|
|
9
|
-
#
|
|
7
|
+
# installed SDK, this installs it, runs the NEW package's `maestro upgrade`
|
|
8
|
+
# (which smart-merges the framework files AND reconciles the seat: plists,
|
|
9
|
+
# launchd jobs, global-setup, the global npm install — see
|
|
10
|
+
# lib/upgrade/post-steps.mjs), restarts the daemon, and VERIFIES the daemon
|
|
11
|
+
# comes back healthy — rolling back to the prior version if it does not.
|
|
10
12
|
#
|
|
11
13
|
# Safety rails:
|
|
12
14
|
# - kill-switch: `touch ~/.maestro-no-autoupdate` (or $AGENT_DIR/.no-autoupdate)
|
|
@@ -14,17 +16,64 @@
|
|
|
14
16
|
# - jitter 0-600s so 15 machines don't hit npm / restart in lockstep.
|
|
15
17
|
# - re-exec from a /tmp copy so `maestro upgrade` overwriting THIS file
|
|
16
18
|
# mid-run cannot corrupt the running shell.
|
|
17
|
-
# -
|
|
19
|
+
# - overlap lock: state/locks/autoupdate/ (mkdir, pid inside; stale after 2 h)
|
|
20
|
+
# so a slow npm on one tick cannot race the next tick.
|
|
21
|
+
# - npm view / install retried 3x with backoff (--no-audit --no-fund --prefer-online).
|
|
22
|
+
# - health-gate + automatic rollback to the previous version on a bad release;
|
|
23
|
+
# the rollback also puts the global install back to the previous version
|
|
24
|
+
# and reverts the -session job IF THIS RUN installed it (never a
|
|
25
|
+
# pre-existing one). What a rollback does NOT undo: files the new version
|
|
26
|
+
# added that the old manifest never listed stay on disk, and the old
|
|
27
|
+
# upgrade rewrites the manifest without sdkVersion — `maestro doctor` /
|
|
28
|
+
# `upgrade --verify` on the next healthy hop report both.
|
|
29
|
+
# - a version that failed the health gate here is NOT retried every hour:
|
|
30
|
+
# state/autoupdate/last.json remembers it and the run holds for 24 h
|
|
31
|
+
# (MAESTRO_AUTOUPDATE_FAILED_HOLD_S; delete last.json to retry now).
|
|
32
|
+
#
|
|
33
|
+
# The health gate is the DAEMON: alive + a fresh "org-mesh connected" /
|
|
34
|
+
# "[daemon] Running" line. When the generated plist set carries a -session
|
|
35
|
+
# job, the gate ALSO checks the session label is loaded and a supervisor is
|
|
36
|
+
# alive — but a missing session is logged as `reconcile-failed` and does NOT
|
|
37
|
+
# roll back (the daemon's --print lane is the front door meanwhile; doctor
|
|
38
|
+
# and `maestro upgrade --verify` say what to do).
|
|
39
|
+
#
|
|
40
|
+
# The front-door session (ai.maestro.<first>-session) is NEVER restarted or
|
|
41
|
+
# killed here: after a HEALTHY upgrade this writes
|
|
42
|
+
# state/session/upgrade-notice.json {from,to,at}; the session's feed surfaces
|
|
43
|
+
# it as an `upgrade-available` directive and the session restarts itself at an
|
|
44
|
+
# idle moment via `maestro session restart` (the supervisor relaunches on the
|
|
45
|
+
# new code). A rollback leaves no notice. Every attempt also writes
|
|
46
|
+
# state/autoupdate/last.json {from,to,at,ok,healthy,reason}, which the beat
|
|
47
|
+
# reports as machine.upgrade. See
|
|
48
|
+
# docs/superpowers/specs/2026-09-08-front-door-session-design.md §3.10 + WP-M6.
|
|
49
|
+
#
|
|
50
|
+
# Every launchd / pgrep question is scoped to THIS agent (its first name from
|
|
51
|
+
# config/agent.json, else its own generated plist set): two seats on one Mac
|
|
52
|
+
# must not gate on — or kickstart — each other's jobs.
|
|
53
|
+
#
|
|
54
|
+
# Test knobs (scripts/local-triggers/autoupdate.test.mjs): MAESTRO_AUTOUPDATE_JITTER_MAX
|
|
55
|
+
# (default 600) and MAESTRO_AUTOUPDATE_HEALTH_WAIT (default 30), both seconds;
|
|
56
|
+
# MAESTRO_AUTOUPDATE_RETRY_SLEEP (default 20 s, ×attempt); MAESTRO_AUTOUPDATE_LOCK_STALE_S
|
|
57
|
+
# (default 7200); MAESTRO_AUTOUPDATE_FAILED_HOLD_S (default 86400);
|
|
58
|
+
# MAESTRO_AUTOUPDATE_PATH_PREFIX (a stub-binary dir that wins over PATH);
|
|
59
|
+
# MAESTRO_SKIP_GLOBAL_INSTALL=1 (never touch the global install).
|
|
18
60
|
# =============================================================================
|
|
19
61
|
set -uo pipefail
|
|
20
|
-
|
|
62
|
+
# MAESTRO_AUTOUPDATE_PATH_PREFIX: a dir that wins over the standard prepend
|
|
63
|
+
# (the test puts its npm/launchctl/pgrep stubs there so a run never reaches
|
|
64
|
+
# the real registry or launchd).
|
|
65
|
+
export PATH="${MAESTRO_AUTOUPDATE_PATH_PREFIX:+$MAESTRO_AUTOUPDATE_PATH_PREFIX:}/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${PATH:-}"
|
|
21
66
|
|
|
22
67
|
# Re-exec from a stable copy: `maestro upgrade` may overwrite this very file.
|
|
68
|
+
# The copy removes itself on exit (REEXEC_COPY is only ever a mktemp'd
|
|
69
|
+
# maestro-autoupdate.* file — never the agent dir's script).
|
|
23
70
|
if [ "${MAESTRO_AUTOUPDATE_REEXEC:-}" != "1" ]; then
|
|
24
71
|
_tmp="$(mktemp "${TMPDIR:-/tmp}/maestro-autoupdate.XXXXXX")" || exit 0
|
|
25
72
|
cp "${BASH_SOURCE[0]}" "$_tmp" 2>/dev/null || exit 0
|
|
26
73
|
MAESTRO_AUTOUPDATE_REEXEC=1 exec /bin/bash "$_tmp" "$@"
|
|
27
74
|
fi
|
|
75
|
+
REEXEC_COPY=""
|
|
76
|
+
case "${BASH_SOURCE[0]}" in */maestro-autoupdate.??????) REEXEC_COPY="${BASH_SOURCE[0]}" ;; esac
|
|
28
77
|
|
|
29
78
|
AGENT_DIR="${AGENT_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
|
30
79
|
cd "$AGENT_DIR" 2>/dev/null || exit 0
|
|
@@ -38,46 +87,221 @@ if [ -e "$HOME/.maestro-no-autoupdate" ] || [ -e "$AGENT_DIR/.no-autoupdate" ];
|
|
|
38
87
|
log "SKIP: kill-switch present"; exit 0
|
|
39
88
|
fi
|
|
40
89
|
|
|
90
|
+
# ── overlap lock ─────────────────────────────────────────────────────────────
|
|
91
|
+
# mkdir is atomic; the pid inside says who holds it. A holder that is dead, or
|
|
92
|
+
# older than LOCK_STALE_S (a wedged npm), is reclaimed rather than honoured.
|
|
93
|
+
LOCK_DIR="$AGENT_DIR/state/locks/autoupdate"
|
|
94
|
+
LOCK_STALE_S="${MAESTRO_AUTOUPDATE_LOCK_STALE_S:-7200}"
|
|
95
|
+
take_lock(){
|
|
96
|
+
mkdir -p "$AGENT_DIR/state/locks" 2>/dev/null
|
|
97
|
+
if mkdir "$LOCK_DIR" 2>/dev/null; then echo $$ > "$LOCK_DIR/pid"; return 0; fi
|
|
98
|
+
local pid mtime now age
|
|
99
|
+
pid="$(cat "$LOCK_DIR/pid" 2>/dev/null)"
|
|
100
|
+
mtime="$(stat -f %m "$LOCK_DIR" 2>/dev/null || stat -c %Y "$LOCK_DIR" 2>/dev/null || echo 0)"
|
|
101
|
+
now="$(date +%s)"; age=$(( now - mtime ))
|
|
102
|
+
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && [ "$age" -lt "$LOCK_STALE_S" ]; then
|
|
103
|
+
log "SKIP: another autoupdate (pid $pid) holds $LOCK_DIR (${age}s old)"; return 1
|
|
104
|
+
fi
|
|
105
|
+
log "reclaiming stale autoupdate lock (pid ${pid:-?}, ${age}s old)"
|
|
106
|
+
rm -rf "$LOCK_DIR" 2>/dev/null
|
|
107
|
+
mkdir "$LOCK_DIR" 2>/dev/null || return 1
|
|
108
|
+
echo $$ > "$LOCK_DIR/pid"; return 0
|
|
109
|
+
}
|
|
110
|
+
release_lock(){ rm -rf "$LOCK_DIR" 2>/dev/null; }
|
|
111
|
+
remove_reexec_copy(){ [ -n "$REEXEC_COPY" ] && rm -f "$REEXEC_COPY" 2>/dev/null; return 0; }
|
|
112
|
+
cleanup(){ release_lock; remove_reexec_copy; }
|
|
113
|
+
trap remove_reexec_copy EXIT # a run that yields to another holder must not release THAT holder's lock
|
|
114
|
+
take_lock || exit 0
|
|
115
|
+
trap cleanup EXIT
|
|
116
|
+
|
|
41
117
|
# ── jitter ───────────────────────────────────────────────────────────────────
|
|
42
|
-
|
|
118
|
+
JITTER_MAX="${MAESTRO_AUTOUPDATE_JITTER_MAX:-600}"
|
|
119
|
+
J=$(( RANDOM % (JITTER_MAX + 1) )); log "wake; jitter ${J}s"; [ "$J" -gt 0 ] && sleep "$J"
|
|
120
|
+
|
|
121
|
+
# ── retry helper (npm view / install) ────────────────────────────────────────
|
|
122
|
+
RETRIES=3
|
|
123
|
+
RETRY_SLEEP="${MAESTRO_AUTOUPDATE_RETRY_SLEEP:-20}"
|
|
124
|
+
retry(){ # $1 = label; rest = command (stdout+stderr → LOG)
|
|
125
|
+
local label="$1"; shift
|
|
126
|
+
local attempt=1 wait
|
|
127
|
+
while :; do
|
|
128
|
+
if "$@" >> "$LOG" 2>&1; then return 0; fi
|
|
129
|
+
if [ "$attempt" -ge "$RETRIES" ]; then log "$label: failed after $attempt attempt(s)"; return 1; fi
|
|
130
|
+
wait=$(( RETRY_SLEEP * attempt ))
|
|
131
|
+
log "$label: attempt $attempt failed; retrying in ${wait}s"
|
|
132
|
+
sleep "$wait"; attempt=$(( attempt + 1 ))
|
|
133
|
+
done
|
|
134
|
+
}
|
|
135
|
+
npm_view_latest(){ # prints the latest version, or nothing after RETRIES attempts
|
|
136
|
+
local attempt=1 out wait
|
|
137
|
+
while :; do
|
|
138
|
+
out="$(npm view "$PKG" version --no-audit --no-fund --prefer-online 2>>"$LOG" | tail -1)"
|
|
139
|
+
if [ -n "$out" ]; then printf '%s' "$out"; return 0; fi
|
|
140
|
+
if [ "$attempt" -ge "$RETRIES" ]; then return 1; fi
|
|
141
|
+
wait=$(( RETRY_SLEEP * attempt ))
|
|
142
|
+
log "npm view: attempt $attempt failed; retrying in ${wait}s"
|
|
143
|
+
sleep "$wait"; attempt=$(( attempt + 1 ))
|
|
144
|
+
done
|
|
145
|
+
}
|
|
43
146
|
|
|
44
147
|
# ── version check ────────────────────────────────────────────────────────────
|
|
45
148
|
CUR="$(node -p "require('$AGENT_DIR/node_modules/$PKG/package.json').version" 2>/dev/null || echo 0.0.0)"
|
|
46
|
-
LATEST="$(
|
|
47
|
-
[ -z "$LATEST" ] && { log "npm view failed; skip (still $CUR)"; exit 0; }
|
|
149
|
+
LATEST="$(npm_view_latest)" || LATEST=""
|
|
150
|
+
[ -z "$LATEST" ] && { log "npm view failed after $RETRIES attempts; skip (still $CUR)"; exit 0; }
|
|
48
151
|
if [ "$CUR" = "$LATEST" ] || [ "$(printf '%s\n%s\n' "$CUR" "$LATEST" | sort -V | tail -1)" != "$LATEST" ]; then
|
|
49
152
|
log "up to date ($CUR)"; exit 0
|
|
50
153
|
fi
|
|
154
|
+
|
|
155
|
+
# ── failed-target hold ───────────────────────────────────────────────────────
|
|
156
|
+
# last.json is the memory of the health gate: a LATEST that was installed here
|
|
157
|
+
# and rolled back (or could not even be rolled back) is not retried every hour
|
|
158
|
+
# — that would be two installs, two global refreshes and two daemon restarts an
|
|
159
|
+
# hour until someone touched the kill-switch. An install-failed (network) entry
|
|
160
|
+
# does not hold. Fail-open: an unreadable last.json holds nothing.
|
|
161
|
+
FAILED_HOLD_S="${MAESTRO_AUTOUPDATE_FAILED_HOLD_S:-86400}"
|
|
162
|
+
failed_hold_reason(){ # prints "<reason> at <at>" when LATEST failed health within FAILED_HOLD_S
|
|
163
|
+
node -e '
|
|
164
|
+
const [file, latest, holdS] = process.argv.slice(1);
|
|
165
|
+
try {
|
|
166
|
+
const j = JSON.parse(require("fs").readFileSync(file, "utf8"));
|
|
167
|
+
const at = Date.parse(j.at);
|
|
168
|
+
if (j.to === latest && j.ok === false && /unhealthy/.test(String(j.reason)) && Number.isFinite(at) && Date.now() - at < Number(holdS) * 1000) process.stdout.write(`${j.reason} at ${j.at}`);
|
|
169
|
+
} catch {}
|
|
170
|
+
' "$AGENT_DIR/state/autoupdate/last.json" "$LATEST" "$FAILED_HOLD_S" 2>/dev/null
|
|
171
|
+
}
|
|
172
|
+
HOLD="$(failed_hold_reason)"
|
|
173
|
+
if [ -n "$HOLD" ]; then
|
|
174
|
+
log "SKIP: $LATEST already failed the health gate here ($HOLD); holding ${FAILED_HOLD_S}s (still $CUR) — rm state/autoupdate/last.json to retry now"
|
|
175
|
+
exit 0
|
|
176
|
+
fi
|
|
51
177
|
log "UPDATE $CUR -> $LATEST"
|
|
52
178
|
|
|
53
|
-
DAEMON_PLIST="$(ls "$HOME"/Library/LaunchAgents/ai.maestro.*-daemon.plist 2>/dev/null | head -1)"
|
|
54
|
-
DAEMON_LABEL="$(basename "$DAEMON_PLIST" .plist 2>/dev/null)"
|
|
55
179
|
UID_="$(id -u)"
|
|
180
|
+
AGENT_FIRST="$(node -p "String(require('$AGENT_DIR/config/agent.json').firstName || '').toLowerCase()" 2>/dev/null)"
|
|
181
|
+
[ -n "$AGENT_FIRST" ] && log "agent: $AGENT_FIRST (launchd/pgrep questions are scoped to ai.maestro.$AGENT_FIRST-*)"
|
|
56
182
|
DLOG="$AGENT_DIR/logs/daemon/daemon-$(date +%Y-%m-%d).log"
|
|
183
|
+
HEALTH_WAIT="${MAESTRO_AUTOUPDATE_HEALTH_WAIT:-30}"
|
|
184
|
+
DAEMON_LABEL=""
|
|
185
|
+
SESSION_LABEL=""
|
|
57
186
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
187
|
+
first_plist(){ # $1 = suffix, $2 = dir — this agent's plist for that suffix, or nothing (unquoted so `*` globs when no name is known)
|
|
188
|
+
ls "$2"/ai.maestro.${AGENT_FIRST:-*}-"$1".plist 2>/dev/null | head -1
|
|
189
|
+
}
|
|
190
|
+
resolve_labels(){ # AFTER the upgrade: its reconcile may have just installed these
|
|
191
|
+
local p
|
|
192
|
+
# This agent's generated set names the daemon label; ~/Library/LaunchAgents
|
|
193
|
+
# is the fallback for a seat with no generated dir. Either way the label only
|
|
194
|
+
# counts when its plist is actually installed.
|
|
195
|
+
p="$(first_plist daemon "$AGENT_DIR/scripts/local-triggers/plists")"
|
|
196
|
+
[ -n "$p" ] || p="$(first_plist daemon "$HOME/Library/LaunchAgents")"
|
|
197
|
+
DAEMON_LABEL="$(basename "$p" .plist 2>/dev/null)"
|
|
198
|
+
[ -n "$DAEMON_LABEL" ] && [ -f "$HOME/Library/LaunchAgents/$DAEMON_LABEL.plist" ] || DAEMON_LABEL=""
|
|
199
|
+
p="$(first_plist session "$AGENT_DIR/scripts/local-triggers/plists")"
|
|
200
|
+
SESSION_LABEL="$(basename "$p" .plist 2>/dev/null)"
|
|
201
|
+
}
|
|
202
|
+
label_loaded(){ # $1 = label — measured in the gui domain (right from ssh too); `launchctl list <label>` only when print could not answer
|
|
203
|
+
local out rc
|
|
204
|
+
out="$(launchctl print "gui/$UID_/$1" 2>&1)"; rc=$?
|
|
205
|
+
[ "$rc" -eq 0 ] && return 0
|
|
206
|
+
case "$out" in *"Could not find service"*) return 1 ;; esac
|
|
207
|
+
launchctl list "$1" >/dev/null 2>&1
|
|
208
|
+
}
|
|
209
|
+
restart_daemon(){
|
|
210
|
+
resolve_labels
|
|
211
|
+
if [ -z "$DAEMON_LABEL" ]; then log "no -daemon plist under ~/Library/LaunchAgents — nothing to kickstart (maestro upgrade installs it; doctor will say)"; return 0; fi
|
|
212
|
+
launchctl kickstart -k "gui/$UID_/$DAEMON_LABEL" >> "$LOG" 2>&1 || log "WARN: kickstart $DAEMON_LABEL nonzero"
|
|
213
|
+
}
|
|
214
|
+
# What -session plists THIS agent had installed BEFORE this run. A rollback
|
|
215
|
+
# reverts only a session job this run's upgrade installed — never one that
|
|
216
|
+
# was already there (that one is the operator's, and `maestro session stop`
|
|
217
|
+
# is how an operator stops it).
|
|
218
|
+
PRE_SESSION_PLISTS="$(ls "$HOME"/Library/LaunchAgents/ai.maestro.${AGENT_FIRST:-*}-session.plist 2>/dev/null)"
|
|
219
|
+
revert_session_install(){ # rollback only
|
|
220
|
+
local p label
|
|
221
|
+
ls "$HOME"/Library/LaunchAgents/ai.maestro.${AGENT_FIRST:-*}-session.plist 2>/dev/null | while IFS= read -r p; do
|
|
222
|
+
[ -n "$p" ] || continue
|
|
223
|
+
case "$PRE_SESSION_PLISTS" in *"$p"*) continue ;; esac
|
|
224
|
+
label="$(basename "$p" .plist)"
|
|
225
|
+
launchctl bootout "gui/$UID_/$label" >> "$LOG" 2>&1 || log "WARN: bootout $label nonzero (not loaded?)"
|
|
226
|
+
rm -f "$p" 2>/dev/null
|
|
227
|
+
log "rollback: reverted session job $label — this run installed it and $CUR has no front-door session"
|
|
228
|
+
done
|
|
229
|
+
return 0
|
|
230
|
+
}
|
|
231
|
+
apply_version(){ # $1 = version spec — install locally, then run THAT package's upgrade
|
|
232
|
+
retry "npm install $PKG@$1" npm install "$PKG@$1" --save --no-audit --no-fund --prefer-online || return 1
|
|
61
233
|
node "$AGENT_DIR/node_modules/$PKG/bin/maestro.mjs" upgrade --force-overwrite >> "$LOG" 2>&1 || log "WARN: upgrade nonzero for $1"
|
|
62
234
|
return 0
|
|
63
235
|
}
|
|
64
|
-
|
|
236
|
+
refresh_global(){ # $1 = version — the rollback's counterpart of upgrade() step 4
|
|
237
|
+
if [ "${MAESTRO_SKIP_GLOBAL_INSTALL:-}" = "1" ]; then
|
|
238
|
+
log "global refresh skipped (MAESTRO_SKIP_GLOBAL_INSTALL=1): npm install -g $PKG@$1 --no-audit --no-fund"; return 0
|
|
239
|
+
fi
|
|
240
|
+
retry "npm install -g $PKG@$1" npm install -g "$PKG@$1" --no-audit --no-fund || log "WARN: global refresh to $1 failed — run: npm install -g $PKG@$1"
|
|
241
|
+
return 0
|
|
242
|
+
}
|
|
243
|
+
is_healthy(){ # THIS agent's daemon process alive AND a fresh boot/connect line
|
|
65
244
|
pgrep -f "$AGENT_DIR/scripts/daemon/maestro-daemon.mjs" >/dev/null 2>&1 || return 1
|
|
66
245
|
tail -80 "$DLOG" 2>/dev/null | grep -qE "org-mesh connected|daemon\] Running" || return 1
|
|
67
246
|
return 0
|
|
68
247
|
}
|
|
248
|
+
session_reconciled(){ # only asked when a -session plist was generated; never a rollback reason
|
|
249
|
+
label_loaded "$SESSION_LABEL" || { log "reconcile-failed: session job $SESSION_LABEL is not loaded (maestro session start)"; return 1; }
|
|
250
|
+
pgrep -f "$AGENT_DIR/scripts/session/supervisor" >/dev/null 2>&1 || { log "reconcile-failed: $SESSION_LABEL is loaded but no supervisor process is alive for $AGENT_DIR (maestro session status)"; return 1; }
|
|
251
|
+
return 0
|
|
252
|
+
}
|
|
253
|
+
write_upgrade_notice(){ # $1 = from, $2 = to — tell the front-door session; never restart it here
|
|
254
|
+
local dir="$AGENT_DIR/state/session" tmp
|
|
255
|
+
mkdir -p "$dir" 2>/dev/null || return 0
|
|
256
|
+
tmp="$(mktemp "$dir/.upgrade-notice.XXXXXX" 2>/dev/null)" || return 0
|
|
257
|
+
printf '{\n "from": "%s",\n "to": "%s",\n "at": "%s"\n}\n' "$1" "$2" "$(date -u +%FT%TZ)" > "$tmp" \
|
|
258
|
+
&& mv -f "$tmp" "$dir/upgrade-notice.json" \
|
|
259
|
+
&& log "session notified: upgrade-notice.json $1 -> $2 (session restarts itself when idle)"
|
|
260
|
+
rm -f "$tmp" 2>/dev/null
|
|
261
|
+
return 0
|
|
262
|
+
}
|
|
263
|
+
write_last(){ # $1 from, $2 to, $3 ok, $4 healthy, $5 reason — state/autoupdate/last.json (→ beat machine.upgrade)
|
|
264
|
+
local dir="$AGENT_DIR/state/autoupdate" tmp
|
|
265
|
+
mkdir -p "$dir" 2>/dev/null || return 0
|
|
266
|
+
tmp="$(mktemp "$dir/.last.XXXXXX" 2>/dev/null)" || return 0
|
|
267
|
+
printf '{\n "from": "%s",\n "to": "%s",\n "at": "%s",\n "ok": %s,\n "healthy": %s,\n "reason": "%s"\n}\n' \
|
|
268
|
+
"$1" "$2" "$(date -u +%FT%TZ)" "$3" "$4" "$5" > "$tmp" && mv -f "$tmp" "$dir/last.json"
|
|
269
|
+
rm -f "$tmp" 2>/dev/null
|
|
270
|
+
return 0
|
|
271
|
+
}
|
|
272
|
+
migrate_poller_plist(){ # audit F6 follow-through: an older install left SLACK_USER_TOKEN in a 0644 poller plist; nothing else rewrites it
|
|
273
|
+
[ -f "$AGENT_DIR/scripts/poller-launchd/migrate.sh" ] || return 0
|
|
274
|
+
bash "$AGENT_DIR/scripts/poller-launchd/migrate.sh" >> "$LOG" 2>&1 || log "WARN: poller plist migration nonzero (see above)"
|
|
275
|
+
return 0
|
|
276
|
+
}
|
|
69
277
|
|
|
70
278
|
if ! apply_version "$LATEST"; then
|
|
71
|
-
log "npm install FAILED; aborting, staying on $CUR"
|
|
279
|
+
log "npm install FAILED; aborting, staying on $CUR"
|
|
280
|
+
write_last "$CUR" "$LATEST" false false "install-failed"
|
|
281
|
+
exit 1
|
|
72
282
|
fi
|
|
73
283
|
restart_daemon
|
|
74
|
-
sleep
|
|
284
|
+
[ "$HEALTH_WAIT" -gt 0 ] && sleep "$HEALTH_WAIT"
|
|
285
|
+
|
|
75
286
|
if is_healthy; then
|
|
76
|
-
|
|
287
|
+
REASON=""
|
|
288
|
+
if [ -n "$SESSION_LABEL" ] && ! session_reconciled; then REASON="session-reconcile-failed"; fi
|
|
289
|
+
log "OK: healthy on $LATEST${REASON:+ ($REASON)}"
|
|
290
|
+
write_upgrade_notice "$CUR" "$LATEST"
|
|
291
|
+
write_last "$CUR" "$LATEST" true true "$REASON"
|
|
292
|
+
migrate_poller_plist
|
|
77
293
|
else
|
|
78
294
|
log "UNHEALTHY on $LATEST -> ROLLBACK to $CUR"
|
|
79
295
|
apply_version "$CUR" || log "WARN: rollback install nonzero"
|
|
296
|
+
refresh_global "$CUR"
|
|
297
|
+
revert_session_install
|
|
80
298
|
restart_daemon
|
|
81
|
-
sleep 20
|
|
82
|
-
is_healthy
|
|
299
|
+
[ "$HEALTH_WAIT" -gt 0 ] && sleep 20
|
|
300
|
+
if is_healthy; then
|
|
301
|
+
log "rolled back to $CUR (healthy)"
|
|
302
|
+
write_last "$CUR" "$LATEST" false true "unhealthy-rolled-back"
|
|
303
|
+
else
|
|
304
|
+
log "ROLLBACK health still failing on $CUR — needs operator"
|
|
305
|
+
write_last "$CUR" "$LATEST" false false "rollback-unhealthy"
|
|
306
|
+
fi
|
|
83
307
|
fi
|