@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
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/cli/inbox.mjs — `maestro inbox list|show|claim|reply|done|defer`
|
|
3
|
+
* (design §3.3): the main session's CLI over the inbox the daemon fills.
|
|
4
|
+
*
|
|
5
|
+
* One state machine, two actors. The daemon and the session share the file
|
|
6
|
+
* markers `scripts/poller/utils.mjs` defines (`.yaml` → `.yaml.dispatched` →
|
|
7
|
+
* `.yaml.processed`, `.yaml.deferred`) plus the session markers in
|
|
8
|
+
* `lib/session/inbox-claims.mjs`, so a claim made here is visible to the
|
|
9
|
+
* daemon's sweep and a `.processed` written here is terminal for both.
|
|
10
|
+
*
|
|
11
|
+
* list [--service <svc>] [--new] pending items (JSON)
|
|
12
|
+
* show <id> the full item (+ history when the body is empty)
|
|
13
|
+
* claim <id> .yaml → .yaml.dispatched, claimed_by: session
|
|
14
|
+
* reply <id> (--text "…" | --file <p>) routes through scripts/daemon/deliver.mjs#deliver
|
|
15
|
+
* (surface routing, send-gate, audit, receipts)
|
|
16
|
+
* done <id> .processed (terminal)
|
|
17
|
+
* defer <id> --until <iso|+30m> [--reason "…"] parked; the daemon sweep reopens it when due
|
|
18
|
+
*
|
|
19
|
+
* Output is one JSON document on stdout. Exit 0 on success, 1 on any refusal
|
|
20
|
+
* (`{ok:false, error}` — expected failures are values, never throws). Every
|
|
21
|
+
* effect outside the inbox directory is an injected seam (`deliver`,
|
|
22
|
+
* `fetchHistory`, `now`, `stdout`) so the command is testable offline.
|
|
23
|
+
*
|
|
24
|
+
* @module lib/cli/inbox
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { readdirSync, readFileSync, existsSync, renameSync } from "node:fs";
|
|
29
|
+
|
|
30
|
+
import { parseInboxItemYaml } from "../../scripts/poller/inbox-scan-poller.mjs";
|
|
31
|
+
import { maskTextString } from "../security/redact.mjs";
|
|
32
|
+
import {
|
|
33
|
+
findInboxFile,
|
|
34
|
+
markSessionClaim,
|
|
35
|
+
markSessionDeferral,
|
|
36
|
+
markSessionReplied,
|
|
37
|
+
parseSessionMarkers,
|
|
38
|
+
stripMarkersInPlace,
|
|
39
|
+
} from "../session/inbox-claims.mjs";
|
|
40
|
+
|
|
41
|
+
const PREVIEW_CHARS = 240;
|
|
42
|
+
|
|
43
|
+
export const USAGE = `Usage:
|
|
44
|
+
maestro inbox list [--service <svc>] [--new]
|
|
45
|
+
maestro inbox show <id>
|
|
46
|
+
maestro inbox claim <id>
|
|
47
|
+
maestro inbox reply <id> (--text "…" | --file <path>)
|
|
48
|
+
maestro inbox done <id>
|
|
49
|
+
maestro inbox defer <id> --until <iso|+30m|+2h|+1d> [--reason "…"]
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
/** `--key value` / `--flag` / positionals. */
|
|
53
|
+
function parseArgs(argv) {
|
|
54
|
+
const flags = {};
|
|
55
|
+
const pos = [];
|
|
56
|
+
for (let i = 0; i < argv.length; i++) {
|
|
57
|
+
const a = argv[i];
|
|
58
|
+
if (a.startsWith("--")) {
|
|
59
|
+
const key = a.slice(2);
|
|
60
|
+
const next = argv[i + 1];
|
|
61
|
+
if (next !== undefined && !next.startsWith("--")) { flags[key] = next; i++; }
|
|
62
|
+
else flags[key] = true;
|
|
63
|
+
} else pos.push(a);
|
|
64
|
+
}
|
|
65
|
+
return { flags, pos };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `+30m` / `+2h` / `+1d` relative to `now`, or an ISO timestamp. Pure.
|
|
70
|
+
* @param {string} text
|
|
71
|
+
* @param {number} now epoch ms
|
|
72
|
+
* @returns {number|null} epoch ms, or null when unparsable
|
|
73
|
+
*/
|
|
74
|
+
export function parseUntil(text, now) {
|
|
75
|
+
const s = String(text || "").trim();
|
|
76
|
+
const rel = s.match(/^\+(\d+)([mhd])$/i);
|
|
77
|
+
if (rel) {
|
|
78
|
+
const n = Number(rel[1]);
|
|
79
|
+
const unit = { m: 60_000, h: 3_600_000, d: 86_400_000 }[rel[2].toLowerCase()];
|
|
80
|
+
return Number.isFinite(n) && n > 0 ? now + n * unit : null;
|
|
81
|
+
}
|
|
82
|
+
const abs = Date.parse(s);
|
|
83
|
+
return Number.isFinite(abs) ? abs : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolveAgentRoot(deps) {
|
|
87
|
+
return deps.agentRoot || process.env.AGENT_ROOT || process.env.AGENT_DIR || process.cwd();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function serviceDirs(agentRoot, only) {
|
|
91
|
+
if (only) return [String(only)];
|
|
92
|
+
try {
|
|
93
|
+
return readdirSync(join(agentRoot, "state", "inbox"), { withFileTypes: true })
|
|
94
|
+
.filter((e) => e.isDirectory() && e.name !== "attachments")
|
|
95
|
+
.map((e) => e.name)
|
|
96
|
+
.sort();
|
|
97
|
+
} catch { return []; /* no inbox yet */ }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The daemon's channel-kind enrichment, in miniature, so a reply built here
|
|
102
|
+
* routes exactly as the responder's would (`is_dm` decides the DM/thread
|
|
103
|
+
* transport in deliver.mjs). Mirrors scripts/daemon/agent-daemon#enrichItem.
|
|
104
|
+
*/
|
|
105
|
+
function enrich(item, service) {
|
|
106
|
+
const kind = String(item.channel_kind || "").toUpperCase();
|
|
107
|
+
const channelStr = String(item.channel || "").toLowerCase();
|
|
108
|
+
const channelId = String(item.channel_id || "");
|
|
109
|
+
item.is_dm = kind
|
|
110
|
+
? kind === "DM"
|
|
111
|
+
: typeof item.is_dm === "boolean"
|
|
112
|
+
? item.is_dm
|
|
113
|
+
: channelStr.startsWith("dm/") || channelId.startsWith("D");
|
|
114
|
+
if (!item.service) item.service = service;
|
|
115
|
+
return item;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readItem(found) {
|
|
119
|
+
const raw = readFileSync(found.path, "utf-8");
|
|
120
|
+
const item = enrich(parseInboxItemYaml(raw), found.service);
|
|
121
|
+
return { raw, item, markers: parseSessionMarkers(raw) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function preview(text) {
|
|
125
|
+
const one = String(text || "").replace(/\s+/g, " ").trim();
|
|
126
|
+
const cut = one.length > PREVIEW_CHARS ? `${one.slice(0, PREVIEW_CHARS - 1)}…` : one;
|
|
127
|
+
try { return maskTextString(cut); } catch { return cut; /* masking is defence in depth, never a blocker */ }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── subcommands ──────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
function list(agentRoot, flags) {
|
|
133
|
+
const out = [];
|
|
134
|
+
for (const service of serviceDirs(agentRoot, flags.service)) {
|
|
135
|
+
const dir = join(agentRoot, "state", "inbox", service);
|
|
136
|
+
if (!existsSync(dir)) continue;
|
|
137
|
+
let names;
|
|
138
|
+
try { names = readdirSync(dir); } catch { continue; }
|
|
139
|
+
for (const file of names) {
|
|
140
|
+
const isNew = file.endsWith(".yaml");
|
|
141
|
+
const isClaimed = file.endsWith(".yaml.dispatched");
|
|
142
|
+
if (!isNew && !isClaimed) continue;
|
|
143
|
+
try {
|
|
144
|
+
const raw = readFileSync(join(dir, file), "utf-8");
|
|
145
|
+
const markers = parseSessionMarkers(raw);
|
|
146
|
+
// A daemon-owned `.dispatched` (no session marker) is not the session's.
|
|
147
|
+
if (isClaimed && !markers.claimedBy) continue;
|
|
148
|
+
if (flags.new && isClaimed) continue;
|
|
149
|
+
const item = parseInboxItemYaml(raw);
|
|
150
|
+
if (!item.id) continue;
|
|
151
|
+
out.push({
|
|
152
|
+
id: item.id,
|
|
153
|
+
service,
|
|
154
|
+
state: isClaimed ? "claimed" : "new",
|
|
155
|
+
kind: item.kind || "message",
|
|
156
|
+
channel: item.channel || null,
|
|
157
|
+
channel_id: item.channel_id || null,
|
|
158
|
+
channel_kind: item.channel_kind || null,
|
|
159
|
+
sender: item.sender || null,
|
|
160
|
+
timestamp: item.timestamp || null,
|
|
161
|
+
subject: item.subject || null,
|
|
162
|
+
thread_id: item.thread_id || null,
|
|
163
|
+
preview: preview(item.content),
|
|
164
|
+
path: join(dir, file),
|
|
165
|
+
...(markers.claimedAt ? { claimedAt: markers.claimedAt } : {}),
|
|
166
|
+
});
|
|
167
|
+
} catch { /* unreadable item — skipped, the daemon's own scan will report it */ }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
out.sort((a, b) => String(a.timestamp || "").localeCompare(String(b.timestamp || "")) || a.id.localeCompare(b.id));
|
|
171
|
+
return { code: 0, body: out };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function show(agentRoot, id, flags, deps) {
|
|
175
|
+
const found = findInboxFile(agentRoot, id, { service: flags.service });
|
|
176
|
+
if (!found) return { code: 1, body: { ok: false, error: "not-found", id } };
|
|
177
|
+
const { raw, item, markers } = readItem(found);
|
|
178
|
+
const body = { ok: true, id: item.id, service: found.service, state: found.state, path: found.path, item, markers, raw };
|
|
179
|
+
if (!String(item.content || "").trim() && item.channel_id) {
|
|
180
|
+
// The body was not hydrated at intake — pull recent history so the
|
|
181
|
+
// session can see the ask. Fail-open: no org, no network → [].
|
|
182
|
+
body.history = await hydrateHistory(agentRoot, item, deps);
|
|
183
|
+
}
|
|
184
|
+
return { code: 0, body };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function hydrateHistory(agentRoot, item, deps) {
|
|
188
|
+
try {
|
|
189
|
+
const impl = deps.fetchHistory || (await defaultFetchHistory(agentRoot));
|
|
190
|
+
const rows = await impl({ channel: item.channel_id, limit: 20 });
|
|
191
|
+
return Array.isArray(rows) ? rows : [];
|
|
192
|
+
} catch { return []; /* history is a convenience, never a blocker */ }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function defaultFetchHistory(agentRoot) {
|
|
196
|
+
const [{ fetchHistory }, { loadOrgConfig }] = await Promise.all([
|
|
197
|
+
import("../org/messaging.mjs"),
|
|
198
|
+
import("../org/client.mjs"),
|
|
199
|
+
]);
|
|
200
|
+
const cfg = loadOrgConfig(agentRoot);
|
|
201
|
+
return (params) => fetchHistory(params, { cfg });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function claim(agentRoot, id, flags, deps) {
|
|
205
|
+
const found = findInboxFile(agentRoot, id, { service: flags.service });
|
|
206
|
+
if (!found) return { code: 1, body: { ok: false, error: "not-found", id } };
|
|
207
|
+
if (found.state !== "new") return { code: 1, body: { ok: false, error: "not-claimable", id, state: found.state } };
|
|
208
|
+
const r = markSessionClaim(agentRoot, found.service, { id }, { now: deps.now });
|
|
209
|
+
return r.ok ? { code: 0, body: { ok: true, id, service: found.service, path: r.path } } : { code: 1, body: { ok: false, error: r.error, id } };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function reply(agentRoot, id, flags, deps) {
|
|
213
|
+
let text = typeof flags.text === "string" ? flags.text : null;
|
|
214
|
+
if (text == null && typeof flags.file === "string") {
|
|
215
|
+
try { text = readFileSync(flags.file, "utf-8"); }
|
|
216
|
+
catch (err) { return { code: 1, body: { ok: false, error: `cannot read --file: ${err.message}`, id } }; }
|
|
217
|
+
}
|
|
218
|
+
text = String(text || "").trim();
|
|
219
|
+
if (!text) return { code: 1, body: { ok: false, error: "reply text required (--text or --file)", id } };
|
|
220
|
+
const found = findInboxFile(agentRoot, id, { service: flags.service });
|
|
221
|
+
if (!found) return { code: 1, body: { ok: false, error: "not-found", id } };
|
|
222
|
+
if (found.state === "processed" || found.state === "bundled") return { code: 1, body: { ok: false, error: "already-processed", id } };
|
|
223
|
+
const { item } = readItem(found);
|
|
224
|
+
const deliverImpl = deps.deliver || (await import("../../scripts/daemon/deliver.mjs")).deliver;
|
|
225
|
+
let result;
|
|
226
|
+
try { result = await deliverImpl(item, text, { kind: "reply", agentRoot }); }
|
|
227
|
+
catch (err) { result = { sent: false, via: null, error: err && err.message ? err.message : String(err) }; }
|
|
228
|
+
const body = { ok: !!(result && result.sent), id, service: found.service, ...(result || {}) };
|
|
229
|
+
// Record the reply ON the item (replied_at) so the daemon's assurance sweep
|
|
230
|
+
// never reopens — and re-answers — an item the human already heard back on.
|
|
231
|
+
// Only an in-flight claim carries markers; a reply to a new item is sent
|
|
232
|
+
// but leaves the item claimable (the session did not claim it).
|
|
233
|
+
if (body.ok && found.state === "claimed") {
|
|
234
|
+
const stamped = markSessionReplied(agentRoot, found.service, { id }, { now: deps.now });
|
|
235
|
+
body.replied_recorded = stamped.ok;
|
|
236
|
+
}
|
|
237
|
+
return { code: body.ok ? 0 : 1, body };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function done(agentRoot, id, flags) {
|
|
241
|
+
const found = findInboxFile(agentRoot, id, { service: flags.service });
|
|
242
|
+
if (!found) return { code: 1, body: { ok: false, error: "not-found", id } };
|
|
243
|
+
if (found.state === "processed" || found.state === "bundled") return { code: 1, body: { ok: false, error: "already-processed", id } };
|
|
244
|
+
// ONE file — the located item — to `<base>.yaml.processed`, the terminal
|
|
245
|
+
// name the scanner skips. (utils#markInboxProcessed matches by substring and
|
|
246
|
+
// would retire look-alike ids beside it.) Markers are stripped in place.
|
|
247
|
+
let renamed = 0;
|
|
248
|
+
try {
|
|
249
|
+
stripMarkersInPlace(found.path);
|
|
250
|
+
renameSync(found.path, found.path.replace(/\.(dispatched|deferred)$/, "") + ".processed");
|
|
251
|
+
renamed = 1;
|
|
252
|
+
} catch { renamed = 0; /* vanished under us — reported below as not-finishable */ }
|
|
253
|
+
return renamed
|
|
254
|
+
? { code: 0, body: { ok: true, id, service: found.service, renamed } }
|
|
255
|
+
: { code: 1, body: { ok: false, error: "not-finishable", id, state: found.state } };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function defer(agentRoot, id, flags, deps) {
|
|
259
|
+
if (flags.until === undefined || flags.until === true) return { code: 1, body: { ok: false, error: "--until required (iso | +30m | +2h | +1d)", id } };
|
|
260
|
+
const now = typeof deps.now === "number" ? deps.now : Date.now();
|
|
261
|
+
const until = parseUntil(flags.until, now);
|
|
262
|
+
if (until == null) return { code: 1, body: { ok: false, error: `invalid --until: ${flags.until}`, id } };
|
|
263
|
+
const found = findInboxFile(agentRoot, id, { service: flags.service });
|
|
264
|
+
if (!found) return { code: 1, body: { ok: false, error: "not-found", id } };
|
|
265
|
+
const r = markSessionDeferral(agentRoot, found.service, { id }, { until, reason: typeof flags.reason === "string" ? flags.reason : "", now });
|
|
266
|
+
return r.ok
|
|
267
|
+
? { code: 0, body: { ok: true, id, service: found.service, path: r.path, until: r.until } }
|
|
268
|
+
: { code: 1, body: { ok: false, error: r.error, id } };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Run `maestro inbox …`. Prints one JSON document (or usage) and returns the
|
|
273
|
+
* exit code; the caller decides whether to `process.exit`.
|
|
274
|
+
*
|
|
275
|
+
* @param {string[]} argv args after `inbox`
|
|
276
|
+
* @param {object} [deps]
|
|
277
|
+
* @param {string} [deps.agentRoot]
|
|
278
|
+
* @param {Function} [deps.deliver] (item, text, o) → delivery result
|
|
279
|
+
* @param {Function} [deps.fetchHistory] (params) → rows
|
|
280
|
+
* @param {number} [deps.now]
|
|
281
|
+
* @param {Function} [deps.stdout] (string) → void
|
|
282
|
+
* @returns {Promise<number>} exit code
|
|
283
|
+
*/
|
|
284
|
+
export async function runInbox(argv = [], deps = {}) {
|
|
285
|
+
const write = deps.stdout || ((s) => process.stdout.write(s));
|
|
286
|
+
const { flags, pos } = parseArgs(argv);
|
|
287
|
+
const [sub, id] = pos;
|
|
288
|
+
const agentRoot = resolveAgentRoot(deps);
|
|
289
|
+
const emit = (r) => { write(JSON.stringify(r.body, null, 2) + "\n"); return r.code; };
|
|
290
|
+
if (!sub || sub === "help" || flags.help) { write(USAGE); return sub === "help" || flags.help ? 0 : 1; }
|
|
291
|
+
if (!["list", "show", "claim", "reply", "done", "defer"].includes(sub)) { write(`Unknown inbox command: ${sub}\n${USAGE}`); return 1; }
|
|
292
|
+
if (sub !== "list" && !id) return emit({ code: 1, body: { ok: false, error: `${sub} requires an <id>` } });
|
|
293
|
+
switch (sub) {
|
|
294
|
+
case "list": return emit(list(agentRoot, flags));
|
|
295
|
+
case "show": return emit(await show(agentRoot, id, flags, deps));
|
|
296
|
+
case "claim": return emit(claim(agentRoot, id, flags, deps));
|
|
297
|
+
case "reply": return emit(await reply(agentRoot, id, flags, deps));
|
|
298
|
+
case "done": return emit(done(agentRoot, id, flags));
|
|
299
|
+
case "defer": return emit(defer(agentRoot, id, flags, deps));
|
|
300
|
+
default: return 1;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export default { runInbox, parseUntil, USAGE };
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inbox.test.mjs — `maestro inbox list|show|claim|reply|done|defer` (design
|
|
3
|
+
* §3.3): the session's CLI over the SAME inbox state machine the daemon uses
|
|
4
|
+
* (scripts/poller/utils.mjs markers + lib/session/inbox-claims markers).
|
|
5
|
+
*
|
|
6
|
+
* Everything with an effect outside the inbox directory is injected: the
|
|
7
|
+
* delivery seam (`deliver`), message-history hydration, the clock and stdout.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
|
|
16
|
+
import { runInbox, parseUntil } from "./inbox.mjs";
|
|
17
|
+
|
|
18
|
+
const T0 = Date.parse("2026-09-08T10:00:00.000Z");
|
|
19
|
+
const ITEM = (id, content = "can you send me the deck?") => `id: "${id}"
|
|
20
|
+
service: "cohort"
|
|
21
|
+
channel: "dm/olivia"
|
|
22
|
+
channel_id: "ch_1"
|
|
23
|
+
channel_kind: "DM"
|
|
24
|
+
sender: "Olivia Chen"
|
|
25
|
+
timestamp: "2026-09-08T09:59:00.000Z"
|
|
26
|
+
subject: ""
|
|
27
|
+
content: |
|
|
28
|
+
${content}
|
|
29
|
+
raw_ref: "${id}"
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
function root() {
|
|
33
|
+
const dir = mkdtempSync(join(tmpdir(), "maestro-cli-inbox-"));
|
|
34
|
+
mkdirSync(join(dir, "state", "inbox", "cohort"), { recursive: true });
|
|
35
|
+
mkdirSync(join(dir, "state", "inbox", "slack"), { recursive: true });
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
function rm(dir) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* */ } }
|
|
39
|
+
function plant(dir, service, name, body) { writeFileSync(join(dir, "state", "inbox", service, name), body); }
|
|
40
|
+
function ls(dir, service = "cohort") { return readdirSync(join(dir, "state", "inbox", service)).sort(); }
|
|
41
|
+
|
|
42
|
+
/** Run the CLI with captured stdout; returns { code, out (parsed JSON or raw) }. */
|
|
43
|
+
async function run(dir, argv, deps = {}) {
|
|
44
|
+
let out = "";
|
|
45
|
+
const code = await runInbox(argv, { agentRoot: dir, now: T0, stdout: (s) => { out += s; }, ...deps });
|
|
46
|
+
let parsed = out;
|
|
47
|
+
try { parsed = JSON.parse(out); } catch { /* raw text (help) */ }
|
|
48
|
+
return { code, out: parsed, raw: out };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test("list: pending items as JSON across services; --service narrows; --new hides session claims; previews are bounded", async () => {
|
|
52
|
+
const dir = root();
|
|
53
|
+
try {
|
|
54
|
+
plant(dir, "cohort", "2026-09-08T09-59-00-000Z-c-1.yaml", ITEM("c-1", "x".repeat(600)));
|
|
55
|
+
plant(dir, "cohort", "2026-09-08T09-58-00-000Z-c-0.yaml.processed", ITEM("c-0"));
|
|
56
|
+
plant(dir, "cohort", "2026-09-08T09-57-00-000Z-c-old.yaml.dispatched", ITEM("c-old")); // daemon-owned: hidden
|
|
57
|
+
plant(dir, "cohort", "2026-09-08T09-56-00-000Z-c-mine.yaml.dispatched", ITEM("c-mine") + 'claimed_by: "session"\nclaimed_at: "2026-09-08T09:56:30.000Z"\n');
|
|
58
|
+
plant(dir, "slack", "2026-09-08T09-55-00-000Z-s-1.yaml", ITEM("s-1").replace('service: "cohort"', 'service: "slack"'));
|
|
59
|
+
|
|
60
|
+
const all = await run(dir, ["list"]);
|
|
61
|
+
assert.equal(all.code, 0);
|
|
62
|
+
// Chronological (timestamp, then id) — every planted item shares one stamp here.
|
|
63
|
+
assert.deepEqual(all.out.map((i) => [i.id, i.service, i.state]), [["c-1", "cohort", "new"], ["c-mine", "cohort", "claimed"], ["s-1", "slack", "new"]]);
|
|
64
|
+
const c1 = all.out.find((i) => i.id === "c-1");
|
|
65
|
+
assert.equal(c1.sender, "Olivia Chen");
|
|
66
|
+
assert.equal(c1.channel_id, "ch_1");
|
|
67
|
+
assert.ok(c1.preview.length <= 240, "preview is bounded");
|
|
68
|
+
assert.ok(c1.path.endsWith("c-1.yaml"));
|
|
69
|
+
assert.equal(all.out.find((i) => i.id === "c-mine").claimedAt, "2026-09-08T09:56:30.000Z");
|
|
70
|
+
|
|
71
|
+
const cohort = await run(dir, ["list", "--service", "cohort"]);
|
|
72
|
+
assert.deepEqual(cohort.out.map((i) => i.id), ["c-1", "c-mine"]);
|
|
73
|
+
const fresh = await run(dir, ["list", "--new"]);
|
|
74
|
+
assert.deepEqual(fresh.out.map((i) => i.id), ["c-1", "s-1"]);
|
|
75
|
+
const none = await run(dir, ["list", "--service", "telegram"]);
|
|
76
|
+
assert.deepEqual(none.out, []);
|
|
77
|
+
} finally { rm(dir); }
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("show: the full parsed item + state + markers; hydrates history when the body is empty (fail-open)", async () => {
|
|
81
|
+
const dir = root();
|
|
82
|
+
try {
|
|
83
|
+
plant(dir, "cohort", "2026-09-08T09-59-00-000Z-c-1.yaml", ITEM("c-1"));
|
|
84
|
+
const r = await run(dir, ["show", "c-1"]);
|
|
85
|
+
assert.equal(r.code, 0);
|
|
86
|
+
assert.equal(r.out.item.id, "c-1");
|
|
87
|
+
assert.equal(r.out.item.content, "can you send me the deck?");
|
|
88
|
+
assert.equal(r.out.item.is_dm, true, "channel_kind DM → enriched as a DM, the way the daemon's responder sees it");
|
|
89
|
+
assert.equal(r.out.state, "new");
|
|
90
|
+
assert.equal(r.out.service, "cohort");
|
|
91
|
+
assert.equal(r.out.history, undefined, "no hydration needed when the body is present");
|
|
92
|
+
|
|
93
|
+
// Empty body → hydrate via messaging.history through the injected seam.
|
|
94
|
+
plant(dir, "cohort", "2026-09-08T09-59-30-000Z-c-2.yaml", ITEM("c-2", "").replace("content: |\n \n", 'content: ""\n'));
|
|
95
|
+
const calls = [];
|
|
96
|
+
const r2 = await run(dir, ["show", "c-2"], { fetchHistory: async (params) => { calls.push(params); return [{ id: "m1", body: "the actual ask" }]; } });
|
|
97
|
+
assert.equal(r2.code, 0);
|
|
98
|
+
assert.equal(calls.length, 1);
|
|
99
|
+
assert.equal(calls[0].channel, "ch_1");
|
|
100
|
+
assert.deepEqual(r2.out.history, [{ id: "m1", body: "the actual ask" }]);
|
|
101
|
+
// A throwing history seam never breaks show.
|
|
102
|
+
const r3 = await run(dir, ["show", "c-2"], { fetchHistory: async () => { throw new Error("offline"); } });
|
|
103
|
+
assert.equal(r3.code, 0);
|
|
104
|
+
assert.deepEqual(r3.out.history, []);
|
|
105
|
+
const missing = await run(dir, ["show", "ghost"]);
|
|
106
|
+
assert.equal(missing.code, 1);
|
|
107
|
+
assert.equal(missing.out.ok, false);
|
|
108
|
+
} finally { rm(dir); }
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("claim → reply → done: the daemon-shared state machine, with reply routed through deliver()", async () => {
|
|
112
|
+
const dir = root();
|
|
113
|
+
try {
|
|
114
|
+
plant(dir, "cohort", "2026-09-08T09-59-00-000Z-c-1.yaml", ITEM("c-1"));
|
|
115
|
+
const c = await run(dir, ["claim", "c-1"]);
|
|
116
|
+
assert.equal(c.code, 0);
|
|
117
|
+
assert.equal(c.out.ok, true);
|
|
118
|
+
assert.deepEqual(ls(dir), ["2026-09-08T09-59-00-000Z-c-1.yaml.dispatched"]);
|
|
119
|
+
assert.match(readFileSync(c.out.path, "utf-8"), /claimed_by: "session"/);
|
|
120
|
+
const twice = await run(dir, ["claim", "c-1"]);
|
|
121
|
+
assert.equal(twice.code, 1);
|
|
122
|
+
assert.equal(twice.out.error, "not-claimable");
|
|
123
|
+
|
|
124
|
+
const sends = [];
|
|
125
|
+
const deliver = async (item, text, o) => { sends.push({ item, text, o }); return { sent: true, via: "org-messaging", channel: item.channel_id }; };
|
|
126
|
+
const r = await run(dir, ["reply", "c-1", "--text", "On its way."], { deliver });
|
|
127
|
+
assert.equal(r.code, 0);
|
|
128
|
+
assert.equal(r.out.sent, true);
|
|
129
|
+
assert.equal(sends.length, 1);
|
|
130
|
+
assert.equal(sends[0].text, "On its way.");
|
|
131
|
+
assert.equal(sends[0].item.id, "c-1");
|
|
132
|
+
assert.equal(sends[0].item.service, "cohort");
|
|
133
|
+
assert.equal(sends[0].item.channel_id, "ch_1");
|
|
134
|
+
assert.equal(sends[0].item.is_dm, true, "the item is enriched the way the daemon's responder sees it");
|
|
135
|
+
assert.equal(sends[0].o.kind, "reply");
|
|
136
|
+
// A successful reply is recorded ON the item so the assurance sweep never re-answers it.
|
|
137
|
+
assert.match(readFileSync(c.out.path, "utf-8"), /replied_at: "2026-09-08T10:00:00\.000Z"/);
|
|
138
|
+
assert.match(readFileSync(c.out.path, "utf-8"), /claimed_by: "session"/, "the claim survives the reply stamp");
|
|
139
|
+
|
|
140
|
+
// --file reads the reply body from disk.
|
|
141
|
+
writeFileSync(join(dir, "reply.md"), "Attached below.\n");
|
|
142
|
+
const rf = await run(dir, ["reply", "c-1", "--file", join(dir, "reply.md")], { deliver });
|
|
143
|
+
assert.equal(rf.code, 0);
|
|
144
|
+
assert.equal(sends[1].text, "Attached below.");
|
|
145
|
+
// A failed delivery is reported and exits 1 — the item is NOT marked done.
|
|
146
|
+
const bad = await run(dir, ["reply", "c-1", "--text", "x"], { deliver: async () => ({ sent: false, via: null, error: "send-gate blocked" }) });
|
|
147
|
+
assert.equal(bad.code, 1);
|
|
148
|
+
assert.equal(bad.out.error, "send-gate blocked");
|
|
149
|
+
assert.deepEqual(ls(dir), ["2026-09-08T09-59-00-000Z-c-1.yaml.dispatched"]);
|
|
150
|
+
// Empty text is refused before touching the seam.
|
|
151
|
+
const empty = await run(dir, ["reply", "c-1", "--text", " "], { deliver });
|
|
152
|
+
assert.equal(empty.code, 1);
|
|
153
|
+
assert.equal(sends.length, 2);
|
|
154
|
+
|
|
155
|
+
const d = await run(dir, ["done", "c-1"]);
|
|
156
|
+
assert.equal(d.code, 0);
|
|
157
|
+
assert.equal(d.out.ok, true);
|
|
158
|
+
assert.deepEqual(ls(dir), ["2026-09-08T09-59-00-000Z-c-1.yaml.processed"]);
|
|
159
|
+
const again = await run(dir, ["done", "c-1"]);
|
|
160
|
+
assert.equal(again.code, 1, "already terminal");
|
|
161
|
+
} finally { rm(dir); }
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("defer: --until accepts +30m / +2h / ISO; parks the item with reason; the sweep reopens it when due", async () => {
|
|
165
|
+
const dir = root();
|
|
166
|
+
try {
|
|
167
|
+
assert.equal(parseUntil("+30m", T0), T0 + 30 * 60_000);
|
|
168
|
+
assert.equal(parseUntil("+2h", T0), T0 + 2 * 3_600_000);
|
|
169
|
+
assert.equal(parseUntil("+1d", T0), T0 + 86_400_000);
|
|
170
|
+
assert.equal(parseUntil("2026-09-08T12:00:00Z", T0), Date.parse("2026-09-08T12:00:00Z"));
|
|
171
|
+
assert.equal(parseUntil("soon", T0), null);
|
|
172
|
+
assert.equal(parseUntil("+5x", T0), null);
|
|
173
|
+
|
|
174
|
+
plant(dir, "cohort", "2026-09-08T09-59-00-000Z-c-1.yaml", ITEM("c-1"));
|
|
175
|
+
const r = await run(dir, ["defer", "c-1", "--until", "+30m", "--reason", "waiting on finance"]);
|
|
176
|
+
assert.equal(r.code, 0);
|
|
177
|
+
assert.equal(r.out.ok, true);
|
|
178
|
+
assert.equal(r.out.until, new Date(T0 + 30 * 60_000).toISOString());
|
|
179
|
+
assert.deepEqual(ls(dir), ["2026-09-08T09-59-00-000Z-c-1.yaml.deferred"]);
|
|
180
|
+
assert.match(readFileSync(r.out.path, "utf-8"), /deferred_reason: "waiting on finance"/);
|
|
181
|
+
const bad = await run(dir, ["defer", "c-1", "--until", "whenever"]);
|
|
182
|
+
assert.equal(bad.code, 1);
|
|
183
|
+
const noUntil = await run(dir, ["defer", "c-1"]);
|
|
184
|
+
assert.equal(noUntil.code, 1);
|
|
185
|
+
// A parked item can still be finished: .deferred → .processed, markers stripped.
|
|
186
|
+
const d = await run(dir, ["done", "c-1"]);
|
|
187
|
+
assert.equal(d.code, 0);
|
|
188
|
+
assert.deepEqual(ls(dir), ["2026-09-08T09-59-00-000Z-c-1.yaml.processed"]);
|
|
189
|
+
assert.doesNotMatch(readFileSync(join(dir, "state/inbox/cohort", ls(dir)[0]), "utf-8"), /deferred_until/);
|
|
190
|
+
} finally { rm(dir); }
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("usage: unknown/missing subcommand prints help and exits 1; ids are required", async () => {
|
|
194
|
+
const dir = root();
|
|
195
|
+
try {
|
|
196
|
+
const h = await run(dir, []);
|
|
197
|
+
assert.equal(h.code, 1);
|
|
198
|
+
assert.match(h.raw, /maestro inbox (list|show)/);
|
|
199
|
+
assert.equal((await run(dir, ["bogus"])).code, 1);
|
|
200
|
+
const noId = await run(dir, ["show"]);
|
|
201
|
+
assert.equal(noId.code, 1);
|
|
202
|
+
assert.equal(noId.out.ok, false);
|
|
203
|
+
} finally { rm(dir); }
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("claim/done touch ONLY the named item: look-alike ids (m1 vs m10) are never renamed alongside it", async () => {
|
|
207
|
+
const dir = root();
|
|
208
|
+
try {
|
|
209
|
+
for (const id of ["cohort-m1", "cohort-m10", "cohort-m11"]) plant(dir, "cohort", `2026-09-08T09-59-00-000Z-${id}.yaml`, ITEM(id));
|
|
210
|
+
const c = await run(dir, ["claim", "cohort-m1"]);
|
|
211
|
+
assert.equal(c.code, 0);
|
|
212
|
+
assert.deepEqual(ls(dir), [
|
|
213
|
+
"2026-09-08T09-59-00-000Z-cohort-m1.yaml.dispatched",
|
|
214
|
+
"2026-09-08T09-59-00-000Z-cohort-m10.yaml",
|
|
215
|
+
"2026-09-08T09-59-00-000Z-cohort-m11.yaml",
|
|
216
|
+
]);
|
|
217
|
+
const d = await run(dir, ["done", "cohort-m1"]);
|
|
218
|
+
assert.equal(d.code, 0);
|
|
219
|
+
assert.equal(d.out.renamed, 1);
|
|
220
|
+
assert.deepEqual(ls(dir), [
|
|
221
|
+
"2026-09-08T09-59-00-000Z-cohort-m1.yaml.processed",
|
|
222
|
+
"2026-09-08T09-59-00-000Z-cohort-m10.yaml",
|
|
223
|
+
"2026-09-08T09-59-00-000Z-cohort-m11.yaml",
|
|
224
|
+
]);
|
|
225
|
+
// A truncated id that could mean several items is refused, not guessed.
|
|
226
|
+
const amb = await run(dir, ["claim", "m1"]);
|
|
227
|
+
assert.equal(amb.code, 1);
|
|
228
|
+
assert.equal(amb.out.error, "not-found");
|
|
229
|
+
} finally { rm(dir); }
|
|
230
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/cli/session-ack.mjs — `maestro session ack <tickId> [--result <path>]`
|
|
3
|
+
* (design §3.4): the main session reports a handed-off cadence tick as done.
|
|
4
|
+
*
|
|
5
|
+
* Moves `state/session/handoffs/<tickId>.json` to `handoffs/done/` with
|
|
6
|
+
* `status:"acked"`, so the consumer's timeout sweep no longer re-enqueues it.
|
|
7
|
+
* Idempotent. Exposed as `ackHandoff` for WP-M3's `lib/cli/session.mjs`
|
|
8
|
+
* (`case "ack"`) and reachable directly as `maestro session-ack` until that
|
|
9
|
+
* lands.
|
|
10
|
+
*
|
|
11
|
+
* @module lib/cli/session-ack
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { ackHandoff as ackHandoffRecord, listHandoffs } from "../session/handoffs.mjs";
|
|
15
|
+
|
|
16
|
+
export const USAGE = `Usage:
|
|
17
|
+
maestro session ack <tickId> [--result <path>] [--note "…"]
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Ack one handoff. Returns the ledger result; never throws.
|
|
22
|
+
* @param {{agentRoot?:string, tickId:string, resultPath?:string, note?:string, now?:number}} o
|
|
23
|
+
* @returns {{ok:true, path:string, already?:boolean}|{ok:false, error:string}}
|
|
24
|
+
*/
|
|
25
|
+
export function ackHandoff(o = {}) {
|
|
26
|
+
const agentRoot = o.agentRoot || process.env.AGENT_ROOT || process.env.AGENT_DIR || process.cwd();
|
|
27
|
+
if (!o.tickId) return { ok: false, error: "tickId required" };
|
|
28
|
+
return ackHandoffRecord(agentRoot, o.tickId, { resultPath: o.resultPath, note: o.note, now: o.now });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run `maestro session ack …` (argv after `ack`). Prints one JSON document
|
|
33
|
+
* and returns the exit code. `--list` prints the open handoffs instead.
|
|
34
|
+
* @param {string[]} argv
|
|
35
|
+
* @param {{agentRoot?:string, now?:number, stdout?:Function}} [deps]
|
|
36
|
+
* @returns {Promise<number>}
|
|
37
|
+
*/
|
|
38
|
+
export async function runSessionAck(argv = [], deps = {}) {
|
|
39
|
+
const write = deps.stdout || ((s) => process.stdout.write(s));
|
|
40
|
+
const agentRoot = deps.agentRoot || process.env.AGENT_ROOT || process.env.AGENT_DIR || process.cwd();
|
|
41
|
+
let tickId = null;
|
|
42
|
+
let resultPath;
|
|
43
|
+
let note;
|
|
44
|
+
let list = false;
|
|
45
|
+
for (let i = 0; i < argv.length; i++) {
|
|
46
|
+
const a = argv[i];
|
|
47
|
+
if (a === "--result") { resultPath = argv[++i]; continue; }
|
|
48
|
+
if (a === "--note") { note = argv[++i]; continue; }
|
|
49
|
+
if (a === "--list") { list = true; continue; }
|
|
50
|
+
if (a === "--help" || a === "-h") { write(USAGE); return 0; }
|
|
51
|
+
if (!a.startsWith("--") && !tickId) tickId = a;
|
|
52
|
+
}
|
|
53
|
+
if (list) {
|
|
54
|
+
write(JSON.stringify(listHandoffs(agentRoot).map(({ path: _p, ...h }) => { void _p; return h; }), null, 2) + "\n");
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
if (!tickId) { write(USAGE); return 1; }
|
|
58
|
+
const r = ackHandoff({ agentRoot, tickId, resultPath, note, now: deps.now });
|
|
59
|
+
write(JSON.stringify({ tickId, ...r }, null, 2) + "\n");
|
|
60
|
+
return r.ok ? 0 : 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default { ackHandoff, runSessionAck, USAGE };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-ack.test.mjs — `maestro session ack <tickId>` moves a handoff to
|
|
3
|
+
* done/ (design §3.4). The function form is what WP-M3's session CLI calls.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
|
|
12
|
+
import { writeHandoff, handoffPaths, listHandoffs } from "../session/handoffs.mjs";
|
|
13
|
+
import { ackHandoff, runSessionAck } from "./session-ack.mjs";
|
|
14
|
+
|
|
15
|
+
const T0 = Date.parse("2026-09-08T10:00:00.000Z");
|
|
16
|
+
function root() { return mkdtempSync(join(tmpdir(), "maestro-session-ack-")); }
|
|
17
|
+
function rm(dir) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* */ } }
|
|
18
|
+
async function run(dir, argv) {
|
|
19
|
+
let out = "";
|
|
20
|
+
const code = await runSessionAck(argv, { agentRoot: dir, now: T0 + 60_000, stdout: (s) => { out += s; } });
|
|
21
|
+
let parsed = out;
|
|
22
|
+
try { parsed = JSON.parse(out); } catch { /* usage text */ }
|
|
23
|
+
return { code, out: parsed, raw: out };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("ackHandoff: acks an open handoff (function form for the session CLI); idempotent; unknown → not-found", () => {
|
|
27
|
+
const dir = root();
|
|
28
|
+
try {
|
|
29
|
+
writeHandoff(dir, { tickId: "evt-1", cadence: "goal-steward", mode: "escalate", promptPath: "/p" }, { now: T0 });
|
|
30
|
+
const r = ackHandoff({ agentRoot: dir, tickId: "evt-1", resultPath: "/tmp/r.md", now: T0 + 1000 });
|
|
31
|
+
assert.equal(r.ok, true);
|
|
32
|
+
assert.deepEqual(listHandoffs(dir), []);
|
|
33
|
+
const done = JSON.parse(readFileSync(join(handoffPaths(dir).done, "evt-1.json"), "utf-8"));
|
|
34
|
+
assert.equal(done.status, "acked");
|
|
35
|
+
assert.equal(done.resultPath, "/tmp/r.md");
|
|
36
|
+
assert.equal(ackHandoff({ agentRoot: dir, tickId: "evt-1" }).already, true);
|
|
37
|
+
assert.equal(ackHandoff({ agentRoot: dir, tickId: "evt-x" }).error, "not-found");
|
|
38
|
+
assert.equal(ackHandoff({ agentRoot: dir }).ok, false);
|
|
39
|
+
} finally { rm(dir); }
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("runSessionAck: CLI form prints JSON and exits 0/1; --list shows open handoffs; usage without an id", async () => {
|
|
43
|
+
const dir = root();
|
|
44
|
+
try {
|
|
45
|
+
writeHandoff(dir, { tickId: "evt-2", cadence: "daily-morning-brief", mode: "escalate", promptPath: "/p" }, { now: T0 });
|
|
46
|
+
const l = await run(dir, ["--list"]);
|
|
47
|
+
assert.equal(l.code, 0);
|
|
48
|
+
assert.deepEqual(l.out.map((h) => h.tickId), ["evt-2"]);
|
|
49
|
+
assert.equal(l.out[0].path, undefined, "no absolute ledger path in the listing");
|
|
50
|
+
const ok = await run(dir, ["evt-2", "--result", "/tmp/out.md", "--note", "done"]);
|
|
51
|
+
assert.equal(ok.code, 0);
|
|
52
|
+
assert.equal(ok.out.ok, true);
|
|
53
|
+
assert.equal(ok.out.tickId, "evt-2");
|
|
54
|
+
assert.equal(existsSync(join(handoffPaths(dir).done, "evt-2.json")), true);
|
|
55
|
+
assert.equal(JSON.parse(readFileSync(join(handoffPaths(dir).done, "evt-2.json"), "utf-8")).note, "done");
|
|
56
|
+
const missing = await run(dir, ["evt-nope"]);
|
|
57
|
+
assert.equal(missing.code, 1);
|
|
58
|
+
assert.equal(missing.out.error, "not-found");
|
|
59
|
+
const usage = await run(dir, []);
|
|
60
|
+
assert.equal(usage.code, 1);
|
|
61
|
+
assert.match(usage.raw, /maestro session ack/);
|
|
62
|
+
} finally { rm(dir); }
|
|
63
|
+
});
|