@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,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/org/board-mine-cache.mjs — the pure half of the daemon's board-mine
|
|
3
|
+
* cache (design 2026-09-08 §3.5; left over from WP-M2/M4).
|
|
4
|
+
*
|
|
5
|
+
* The daemon asks hq `board.mine` (`lib/org/client.mjs#listMine`, fail-open
|
|
6
|
+
* `[]`) every five minutes and writes the answer to
|
|
7
|
+
* `state/org/board-mine.json` as `{ts, items:[...]}`. Three readers already
|
|
8
|
+
* exist: the SessionStart primer (`lib/session/status-summary.mjs`), the
|
|
9
|
+
* `session_status` MCP tool and `maestro board mine` (`lib/cli/board.mjs`,
|
|
10
|
+
* which falls back to this cache when the live read is empty). This module
|
|
11
|
+
* decides WHEN to refresh and WHAT to write; the daemon does the I/O.
|
|
12
|
+
*
|
|
13
|
+
* @module lib/org/board-mine-cache
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
"use strict";
|
|
17
|
+
|
|
18
|
+
/** Five minutes, per the design. */
|
|
19
|
+
export const REFRESH_INTERVAL_MS = 5 * 60_000;
|
|
20
|
+
|
|
21
|
+
/** Where the daemon writes the cache, relative to the agent root. */
|
|
22
|
+
export const BOARD_MINE_REL = "state/org/board-mine.json";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Should the cache be refreshed now? Pure. `lastTs` is the `ts` of the last
|
|
26
|
+
* write (ISO string or epoch ms; null/invalid = never), `now` is epoch ms.
|
|
27
|
+
* A clock that went backwards (lastTs in the future) also refreshes, so a
|
|
28
|
+
* bad timestamp can never freeze the cache.
|
|
29
|
+
* @param {string|number|null|undefined} lastTs
|
|
30
|
+
* @param {number} now
|
|
31
|
+
* @param {number} [intervalMs]
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function shouldRefresh(lastTs, now, intervalMs = REFRESH_INTERVAL_MS) {
|
|
35
|
+
const last = toMs(lastTs);
|
|
36
|
+
if (last === null) return true;
|
|
37
|
+
if (last > now) return true;
|
|
38
|
+
return now - last >= intervalMs;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The document to write. Pure. Non-array or non-object items are dropped so a
|
|
43
|
+
* malformed server answer cannot poison the readers.
|
|
44
|
+
* @param {unknown} items what listMine() returned
|
|
45
|
+
* @param {number} now epoch ms
|
|
46
|
+
* @returns {{ts:string, items:object[]}}
|
|
47
|
+
*/
|
|
48
|
+
export function render(items, now) {
|
|
49
|
+
const list = Array.isArray(items) ? items.filter((it) => it && typeof it === "object" && !Array.isArray(it)) : [];
|
|
50
|
+
return { ts: new Date(now).toISOString(), items: list };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What to write after a refresh attempt. Pure. A successful read renders the
|
|
55
|
+
* fresh items; a FAILED read keeps the previous document's items (last known
|
|
56
|
+
* good) and marks the document `stale: true` with `itemsAt` = when those
|
|
57
|
+
* items were actually fetched — so one unreachable hq tick does not turn the
|
|
58
|
+
* SessionStart primer, session_status and `maestro board mine` into "zero
|
|
59
|
+
* items" for five minutes. With nothing previous, a failed read renders
|
|
60
|
+
* empty-and-stale.
|
|
61
|
+
* @param {{ok:boolean, items?:unknown, error?:string}|unknown[]|null|undefined} result listMineResult() (an array is treated as ok)
|
|
62
|
+
* @param {unknown} prev the current cache document (or null)
|
|
63
|
+
* @param {number} now epoch ms
|
|
64
|
+
* @returns {{ts:string, items:object[], stale?:true, itemsAt?:string|null, error?:string}}
|
|
65
|
+
*/
|
|
66
|
+
export function renderRefresh(result, prev, now) {
|
|
67
|
+
const r = Array.isArray(result) ? { ok: true, items: result } : (result && typeof result === "object" ? result : { ok: false, items: [] });
|
|
68
|
+
if (r.ok) return render(r.items, now);
|
|
69
|
+
const prevDoc = prev && typeof prev === "object" && !Array.isArray(prev) ? prev : null;
|
|
70
|
+
const kept = render(prevDoc ? prevDoc.items : [], now);
|
|
71
|
+
const itemsAt = prevDoc ? (prevDoc.stale && prevDoc.itemsAt !== undefined ? prevDoc.itemsAt : cacheTs(prevDoc)) : null;
|
|
72
|
+
const doc = { ...kept, stale: true, itemsAt: itemsAt || null };
|
|
73
|
+
if (r.error) doc.error = String(r.error).slice(0, 200);
|
|
74
|
+
return doc;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The `ts` of an existing cache document, or null. Tolerates the pre-M6
|
|
79
|
+
* `{fetchedAt}` spelling and a bare array (no timestamp → refresh).
|
|
80
|
+
* @param {unknown} doc
|
|
81
|
+
* @returns {string|null}
|
|
82
|
+
*/
|
|
83
|
+
export function cacheTs(doc) {
|
|
84
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
|
|
85
|
+
if (typeof doc.ts === "string") return doc.ts;
|
|
86
|
+
if (typeof doc.fetchedAt === "string") return doc.fetchedAt;
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function toMs(v) {
|
|
91
|
+
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
92
|
+
if (typeof v === "string" && v.trim()) {
|
|
93
|
+
const n = Date.parse(v);
|
|
94
|
+
if (Number.isFinite(n)) return n;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export default { shouldRefresh, render, renderRefresh, cacheTs, REFRESH_INTERVAL_MS, BOARD_MINE_REL };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* board-mine-cache.test.mjs — the pure half of the daemon's 5-minute
|
|
3
|
+
* state/org/board-mine.json cache.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { shouldRefresh, render, renderRefresh, cacheTs, REFRESH_INTERVAL_MS, BOARD_MINE_REL } from "./board-mine-cache.mjs";
|
|
9
|
+
|
|
10
|
+
const NOW = Date.parse("2026-09-08T12:00:00Z");
|
|
11
|
+
|
|
12
|
+
test("shouldRefresh: never written → yes; younger than 5 min → no; 5 min or older → yes; a future ts → yes", () => {
|
|
13
|
+
assert.equal(shouldRefresh(null, NOW), true);
|
|
14
|
+
assert.equal(shouldRefresh(undefined, NOW), true);
|
|
15
|
+
assert.equal(shouldRefresh("garbage", NOW), true);
|
|
16
|
+
assert.equal(shouldRefresh("2026-09-08T11:56:00Z", NOW), false);
|
|
17
|
+
assert.equal(shouldRefresh(NOW - REFRESH_INTERVAL_MS + 1, NOW), false);
|
|
18
|
+
assert.equal(shouldRefresh("2026-09-08T11:55:00Z", NOW), true);
|
|
19
|
+
assert.equal(shouldRefresh(NOW - REFRESH_INTERVAL_MS, NOW), true);
|
|
20
|
+
assert.equal(shouldRefresh("2026-09-08T12:30:00Z", NOW), true, "a clock that went backwards must not freeze the cache");
|
|
21
|
+
assert.equal(shouldRefresh("2026-09-08T11:59:30Z", NOW, 10_000), true, "interval is a parameter");
|
|
22
|
+
assert.equal(REFRESH_INTERVAL_MS, 300_000);
|
|
23
|
+
assert.equal(BOARD_MINE_REL, "state/org/board-mine.json");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("render: {ts, items} with only object items kept; a non-array answer renders as empty", () => {
|
|
27
|
+
const doc = render([{ itemId: "i1", title: "Do X" }, null, "junk", [1], { itemId: "i2" }], NOW);
|
|
28
|
+
assert.deepEqual(doc, { ts: "2026-09-08T12:00:00.000Z", items: [{ itemId: "i1", title: "Do X" }, { itemId: "i2" }] });
|
|
29
|
+
assert.deepEqual(render(undefined, NOW), { ts: "2026-09-08T12:00:00.000Z", items: [] });
|
|
30
|
+
assert.deepEqual(render({ items: [{ itemId: "x" }] }, NOW).items, [], "an object is not the items array (listMine already unwraps)");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("cacheTs reads ts, tolerates the earlier fetchedAt spelling, and is null for a bare array or junk", () => {
|
|
34
|
+
assert.equal(cacheTs({ ts: "2026-09-08T11:59:00Z", items: [] }), "2026-09-08T11:59:00Z");
|
|
35
|
+
assert.equal(cacheTs({ fetchedAt: "2026-09-08T11:59:00Z", items: [] }), "2026-09-08T11:59:00Z");
|
|
36
|
+
assert.equal(cacheTs([{ itemId: "x" }]), null);
|
|
37
|
+
assert.equal(cacheTs(null), null);
|
|
38
|
+
assert.equal(cacheTs({ items: [] }), null);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("renderRefresh: a successful read renders fresh items; a FAILED read keeps the previous items, marks stale and remembers when they were fetched", () => {
|
|
42
|
+
const prev = { ts: "2026-09-08T11:55:00.000Z", items: [{ itemId: "i1" }] };
|
|
43
|
+
assert.deepEqual(renderRefresh({ ok: true, items: [{ itemId: "i2" }] }, prev, NOW), { ts: "2026-09-08T12:00:00.000Z", items: [{ itemId: "i2" }] });
|
|
44
|
+
assert.deepEqual(renderRefresh([{ itemId: "i3" }], prev, NOW), { ts: "2026-09-08T12:00:00.000Z", items: [{ itemId: "i3" }] }, "a bare array (the old listMine shape) is a success");
|
|
45
|
+
assert.deepEqual(renderRefresh({ ok: true, items: [] }, prev, NOW), { ts: "2026-09-08T12:00:00.000Z", items: [] }, "an empty board is a real answer, not a failure");
|
|
46
|
+
const failed = renderRefresh({ ok: false, items: [], error: "http 503" }, prev, NOW);
|
|
47
|
+
assert.deepEqual(failed, { ts: "2026-09-08T12:00:00.000Z", items: [{ itemId: "i1" }], stale: true, itemsAt: "2026-09-08T11:55:00.000Z", error: "http 503" });
|
|
48
|
+
const failedAgain = renderRefresh({ ok: false, items: [] }, failed, NOW + 300_000);
|
|
49
|
+
assert.equal(failedAgain.itemsAt, "2026-09-08T11:55:00.000Z", "a second failure keeps the ORIGINAL fetch time, not the last stale write");
|
|
50
|
+
assert.deepEqual(failedAgain.items, [{ itemId: "i1" }]);
|
|
51
|
+
assert.deepEqual(renderRefresh({ ok: false, items: [] }, null, NOW), { ts: "2026-09-08T12:00:00.000Z", items: [], stale: true, itemsAt: null }, "nothing previous → empty and stale");
|
|
52
|
+
assert.deepEqual(renderRefresh(null, prev, NOW).items, [{ itemId: "i1" }], "a null result is a failure");
|
|
53
|
+
});
|
package/lib/org/board.mjs
CHANGED
|
@@ -128,6 +128,17 @@ export function ready(opts = {}) {
|
|
|
128
128
|
return resolveClient(opts).listReady(rpcOpts(opts));
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Read THIS agent's items across every board (GET /v1/board.mine — assignee
|
|
133
|
+
* or reviewer = caller). Returns the items array, or [] on any failure
|
|
134
|
+
* (fail-open; an hq without the read yet answers 404 → []).
|
|
135
|
+
* @param {object} [opts] - { client?, base, token, fetchImpl? }
|
|
136
|
+
* @returns {Promise<object[]>}
|
|
137
|
+
*/
|
|
138
|
+
export function mine(opts = {}) {
|
|
139
|
+
return resolveClient(opts).listMine(rpcOpts(opts));
|
|
140
|
+
}
|
|
141
|
+
|
|
131
142
|
// ---------------------------------------------------------------------------
|
|
132
143
|
// Read-only YAML projection writer
|
|
133
144
|
// ---------------------------------------------------------------------------
|
package/lib/org/board.test.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { tmpdir } from "node:os";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
|
-
createItem, claimItem, completeItem, decompose, assign, link, ready,
|
|
17
|
+
createItem, claimItem, completeItem, decompose, assign, link, ready, mine,
|
|
18
18
|
buildProjection, writeProjection, writeReadyProjection,
|
|
19
19
|
} from "./board.mjs";
|
|
20
20
|
import { okFrame, errFrame, BOARD_STATUS } from "./protocol.mjs";
|
|
@@ -38,6 +38,7 @@ function fakeClient(map = {}) {
|
|
|
38
38
|
boardAssign: make("boardAssign"),
|
|
39
39
|
boardLink: make("boardLink"),
|
|
40
40
|
listReady: make("listReady"),
|
|
41
|
+
listMine: make("listMine"),
|
|
41
42
|
};
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -175,3 +176,12 @@ test("writeReadyProjection: down server (empty ready) writes an empty projection
|
|
|
175
176
|
assert.equal(doc.count, 0);
|
|
176
177
|
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
177
178
|
});
|
|
179
|
+
|
|
180
|
+
test("mine: routes to client.listMine, forwarding conn opts (fail-open [] handled by the client)", async () => {
|
|
181
|
+
const client = fakeClient({ listMine: () => [{ itemId: "m1" }] });
|
|
182
|
+
assert.deepEqual(await mine({ client, ...CONN }), [{ itemId: "m1" }]);
|
|
183
|
+
// listMine takes ONE argument (the conn opts), like listReady.
|
|
184
|
+
const c = client.calls.find((x) => x.name === "listMine");
|
|
185
|
+
assert.equal(c.params.base, CONN.base);
|
|
186
|
+
assert.equal(c.params.token, CONN.token);
|
|
187
|
+
});
|
package/lib/org/client.mjs
CHANGED
|
@@ -694,6 +694,42 @@ export async function listReady(o = {}) {
|
|
|
694
694
|
return [];
|
|
695
695
|
}
|
|
696
696
|
|
|
697
|
+
/**
|
|
698
|
+
* List every board item assigned to (or under review by) THIS agent across all
|
|
699
|
+
* boards and workstreams (GET /v1/board.mine — hq 2026-09, front-door session
|
|
700
|
+
* §3.5). Returns the items array, or [] on any failure (fail-open): an older hq
|
|
701
|
+
* without the read answers 404 and this returns [], same as listReady.
|
|
702
|
+
* Item shape: {itemId, title, boardId, boardName, channelId, workstreamId, col,
|
|
703
|
+
* stage, priority, dueAt, updatedAt, url}.
|
|
704
|
+
* @param {object} o - { base, token, orgId?, fetchImpl? }
|
|
705
|
+
* @returns {Promise<object[]>}
|
|
706
|
+
*/
|
|
707
|
+
export async function listMine(o = {}) {
|
|
708
|
+
return (await listMineResult(o)).items;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* `listMine` with the failure kept visible: `{ok:false, items:[]}` when hq
|
|
713
|
+
* could not be read, `{ok:true, items}` otherwise (an empty board is
|
|
714
|
+
* `ok:true`). The daemon's board-mine cache needs the difference so one
|
|
715
|
+
* unreachable tick does not overwrite the last known good answer with [].
|
|
716
|
+
* Never throws.
|
|
717
|
+
* @param {object} o - { base, token, orgId, fetchImpl? }
|
|
718
|
+
* @returns {Promise<{ok:boolean, items:object[], error?:string}>}
|
|
719
|
+
*/
|
|
720
|
+
export async function listMineResult(o = {}) {
|
|
721
|
+
try {
|
|
722
|
+
const r = await read("board.mine", o);
|
|
723
|
+
if (!r.ok) return { ok: false, items: [], error: (r.error && r.error.message) || `http ${r.status || 0}` };
|
|
724
|
+
if (!r.payload) return { ok: true, items: [] };
|
|
725
|
+
if (Array.isArray(r.payload)) return { ok: true, items: r.payload };
|
|
726
|
+
if (Array.isArray(r.payload.items)) return { ok: true, items: r.payload.items };
|
|
727
|
+
return { ok: true, items: [] };
|
|
728
|
+
} catch (err) {
|
|
729
|
+
return { ok: false, items: [], error: String((err && err.message) || err) }; // read() never throws; belt-and-braces for a throwing fetchImpl
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
697
733
|
/**
|
|
698
734
|
* Fetch the event slice (GET /v1/events?cursor=N&limit=500). Returns the events
|
|
699
735
|
* array (or [] on failure). The caller pipes these to verify.verifyEvents.
|
package/lib/org/client.test.mjs
CHANGED
|
@@ -35,6 +35,9 @@ import {
|
|
|
35
35
|
claimItem,
|
|
36
36
|
completeItem,
|
|
37
37
|
listReady,
|
|
38
|
+
listMine,
|
|
39
|
+
listMineResult,
|
|
40
|
+
trackWork,
|
|
38
41
|
fetchEvents,
|
|
39
42
|
fetchSnapshot,
|
|
40
43
|
publishEntry,
|
|
@@ -1158,3 +1161,46 @@ test("loadOrgConfig: a PRESENT but unparseable org.yaml WARNS (never silently un
|
|
|
1158
1161
|
assert.match(warnings.join("\n"), /could not be loaded/, "the operator must be told");
|
|
1159
1162
|
assert.match(warnings.join("\n"), /NOT enrolled/, "and told what it means");
|
|
1160
1163
|
});
|
|
1164
|
+
|
|
1165
|
+
// ── WP-M4: board.mine + board.track passthrough (front-door session §3.5) ──
|
|
1166
|
+
|
|
1167
|
+
test("listMineResult: keeps the failure visible — {ok:false} on an error frame or a throwing transport, {ok:true, items:[]} for an empty board", async () => {
|
|
1168
|
+
assert.deepEqual(await listMineResult({ base: BASE, token: TOKEN, fetchImpl: fakeFetch(() => ({ body: [{ itemId: "i1" }] })) }), { ok: true, items: [{ itemId: "i1" }] });
|
|
1169
|
+
assert.deepEqual(await listMineResult({ base: BASE, token: TOKEN, fetchImpl: fakeFetch(() => ({ body: { items: [] } })) }), { ok: true, items: [] }, "an empty board is a real answer");
|
|
1170
|
+
const failed = await listMineResult({ base: BASE, token: TOKEN, fetchImpl: fakeFetch(() => ({ ok: false, status: 503, body: { error: { code: "UNAVAILABLE", message: "hq down" } } })) });
|
|
1171
|
+
assert.equal(failed.ok, false);
|
|
1172
|
+
assert.deepEqual(failed.items, []);
|
|
1173
|
+
assert.match(failed.error, /hq down/);
|
|
1174
|
+
const threw = await listMineResult({ base: BASE, token: TOKEN, fetchImpl: () => { throw new Error("ECONNREFUSED"); } });
|
|
1175
|
+
assert.equal(threw.ok, false);
|
|
1176
|
+
assert.deepEqual(threw.items, []);
|
|
1177
|
+
assert.deepEqual(await listMine({ base: BASE, token: TOKEN, fetchImpl: () => { throw new Error("ECONNREFUSED"); } }), [], "listMine stays the fail-open [] its other callers expect");
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
test("listMine: GET /v1/board.mine; parses array and {items:[]}; [] on error / transport failure (fail-open)", async () => {
|
|
1181
|
+
const f = fakeFetch(() => ({ body: [{ itemId: "i1", title: "Deck" }] }));
|
|
1182
|
+
assert.deepEqual(await listMine({ base: BASE, token: TOKEN, fetchImpl: f }), [{ itemId: "i1", title: "Deck" }]);
|
|
1183
|
+
assert.match(f.calls[0].url, /\/v1\/board\.mine$/);
|
|
1184
|
+
assert.equal(f.calls[0].init.method, "GET");
|
|
1185
|
+
assert.deepEqual(await listMine({ base: BASE, token: TOKEN, fetchImpl: fakeFetch(() => ({ body: { items: [{ itemId: "i2" }] } })) }), [{ itemId: "i2" }]);
|
|
1186
|
+
assert.deepEqual(await listMine({ base: BASE, token: TOKEN, fetchImpl: fakeFetch(() => ({ ok: false, status: 404, body: { error: { code: "NOT_FOUND" } } })) }), [], "an hq without board.mine yet → []");
|
|
1187
|
+
assert.deepEqual(await listMine({ base: BASE, token: TOKEN, fetchImpl: async () => { throw new Error("boom"); } }), []);
|
|
1188
|
+
assert.deepEqual(await listMine({ base: "", token: TOKEN }), [], "no base → []");
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
test("trackWork: title/why/stage (incl. accepted|done) pass through to board.track verbatim", async () => {
|
|
1192
|
+
const f = fakeFetch(() => ({ body: { ok: true, result: { tracked: true, taskId: "t1" } } }));
|
|
1193
|
+
const r = await trackWork(
|
|
1194
|
+
{ service: "cohort", channelId: "c1", messageId: "m1", stage: "accepted", title: "Build the deck", why: "asked in DM" },
|
|
1195
|
+
{ base: BASE, token: TOKEN, fetchImpl: f, idempotencyKey: "k1" },
|
|
1196
|
+
);
|
|
1197
|
+
assert.equal(r.ok, true);
|
|
1198
|
+
const body = JSON.parse(f.calls[0].init.body);
|
|
1199
|
+
assert.equal(body.stage, "accepted");
|
|
1200
|
+
assert.equal(body.title, "Build the deck");
|
|
1201
|
+
assert.equal(body.why, "asked in DM");
|
|
1202
|
+
assert.equal(body.channelId, "c1");
|
|
1203
|
+
const f2 = fakeFetch(() => ({ body: { ok: true, result: { tracked: true } } }));
|
|
1204
|
+
await trackWork({ service: "cohort", channelId: "c1", messageId: "m1", stage: "done" }, { base: BASE, token: TOKEN, fetchImpl: f2 });
|
|
1205
|
+
assert.equal(JSON.parse(f2.calls[0].init.body).stage, "done");
|
|
1206
|
+
});
|
|
@@ -406,7 +406,9 @@ function yes(surface, reason, extra = {}) {
|
|
|
406
406
|
* @param {Candidate} cand
|
|
407
407
|
* @param {string} me the agent's member id
|
|
408
408
|
* @param {import("./facts.mjs").Facts} facts
|
|
409
|
-
* @param {{enabled?: Record<string,boolean
|
|
409
|
+
* @param {{enabled?: Record<string,boolean>, meAliases?: string[]}} [opts]
|
|
410
|
+
* `meAliases` — additional identity strings for this seat (slug, member id,
|
|
411
|
+
* agent email). Used ONLY to widen own-echo suppression; never to widen reach.
|
|
410
412
|
* @returns {{directed:boolean, surface:string|null, reason:string, why:string}}
|
|
411
413
|
*/
|
|
412
414
|
export function resolveDirected(cand, me, facts = {}, opts = {}) {
|
|
@@ -414,10 +416,24 @@ export function resolveDirected(cand, me, facts = {}, opts = {}) {
|
|
|
414
416
|
const enabled = opts.enabled || null;
|
|
415
417
|
const on = (s) => (enabled ? !!enabled[s] : true);
|
|
416
418
|
const meId = me ? String(me) : "";
|
|
419
|
+
// Every identity string that means "this seat". `me` SHOULD be the org member
|
|
420
|
+
// id (a cuid), because that is what hq stamps on `actor` and `directTo`. But a
|
|
421
|
+
// real machine can just as easily hand us the SLUG ("A028") — COHORT_AGENT_ID
|
|
422
|
+
// holds a slug, and the daemon's responder-election path needs it that way.
|
|
423
|
+
// When the slug arrives here, `actor === meId` compares across two namespaces,
|
|
424
|
+
// never matches, and the own-echo drop below silently stops existing. Echo
|
|
425
|
+
// suppression must not depend on which alias the caller happened to pass, so
|
|
426
|
+
// match ANY known alias. Observed in production 2026-08-26: A028 re-ingested
|
|
427
|
+
// its own morning brief and its own holding note and began answering itself.
|
|
428
|
+
const meAliases = new Set(
|
|
429
|
+
[meId, ...(Array.isArray(opts.meAliases) ? opts.meAliases : [])]
|
|
430
|
+
.map((v) => (v == null ? "" : String(v).trim()))
|
|
431
|
+
.filter(Boolean),
|
|
432
|
+
);
|
|
417
433
|
|
|
418
434
|
// My OWN echo is never inbound. This is the single most important drop: without
|
|
419
435
|
// it an agent re-ingests its own replies and holds a conversation with itself.
|
|
420
|
-
if (
|
|
436
|
+
if (cand.actor && meAliases.has(String(cand.actor))) return no("own_echo");
|
|
421
437
|
|
|
422
438
|
// A payload that named me outright needs no join at all.
|
|
423
439
|
if (meId && cand.directTo.map(String).includes(meId)) {
|
|
@@ -145,6 +145,64 @@ test("NEGATIVE: my own echo is never inbound", () => {
|
|
|
145
145
|
assert.equal(resolveDirected(c, ME, f).reason, "own_echo");
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
+
// --- own-echo across id namespaces (regression, production 2026-08-26) -------
|
|
149
|
+
//
|
|
150
|
+
// `me` SHOULD be the org member cuid — that is what hq stamps on `actor`. But
|
|
151
|
+
// COHORT_AGENT_ID holds a SLUG and the daemon's responder-election path needs it
|
|
152
|
+
// that way, so a slug reaches this resolver on a real machine. Strict equality
|
|
153
|
+
// on one id then compares across two namespaces: it does not throw, it answers
|
|
154
|
+
// "no" to every identity question, and the own-echo drop silently stops
|
|
155
|
+
// existing. That is how a seat re-ingested its own morning brief and its own
|
|
156
|
+
// holding note and began answering itself.
|
|
157
|
+
//
|
|
158
|
+
// These tests exist because nothing turns red when this lapses. The failure has
|
|
159
|
+
// no runtime signal at all — only an assertion.
|
|
160
|
+
|
|
161
|
+
const ME_SLUG = "A028";
|
|
162
|
+
|
|
163
|
+
test("REGRESSION: a SLUG in the me position still suppresses my own echo", () => {
|
|
164
|
+
const f = withChannel(facts(), "C-dm", "DM");
|
|
165
|
+
const c = classifyEvent(
|
|
166
|
+
ev({ family: "messaging", kind: "send", actor: ME, entity_id: "m1", payload: { actor: ME, channelId: "C-dm", channelKind: "DM" } }),
|
|
167
|
+
);
|
|
168
|
+
// hq stamped the cuid; the caller handed us the slug. Pre-fix this returned
|
|
169
|
+
// `dm` — the agent's own message delivered back to it as fresh inbound.
|
|
170
|
+
const v = resolveDirected(c, ME_SLUG, f, { meAliases: [ME_SLUG, ME] });
|
|
171
|
+
assert.equal(v.directed, false);
|
|
172
|
+
assert.equal(v.reason, "own_echo");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("REGRESSION: a SLUG-shaped actor is suppressed when me is the cuid", () => {
|
|
176
|
+
const f = withChannel(facts(), "C-dm", "DM");
|
|
177
|
+
const c = classifyEvent(
|
|
178
|
+
ev({ family: "messaging", kind: "send", actor: ME_SLUG, entity_id: "m1", payload: { actor: ME_SLUG, channelId: "C-dm", channelKind: "DM" } }),
|
|
179
|
+
);
|
|
180
|
+
// Symmetric: suppression must not depend on WHICH namespace either side used.
|
|
181
|
+
assert.equal(resolveDirected(c, ME, f, { meAliases: [ME, ME_SLUG] }).reason, "own_echo");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("meAliases widen own-echo ONLY — never reach", () => {
|
|
185
|
+
const f = facts(); // C-theirs is in nobody's roster but its members'
|
|
186
|
+
const c = classifyEvent(
|
|
187
|
+
ev({ family: "messaging", kind: "send", entity_id: "m1", payload: { actor: THEM, channelId: "C-theirs", channelKind: "DM" } }),
|
|
188
|
+
);
|
|
189
|
+
const v = resolveDirected(c, ME_SLUG, f, { meAliases: [ME_SLUG, ME] });
|
|
190
|
+
assert.equal(v.directed, false, "an alias list must never open someone else's room");
|
|
191
|
+
assert.equal(v.reason, "channel_not_visible");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("a blank/garbage alias never swallows real inbound", () => {
|
|
195
|
+
const f = withChannel(facts(), "C-dm", "DM");
|
|
196
|
+
withMessage(f, "C-dm", { id: "m1", authorId: THEM, body: "ping" });
|
|
197
|
+
const c = classifyEvent(
|
|
198
|
+
ev({ family: "messaging", kind: "send", entity_id: "m1", payload: { actor: THEM, channelId: "C-dm", channelKind: "DM", mentionCount: 0, threaded: false } }),
|
|
199
|
+
);
|
|
200
|
+
// Empty strings and nullish entries are dropped, so `has(actor)` cannot match
|
|
201
|
+
// on emptiness and turn every message into my own echo.
|
|
202
|
+
const v = resolveDirected(c, ME, f, { meAliases: [null, "", " ", undefined] });
|
|
203
|
+
assert.deepEqual({ d: v.directed, r: v.reason }, { d: true, r: "dm" });
|
|
204
|
+
});
|
|
205
|
+
|
|
148
206
|
// ---------------------------------------------------------------------------
|
|
149
207
|
// messaging — DM / mention / thread / ambient
|
|
150
208
|
// ---------------------------------------------------------------------------
|
|
@@ -87,6 +87,8 @@ const DEFAULT_EVENT_LIMIT = 200;
|
|
|
87
87
|
* @param {number} [o.limit]
|
|
88
88
|
* @param {Function} [o.fetchImpl]
|
|
89
89
|
* @param {string[]} [o.myNames] display names for prose-mention matching
|
|
90
|
+
* @param {string[]} [o.meAliases] other identity strings for this seat
|
|
91
|
+
* (slug, member id) — widens OWN-ECHO suppression only, never reach
|
|
90
92
|
* @param {string[]|Record<string,boolean>} [o.surfaces] surface enable override
|
|
91
93
|
* @param {object} [o.limits] overrides for {@link DEFAULT_LIMITS}
|
|
92
94
|
* @param {Function} [o.log] (level, message, meta?) — daemon logger
|
|
@@ -125,6 +127,11 @@ export async function pullWideInbound(o = {}) {
|
|
|
125
127
|
return empty;
|
|
126
128
|
}
|
|
127
129
|
|
|
130
|
+
// Extra identity strings for own-echo suppression only (slug AND member id).
|
|
131
|
+
// The caller cannot always know which namespace `agentId` is in; passing both
|
|
132
|
+
// means a slug-valued COHORT_AGENT_ID can never silently disable the drop.
|
|
133
|
+
const meAliases = Array.isArray(o.meAliases) ? o.meAliases.filter(Boolean) : [];
|
|
134
|
+
|
|
128
135
|
const { enabled, unknown } = resolveEnabledSurfaces(
|
|
129
136
|
o.surfaces !== undefined ? o.surfaces : parseSurfaceEnv(process.env.COHORT_INBOUND_SURFACES),
|
|
130
137
|
);
|
|
@@ -185,7 +192,7 @@ export async function pullWideInbound(o = {}) {
|
|
|
185
192
|
// ── 3. Verdicts.
|
|
186
193
|
const directed = [];
|
|
187
194
|
for (const cand of candidates) {
|
|
188
|
-
const verdict = resolveDirected(cand, me, facts, { enabled });
|
|
195
|
+
const verdict = resolveDirected(cand, me, facts, { enabled, meAliases });
|
|
189
196
|
if (!verdict.directed) {
|
|
190
197
|
stats.dropped[verdict.reason] = (stats.dropped[verdict.reason] || 0) + 1;
|
|
191
198
|
continue;
|
|
@@ -208,6 +208,28 @@ test("NEGATIVE: nothing from someone else's private room, my own echo, or ambien
|
|
|
208
208
|
assert.ok(stats.dropped.channel_not_visible >= 1);
|
|
209
209
|
});
|
|
210
210
|
|
|
211
|
+
test("REGRESSION: a slug-valued agentId still drops my own echo, via meAliases", async () => {
|
|
212
|
+
// The production shape on 2026-08-26: COHORT_AGENT_ID is a SLUG, hq stamps
|
|
213
|
+
// `actor` with the member cuid. `agentId` alone then matches nothing and the
|
|
214
|
+
// own-echo drop silently stops existing — no throw, no log, just the seat
|
|
215
|
+
// answering itself. The daemon hands BOTH namespaces down as `meAliases`, so
|
|
216
|
+
// the drop survives regardless of which one `agentId` resolved to.
|
|
217
|
+
const io = fakeIo(routes());
|
|
218
|
+
const { events, stats } = await pullWideInbound({
|
|
219
|
+
cfg: CFG,
|
|
220
|
+
agentId: "A028",
|
|
221
|
+
meAliases: ["A028", ME],
|
|
222
|
+
io,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
assert.ok(stats.dropped.own_echo >= 1, "own_echo must still fire when me is the slug");
|
|
226
|
+
assert.equal(
|
|
227
|
+
events.some((e) => e.message_id === "m-mine"),
|
|
228
|
+
false,
|
|
229
|
+
"my own message must never come back, whichever id namespace the caller passed",
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
211
233
|
test("email is OFF by default and is not even hydrated", async () => {
|
|
212
234
|
const io = fakeIo(routes());
|
|
213
235
|
const { events } = await pullWideInbound({ cfg: CFG, agentId: ME, io });
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mesh-directives.test.mjs — the two front-door directives (design §3.4 /
|
|
3
|
+
* audit F10): hq's `remediateFleetSeat` queues `restart` and nothing honoured
|
|
4
|
+
* it. Now:
|
|
5
|
+
*
|
|
6
|
+
* restart → the daemon flushes and exits NON-ZERO so launchd's
|
|
7
|
+
* KeepAlive {SuccessfulExit:false} relaunches it (a clean
|
|
8
|
+
* exit 0 would leave it down). Injected as requestRestart.
|
|
9
|
+
* session.restart → touch state/session/restart-requested; the session's
|
|
10
|
+
* feed sees it and the session restarts itself at an idle
|
|
11
|
+
* moment. The daemon is untouched.
|
|
12
|
+
*
|
|
13
|
+
* Driven through the same fake client + explicit beatOnce as mesh.test.mjs.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { test } from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
|
|
22
|
+
import { connectOrgMesh, _internals } from "./mesh.mjs";
|
|
23
|
+
|
|
24
|
+
const BASE = "https://org.cohortapp.com";
|
|
25
|
+
function tmpRepo() {
|
|
26
|
+
const root = mkdtempSync(join(tmpdir(), "org-mesh-directives-"));
|
|
27
|
+
mkdirSync(join(root, "config"), { recursive: true });
|
|
28
|
+
writeFileSync(join(root, "config", "agent.json"), JSON.stringify({ firstName: "Alex", fullName: "Alex Chen", repoSlug: "alex-ai" }));
|
|
29
|
+
return root;
|
|
30
|
+
}
|
|
31
|
+
function cfgEnabled() { return { org: { cohort: { enabled: true, base: BASE, token: "tok" } } }; }
|
|
32
|
+
function fakeClient(beatFrames) {
|
|
33
|
+
const c = {
|
|
34
|
+
isEnabled: (cfg) => !!(cfg && cfg.org && cfg.org.cohort && cfg.org.cohort.enabled),
|
|
35
|
+
configFromAgent: () => ({ base: BASE, token: "tok", orgId: "", enabled: true, trustedSignersPem: [] }),
|
|
36
|
+
_beatFrames: beatFrames.slice(),
|
|
37
|
+
register() { return Promise.resolve({ ok: true, result: { status: 200 } }); },
|
|
38
|
+
presenceBeat() { return Promise.resolve(c._beatFrames.length ? c._beatFrames.shift() : { ok: true, result: { directives: [] } }); },
|
|
39
|
+
fetchDirectory() { return Promise.resolve({ ok: true, result: { agents: [] } }); },
|
|
40
|
+
call() { return Promise.resolve({ ok: true, result: {} }); },
|
|
41
|
+
};
|
|
42
|
+
return c;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("KNOWN_DIRECTIVES includes restart and session.restart", () => {
|
|
46
|
+
assert.ok(_internals.KNOWN_DIRECTIVES.has("restart"));
|
|
47
|
+
assert.ok(_internals.KNOWN_DIRECTIVES.has("session.restart"));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('"restart" directive stops the beat loop and asks the daemon to restart (exit non-zero); never writes .emergency-stop', async () => {
|
|
51
|
+
const root = tmpRepo();
|
|
52
|
+
const client = fakeClient([{ ok: true, result: { directives: ["restart"] } }]);
|
|
53
|
+
let stops = 0;
|
|
54
|
+
let restarts = 0;
|
|
55
|
+
const handle = await connectOrgMesh({
|
|
56
|
+
agentRoot: root, cfg: cfgEnabled(), client,
|
|
57
|
+
setInterval: () => ({ unref() {} }),
|
|
58
|
+
requestStop: () => { stops += 1; },
|
|
59
|
+
requestRestart: () => { restarts += 1; },
|
|
60
|
+
});
|
|
61
|
+
const directives = await _internals.beatOnce(handle._ctx);
|
|
62
|
+
assert.deepEqual(directives, ["restart"]);
|
|
63
|
+
assert.equal(restarts, 1, "requestRestart() was invoked");
|
|
64
|
+
assert.equal(stops, 0, "a restart is not a halt");
|
|
65
|
+
assert.equal(existsSync(join(root, ".emergency-stop")), false, "a restart must never trip the kill switch");
|
|
66
|
+
assert.equal(handle._ctx.stopped, true, "the beat loop stopped: the process is going down to come back");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('"session.restart" directive touches state/session/restart-requested and leaves the daemon running', async () => {
|
|
70
|
+
const root = tmpRepo();
|
|
71
|
+
const client = fakeClient([{ ok: true, result: { directives: ["session.restart"] } }]);
|
|
72
|
+
let stops = 0;
|
|
73
|
+
let restarts = 0;
|
|
74
|
+
const T0 = Date.parse("2026-09-08T10:00:00.000Z");
|
|
75
|
+
const handle = await connectOrgMesh({
|
|
76
|
+
agentRoot: root, cfg: cfgEnabled(), client,
|
|
77
|
+
setInterval: () => ({ unref() {} }),
|
|
78
|
+
now: () => T0,
|
|
79
|
+
requestStop: () => { stops += 1; },
|
|
80
|
+
requestRestart: () => { restarts += 1; },
|
|
81
|
+
});
|
|
82
|
+
const directives = await _internals.beatOnce(handle._ctx);
|
|
83
|
+
assert.deepEqual(directives, ["session.restart"]);
|
|
84
|
+
const flag = join(root, "state", "session", "restart-requested");
|
|
85
|
+
assert.ok(existsSync(flag), "restart-requested flag written");
|
|
86
|
+
assert.match(readFileSync(flag, "utf8"), /2026-09-08T10:00:00/, "flag carries the request time");
|
|
87
|
+
assert.equal(stops, 0);
|
|
88
|
+
assert.equal(restarts, 0, "the daemon itself is not restarted");
|
|
89
|
+
assert.equal(handle._ctx.stopped, false, "the beat loop keeps running");
|
|
90
|
+
// Idempotent: a second directive overwrites the flag, never throws.
|
|
91
|
+
await _internals.handleDirective("session.restart", handle._ctx);
|
|
92
|
+
assert.ok(existsSync(flag));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("the default restart signal sets a non-zero exit code before the graceful stop, so launchd relaunches", () => {
|
|
96
|
+
// defaultRequestRestart is the production path; we only check its contract
|
|
97
|
+
// on process.exitCode without sending ourselves a signal.
|
|
98
|
+
const prevExitCode = process.exitCode;
|
|
99
|
+
const prevKill = process.kill;
|
|
100
|
+
let killed = null;
|
|
101
|
+
process.kill = (pid, sig) => { killed = { pid, sig }; return true; };
|
|
102
|
+
try {
|
|
103
|
+
_internals.defaultRequestRestart();
|
|
104
|
+
assert.equal(process.exitCode, 1, "exit code 1 → KeepAlive {SuccessfulExit:false} brings the daemon back");
|
|
105
|
+
assert.deepEqual(killed, { pid: process.pid, sig: "SIGTERM" }, "graceful stop via the daemon's own SIGTERM handlers (flush)");
|
|
106
|
+
} finally {
|
|
107
|
+
process.kill = prevKill;
|
|
108
|
+
process.exitCode = prevExitCode;
|
|
109
|
+
}
|
|
110
|
+
});
|