@cohortapp/agent-sdk 2.3.1 → 2.4.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/bin/maestro.mjs +37 -50
- package/framework-features.json +30 -0
- package/lib/backlog.mjs +136 -0
- package/lib/cadences.mjs +63 -2
- package/lib/cadences.test.mjs +105 -0
- package/lib/capability/inventory.mjs +542 -0
- package/lib/capability/inventory.test.mjs +232 -0
- package/lib/capability/probe.mjs +255 -0
- package/lib/channels/contract.mjs +37 -1
- package/lib/channels/contract.test.mjs +25 -1
- package/lib/channels/inbox-item.mjs +20 -0
- package/lib/claude-bin.mjs +37 -3
- package/lib/claude-bin.test.mjs +42 -8
- package/lib/execution/disposition.mjs +501 -0
- package/lib/execution/disposition.test.mjs +482 -0
- package/lib/execution/drive.mjs +352 -0
- package/lib/execution/drive.test.mjs +270 -0
- package/lib/execution/effects.mjs +340 -0
- package/lib/execution/effects.test.mjs +193 -0
- package/lib/execution/index.mjs +152 -0
- package/lib/execution/intake.mjs +581 -0
- package/lib/execution/intake.test.mjs +343 -0
- package/lib/execution/journal.mjs +374 -0
- package/lib/execution/journal.test.mjs +261 -0
- package/lib/execution/match.mjs +331 -0
- package/lib/execution/match.test.mjs +235 -0
- package/lib/execution/pipeline.mjs +341 -0
- package/lib/execution/pipeline.test.mjs +389 -0
- package/lib/execution/route.mjs +332 -0
- package/lib/execution/route.test.mjs +186 -0
- package/lib/execution/surface-policy.mjs +446 -0
- package/lib/execution/surface-policy.test.mjs +162 -0
- package/lib/goals/admission.mjs +209 -0
- package/lib/goals/admission.test.mjs +139 -0
- package/lib/goals/classify.mjs +206 -0
- package/lib/goals/classify.test.mjs +109 -0
- package/lib/goals/collaborate.mjs +415 -0
- package/lib/goals/collaborate.test.mjs +324 -0
- package/lib/goals/gaps.mjs +111 -0
- package/lib/goals/gaps.test.mjs +284 -0
- package/lib/goals/loop.mjs +537 -0
- package/lib/goals/loop.test.mjs +719 -0
- package/lib/identity/persona.mjs +247 -0
- package/lib/identity/persona.test.mjs +117 -0
- package/lib/kpi.mjs +469 -0
- package/lib/kpi.test.mjs +244 -0
- package/lib/mandate/audit.mjs +168 -0
- package/lib/mandate/audit.test.mjs +195 -0
- package/lib/mandate/cache.mjs +162 -0
- package/lib/mandate/derive.mjs +317 -0
- package/lib/mandate/derive.test.mjs +224 -0
- package/lib/mandate/model.mjs +352 -0
- package/lib/mandate/model.test.mjs +145 -0
- package/lib/mandate/refresh.mjs +187 -0
- package/lib/mandate/refresh.test.mjs +293 -0
- package/lib/mcp/server.test.mjs +4 -4
- package/lib/org/approvals.mjs +14 -2
- package/lib/org/client.mjs +79 -25
- package/lib/org/client.test.mjs +54 -1
- package/lib/org/doctor.mjs +64 -0
- package/lib/org/doctor.test.mjs +31 -2
- package/lib/org/inbound/directedness.mjs +720 -0
- package/lib/org/inbound/directedness.test.mjs +543 -0
- package/lib/org/inbound/facts.mjs +501 -0
- package/lib/org/inbound/facts.test.mjs +375 -0
- package/lib/org/inbound/hydrate.mjs +535 -0
- package/lib/org/inbound/hydrate.test.mjs +326 -0
- package/lib/org/inbound/index.mjs +233 -0
- package/lib/org/inbound/index.test.mjs +324 -0
- package/lib/org/inbound/io.mjs +141 -0
- package/lib/org/inbound/project.mjs +201 -0
- package/lib/org/inbound/project.test.mjs +287 -0
- package/lib/org/inbound/surfaces.mjs +257 -0
- package/lib/org/knowledge.mjs +10 -1
- package/lib/org/knowledge.test.mjs +8 -1
- package/lib/org/leases.mjs +5 -0
- package/lib/org/mesh.mjs +45 -2
- package/lib/org/mesh.test.mjs +55 -0
- package/lib/org/messaging.mjs +180 -15
- package/lib/org/messaging.test.mjs +117 -0
- package/lib/org/param-contract.mjs +694 -0
- package/lib/org/param-contract.test.mjs +451 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +8 -0
- package/lib/org/protocol.test.mjs +5 -1
- package/lib/org/push.mjs +1025 -0
- package/lib/org/push.test.mjs +690 -0
- package/lib/org/tool-surface.mjs +138 -38
- package/lib/org/tool-surface.test.mjs +13 -8
- package/lib/org/typing.mjs +341 -0
- package/lib/org/typing.test.mjs +291 -0
- package/lib/plan/compile.mjs +510 -0
- package/lib/plan/compile.test.mjs +286 -0
- package/lib/plan/emit.mjs +256 -0
- package/lib/plan/emit.test.mjs +246 -0
- package/lib/plan/explain.mjs +226 -0
- package/lib/plan/explain.test.mjs +188 -0
- package/lib/plan/schema.mjs +140 -0
- package/lib/resource-governor.mjs +47 -1
- package/lib/resource-governor.test.mjs +21 -1
- package/lib/setup/enroll-from-cohort.mjs +84 -16
- package/lib/setup/enroll-from-cohort.test.mjs +43 -1
- package/lib/setup/sections/identity.mjs +15 -4
- package/lib/setup/sections/identity.test.mjs +94 -0
- package/lib/setup/sections/inventory.mjs +178 -0
- package/lib/setup/sections/inventory.test.mjs +198 -0
- package/lib/setup/sections/mandate.mjs +392 -0
- package/lib/setup/sections/mandate.test.mjs +373 -0
- package/lib/setup/sections/subagents.mjs +427 -0
- package/lib/setup/sections/subagents.test.mjs +429 -0
- package/lib/setup/sections/verify.mjs +121 -0
- package/lib/setup/sections/verify.test.mjs +175 -0
- package/lib/setup/sot.mjs +2 -0
- package/lib/subagents/cli.mjs +463 -0
- package/lib/subagents/cli.test.mjs +389 -0
- package/lib/subagents/client.mjs +373 -0
- package/lib/subagents/client.test.mjs +309 -0
- package/lib/subagents/gap.mjs +268 -0
- package/lib/subagents/gap.test.mjs +234 -0
- package/lib/subagents/lock.mjs +296 -0
- package/lib/subagents/lock.test.mjs +248 -0
- package/lib/subagents/manifest.mjs +224 -0
- package/lib/subagents/manifest.test.mjs +175 -0
- package/lib/subagents/refs.mjs +274 -0
- package/lib/subagents/refs.test.mjs +204 -0
- package/lib/subagents/resolve.mjs +455 -0
- package/lib/subagents/resolve.test.mjs +422 -0
- package/lib/subagents/schema.mjs +467 -0
- package/lib/subagents/schema.test.mjs +306 -0
- package/package.json +9 -4
- package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
- package/policies/ai-disclosure.yaml +42 -2
- package/scaffold/CLAUDE.md +16 -2
- package/schedules/triggers/goal-steward.md +79 -0
- package/scripts/ci/conformance-org-api.mjs +792 -0
- package/scripts/ci/conformance-org-api.test.mjs +417 -0
- package/scripts/daemon/agent-daemon.mjs +70 -11
- package/scripts/daemon/cadence-handlers.mjs +187 -5
- package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
- package/scripts/daemon/inbox-deferral.mjs +45 -2
- package/scripts/daemon/inbox-deferral.test.mjs +56 -0
- package/scripts/daemon/inbox-wake.mjs +282 -0
- package/scripts/daemon/inbox-wake.test.mjs +199 -0
- package/scripts/daemon/maestro-daemon.mjs +23 -0
- package/scripts/daemon/prompt-builder.mjs +41 -1
- package/scripts/daemon/responder.mjs +56 -0
- package/scripts/daemon/typing-registry.mjs +55 -2
- package/scripts/daemon/typing-registry.test.mjs +25 -0
- package/scripts/local-triggers/generate-plists.test.mjs +5 -5
- package/scripts/poller/inbox-scan-poller.mjs +26 -1
- package/scripts/poller/inbox-scan-poller.test.mjs +64 -0
- package/scripts/poller/slack-cloud-relay-client.mjs +5 -0
- package/scripts/poller/slack-poller.mjs +32 -0
- package/scripts/poller/slack-socket-mode.mjs +27 -1
- package/scripts/poller/slack-socket-mode.test.mjs +52 -0
- package/scripts/poller/utils.mjs +47 -0
- package/scripts/setup/gen-subagent-manifest.mjs +95 -0
- package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
- package/scripts/setup/generate-plan.mjs +108 -0
- package/scripts/setup/init-capability-manifest.mjs +70 -0
- package/scripts/setup/init-skill-marketplace.mjs +155 -0
- package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
|
@@ -19,9 +19,31 @@
|
|
|
19
19
|
|
|
20
20
|
"use strict";
|
|
21
21
|
|
|
22
|
+
import { cohortTypingAdapter } from "../../lib/org/typing.mjs";
|
|
23
|
+
|
|
22
24
|
/** @type {Map<string, { startTypingHeartbeat:Function, stopTypingHeartbeat:Function }>} */
|
|
23
25
|
const ADAPTERS = new Map();
|
|
24
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Services whose adapter is BUILT IN rather than created by the daemon's
|
|
29
|
+
* channel-bus loop, resolved on first use.
|
|
30
|
+
*
|
|
31
|
+
* `cohort` is the org substrate itself: there is no channel adapter object for
|
|
32
|
+
* it anywhere (the messaging-inbound cadence writes inbox YAML directly), so
|
|
33
|
+
* nothing would ever have called `registerTypingAdapter("cohort", …)` — and an
|
|
34
|
+
* SDK-driven agent, which is now the ONLY responder for its own rooms
|
|
35
|
+
* (hq `llm-responder/sdk-driven.ts` stands hq down for a live daemon), would
|
|
36
|
+
* show no "composing" indicator at all while it worked. The adapter is a plain
|
|
37
|
+
* `messaging.typing` client (`lib/org/typing.mjs`) that no-ops when the agent is
|
|
38
|
+
* not enrolled, so resolving it lazily here costs nothing for an agent that has
|
|
39
|
+
* no org.
|
|
40
|
+
*
|
|
41
|
+
* @type {Record<string, () => object>}
|
|
42
|
+
*/
|
|
43
|
+
const BUILTIN_ADAPTERS = {
|
|
44
|
+
cohort: () => cohortTypingAdapter(),
|
|
45
|
+
};
|
|
46
|
+
|
|
25
47
|
/** Register a channel adapter under its service name (e.g. "telegram"). */
|
|
26
48
|
export function registerTypingAdapter(service, adapter) {
|
|
27
49
|
if (!service || !adapter) return;
|
|
@@ -29,6 +51,37 @@ export function registerTypingAdapter(service, adapter) {
|
|
|
29
51
|
ADAPTERS.set(service, adapter);
|
|
30
52
|
}
|
|
31
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the adapter for a service: an explicitly registered one wins (so a
|
|
56
|
+
* test or a future real cohort adapter can override), else a built-in.
|
|
57
|
+
* Fail-open: a built-in that cannot be constructed yields null and the caller
|
|
58
|
+
* no-ops, but the reason is logged — a silently missing indicator is exactly
|
|
59
|
+
* the class of bug this module exists to fix.
|
|
60
|
+
*/
|
|
61
|
+
function adapterFor(service) {
|
|
62
|
+
if (!service) return null;
|
|
63
|
+
const registered = ADAPTERS.get(service);
|
|
64
|
+
if (registered) return registered;
|
|
65
|
+
const builtin = BUILTIN_ADAPTERS[service];
|
|
66
|
+
if (!builtin) return null;
|
|
67
|
+
try {
|
|
68
|
+
const made = builtin();
|
|
69
|
+
if (!made || typeof made.startTypingHeartbeat !== "function") return null;
|
|
70
|
+
ADAPTERS.set(service, made);
|
|
71
|
+
return made;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
try {
|
|
74
|
+
console.warn(
|
|
75
|
+
`[typing-registry] built-in typing adapter for "${service}" failed to load ` +
|
|
76
|
+
`(${err && err.message}) — sessions will run without a typing indicator`
|
|
77
|
+
);
|
|
78
|
+
} catch {
|
|
79
|
+
/* never throw from logging */
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
32
85
|
export function clearTypingAdapters() {
|
|
33
86
|
ADAPTERS.clear();
|
|
34
87
|
}
|
|
@@ -57,7 +110,7 @@ export function sourceFromItem(item) {
|
|
|
57
110
|
export function startTyping(item) {
|
|
58
111
|
try {
|
|
59
112
|
const service = item?.service;
|
|
60
|
-
const adapter =
|
|
113
|
+
const adapter = adapterFor(service);
|
|
61
114
|
if (!adapter) return null;
|
|
62
115
|
const source = sourceFromItem(item);
|
|
63
116
|
if (!source || !source.chatId) return null;
|
|
@@ -71,7 +124,7 @@ export function startTyping(item) {
|
|
|
71
124
|
export function stopTyping(item) {
|
|
72
125
|
try {
|
|
73
126
|
const service = item?.service;
|
|
74
|
-
const adapter =
|
|
127
|
+
const adapter = adapterFor(service);
|
|
75
128
|
if (!adapter) return;
|
|
76
129
|
const source = sourceFromItem(item);
|
|
77
130
|
if (!source || !source.chatId) return;
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
stopTyping,
|
|
17
17
|
sourceFromItem,
|
|
18
18
|
} from "./typing-registry.mjs";
|
|
19
|
+
import { resetCohortTypingAdapter } from "../../lib/org/typing.mjs";
|
|
19
20
|
|
|
20
21
|
function fakeAdapter() {
|
|
21
22
|
return {
|
|
@@ -68,6 +69,30 @@ test("sourceFromItem prefers a structured source, else reconstructs from item",
|
|
|
68
69
|
assert.equal(group.threadRef, "77");
|
|
69
70
|
});
|
|
70
71
|
|
|
72
|
+
test("cohort resolves the BUILT-IN adapter with nothing registered (the SDK parity gap)", () => {
|
|
73
|
+
clearTypingAdapters();
|
|
74
|
+
resetCohortTypingAdapter();
|
|
75
|
+
// No registerTypingAdapter("cohort", …) anywhere — nothing constructs a
|
|
76
|
+
// cohort channel adapter, which is why the indicator was missing entirely.
|
|
77
|
+
const item = { service: "cohort", channel_id: "ch_1", channel: "#build", thread_id: "" };
|
|
78
|
+
assert.doesNotThrow(() => startTyping(item));
|
|
79
|
+
assert.doesNotThrow(() => stopTyping(item));
|
|
80
|
+
clearTypingAdapters();
|
|
81
|
+
resetCohortTypingAdapter();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("an explicitly registered cohort adapter still wins over the built-in", () => {
|
|
85
|
+
clearTypingAdapters();
|
|
86
|
+
resetCohortTypingAdapter();
|
|
87
|
+
const fake = fakeAdapter();
|
|
88
|
+
registerTypingAdapter("cohort", fake);
|
|
89
|
+
startTyping({ service: "cohort", channel_id: "ch_1", channel: "#build" });
|
|
90
|
+
assert.equal(fake.started.length, 1);
|
|
91
|
+
assert.equal(fake.started[0].chatId, "ch_1");
|
|
92
|
+
clearTypingAdapters();
|
|
93
|
+
resetCohortTypingAdapter();
|
|
94
|
+
});
|
|
95
|
+
|
|
71
96
|
test("registerTypingAdapter ignores adapters lacking startTypingHeartbeat", () => {
|
|
72
97
|
clearTypingAdapters();
|
|
73
98
|
registerTypingAdapter("bad", { foo: 1 });
|
|
@@ -107,7 +107,7 @@ function listPlists(agentRoot) {
|
|
|
107
107
|
// Tests
|
|
108
108
|
// ---------------------------------------------------------------------------
|
|
109
109
|
|
|
110
|
-
test("generator emits
|
|
110
|
+
test("generator emits 22 standard plists with the agent's first name", async () => {
|
|
111
111
|
// Standard inventory (cadence-bus v1 + slack-socket-mode v1), with NO
|
|
112
112
|
// config/.cadence-plists.tsv present (no archetype cadences):
|
|
113
113
|
// daemon, poll-relay, slack-socket (3 infra), PLUS one trigger plist per
|
|
@@ -118,8 +118,8 @@ test("generator emits 21 standard plists with the agent's first name", async ()
|
|
|
118
118
|
// economics/scheduling cadences nightly-cost-reconcile, fleet-cost-digest,
|
|
119
119
|
// org-cost-sync, monitoring-alerts, dynamic-jobs (5), PLUS the SP10
|
|
120
120
|
// messaging-inbound cadence (1), PLUS the 2026-07 Directory & Design
|
|
121
|
-
// stewards directory-hygiene + brand-steward (2)
|
|
122
|
-
// =
|
|
121
|
+
// stewards directory-hygiene + brand-steward (2), PLUS the D3 self-directed
|
|
122
|
+
// loop goal-steward (1). = 3 infra + 19 trigger = 22 total.
|
|
123
123
|
// org-pulse used to be declared standard but was OMITTED by the old hardcoded
|
|
124
124
|
// list (it never got a plist); deriving from the SoT fixes that drift.
|
|
125
125
|
// The former weekly-* cadences are still ARCHETYPE-DRIVEN (function × altitude),
|
|
@@ -129,7 +129,7 @@ test("generator emits 21 standard plists with the agent's first name", async ()
|
|
|
129
129
|
const r = runGenerator(root);
|
|
130
130
|
assert.equal(r.status, 0, r.stderr);
|
|
131
131
|
const plists = listPlists(root);
|
|
132
|
-
assert.equal(plists.length,
|
|
132
|
+
assert.equal(plists.length, 22, `expected 22 standard plists; got ${plists.join(",")}`);
|
|
133
133
|
assert.ok(plists.includes("ai.maestro.alice-messaging-inbound.plist"),
|
|
134
134
|
"messaging-inbound (SP10 standard cadence) must get a SoT-derived plist");
|
|
135
135
|
for (const id of ["directory-hygiene", "brand-steward"]) {
|
|
@@ -200,7 +200,7 @@ test("archetype cadences from config/.cadence-plists.tsv emit extra trigger plis
|
|
|
200
200
|
const r = runGenerator(root);
|
|
201
201
|
assert.equal(r.status, 0, r.stderr);
|
|
202
202
|
const plists = listPlists(root);
|
|
203
|
-
assert.equal(plists.length,
|
|
203
|
+
assert.equal(plists.length, 24, `expected 22 standard + 2 archetype; got ${plists.join(",")}`);
|
|
204
204
|
const dir = join(root, "scripts/local-triggers/plists");
|
|
205
205
|
const eng = readFileSync(join(dir, "ai.maestro.erin-engineering-health.plist"), "utf-8");
|
|
206
206
|
assert.match(eng, /<key>Weekday<\/key>\s*<integer>3<\/integer>/); // base64 schedule decoded
|
|
@@ -57,6 +57,19 @@ export function parseInboxItemYaml(body) {
|
|
|
57
57
|
const m = body.match(new RegExp(`^\\s{2}${key}:\\s*(\\S+)`, "m"));
|
|
58
58
|
return /true/i.test((m || [])[1] || "");
|
|
59
59
|
};
|
|
60
|
+
// TRI-STATE. The confidentiality fields must distinguish "absent" from
|
|
61
|
+
// "false": `is_private: false` is a positive assertion that the source is
|
|
62
|
+
// public, and the daemon's fail-closed outcome gate
|
|
63
|
+
// (scripts/daemon/session-outcomes.mjs) admits on it. Decoding a missing line
|
|
64
|
+
// as `false` — which `bool()` does — would forge that assertion for every
|
|
65
|
+
// item ever written, which is exactly the leak the gate exists to prevent.
|
|
66
|
+
const triBool = (key) => {
|
|
67
|
+
const m = body.match(new RegExp(`^${key}:\\s*(\\S+)`, "m"));
|
|
68
|
+
if (!m) return undefined;
|
|
69
|
+
if (/^true$/i.test(m[1])) return true;
|
|
70
|
+
if (/^false$/i.test(m[1])) return false;
|
|
71
|
+
return undefined;
|
|
72
|
+
};
|
|
60
73
|
|
|
61
74
|
// Block scalar `content: |` — capture the indented block.
|
|
62
75
|
const blockScalar = (key) => {
|
|
@@ -66,7 +79,11 @@ export function parseInboxItemYaml(body) {
|
|
|
66
79
|
return m[1].replace(/^ {2}/gm, "").replace(/\n+$/, "");
|
|
67
80
|
};
|
|
68
81
|
|
|
69
|
-
|
|
82
|
+
const channelType = scalar("channel_type");
|
|
83
|
+
const isPrivate = triBool("is_private");
|
|
84
|
+
const isDm = triBool("is_dm");
|
|
85
|
+
|
|
86
|
+
const item = {
|
|
70
87
|
id: scalar("id"),
|
|
71
88
|
service: scalar("service"),
|
|
72
89
|
channel: scalar("channel"),
|
|
@@ -88,6 +105,14 @@ export function parseInboxItemYaml(body) {
|
|
|
88
105
|
},
|
|
89
106
|
raw_ref: scalar("raw_ref"),
|
|
90
107
|
};
|
|
108
|
+
|
|
109
|
+
// Only attached when the writer actually knew (see triBool) — an item with no
|
|
110
|
+
// privacy signal must stay signal-less so the outcome gate fails closed.
|
|
111
|
+
if (channelType) item.channel_type = channelType;
|
|
112
|
+
if (isPrivate !== undefined) item.is_private = isPrivate;
|
|
113
|
+
if (isDm !== undefined) item.is_dm = isDm;
|
|
114
|
+
|
|
115
|
+
return item;
|
|
91
116
|
}
|
|
92
117
|
|
|
93
118
|
/**
|
|
@@ -16,6 +16,7 @@ import { join } from "node:path";
|
|
|
16
16
|
import { parseInboxItemYaml, scanInbox } from "./inbox-scan-poller.mjs";
|
|
17
17
|
import { markDispatched, markInboxProcessed, writeInboxItem } from "./utils.mjs";
|
|
18
18
|
import { eventToInboxItem } from "../../lib/channels/inbox-item.mjs";
|
|
19
|
+
import { outcomeSourceShareable } from "../daemon/session-outcomes.mjs";
|
|
19
20
|
|
|
20
21
|
async function makeRoot() {
|
|
21
22
|
const p = join(tmpdir(), `inbox-scan-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
@@ -229,3 +230,66 @@ raw_ref: "${item.raw_ref || ""}"
|
|
|
229
230
|
if (item.kind) yaml += `kind: "${item.kind}"\n`;
|
|
230
231
|
return yaml;
|
|
231
232
|
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// CONFIDENTIALITY — the privacy signal must survive the YAML round-trip
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
//
|
|
238
|
+
// The daemon's outcome gate (scripts/daemon/session-outcomes.mjs) is fail-closed
|
|
239
|
+
// and admits a turn's commitments as ORG-VISIBLE only on a POSITIVE public
|
|
240
|
+
// signal, because Slack's conversation-ID prefix is not a reliable type
|
|
241
|
+
// indicator — private channels carry `C…` ids too. That signal is minted at
|
|
242
|
+
// ingest and reaches the daemon only through this writer/reader pair, so the
|
|
243
|
+
// round-trip is part of the security boundary and is tested as such.
|
|
244
|
+
|
|
245
|
+
test("privacy signals survive writeInboxItem → scanInbox, and drive the outcome gate", async () => {
|
|
246
|
+
const root = await makeRoot();
|
|
247
|
+
try {
|
|
248
|
+
const base = {
|
|
249
|
+
service: "slack",
|
|
250
|
+
sender: "casey",
|
|
251
|
+
sender_privilege: "unknown",
|
|
252
|
+
timestamp: "2026-06-09T10:00:00.000Z",
|
|
253
|
+
content: "hello",
|
|
254
|
+
thread_id: "",
|
|
255
|
+
is_reply: false,
|
|
256
|
+
priority_signals: { from_ceo: false, tagged_urgent: false, contains_deadline: false, mentions_agent: true },
|
|
257
|
+
};
|
|
258
|
+
writeInboxItem("slack", {
|
|
259
|
+
...base, id: "pub-1", channel: "general", channel_id: "C0GENERAL",
|
|
260
|
+
channel_type: "channel", is_private: false, is_dm: false,
|
|
261
|
+
subject: "#general", raw_ref: "slack:C0GENERAL:1",
|
|
262
|
+
}, root);
|
|
263
|
+
// A PRIVATE channel that Slack minted with a `C…` id — the case the old
|
|
264
|
+
// prefix test admitted, and the leak this whole chain exists to stop.
|
|
265
|
+
writeInboxItem("slack", {
|
|
266
|
+
...base, id: "priv-1", channel: "leadership", channel_id: "C0LEADERSHIP",
|
|
267
|
+
channel_type: "group", is_private: true, is_dm: false,
|
|
268
|
+
subject: "#leadership", raw_ref: "slack:C0LEADERSHIP:2",
|
|
269
|
+
}, root);
|
|
270
|
+
// An item minted before/without any privacy resolution.
|
|
271
|
+
writeInboxItem("slack", {
|
|
272
|
+
...base, id: "unk-1", channel: "mystery", channel_id: "C0MYSTERY",
|
|
273
|
+
subject: "#mystery", raw_ref: "slack:C0MYSTERY:3",
|
|
274
|
+
}, root);
|
|
275
|
+
|
|
276
|
+
const byId = Object.fromEntries(
|
|
277
|
+
scanInbox("slack", { agentRoot: root }).items.map((i) => [i.id, i])
|
|
278
|
+
);
|
|
279
|
+
assert.equal(Object.keys(byId).length, 3);
|
|
280
|
+
|
|
281
|
+
assert.equal(byId["pub-1"].channel_type, "channel");
|
|
282
|
+
assert.equal(byId["pub-1"].is_private, false, "an explicit false must survive as false, not vanish");
|
|
283
|
+
assert.equal(byId["priv-1"].channel_type, "group");
|
|
284
|
+
assert.equal(byId["priv-1"].is_private, true);
|
|
285
|
+
assert.equal(
|
|
286
|
+
"is_private" in byId["unk-1"], false,
|
|
287
|
+
"an unresolved item must carry NO privacy field — a defaulted `false` would forge a public verdict"
|
|
288
|
+
);
|
|
289
|
+
assert.equal("channel_type" in byId["unk-1"], false);
|
|
290
|
+
|
|
291
|
+
assert.deepEqual(outcomeSourceShareable(byId["pub-1"]), { shareable: true, reason: "public-channel" });
|
|
292
|
+
assert.deepEqual(outcomeSourceShareable(byId["priv-1"]), { shareable: false, reason: "private-channel" });
|
|
293
|
+
assert.deepEqual(outcomeSourceShareable(byId["unk-1"]), { shareable: false, reason: "privacy-unknown" });
|
|
294
|
+
} finally { await rmRoot(root); }
|
|
295
|
+
});
|
|
@@ -306,6 +306,11 @@ function handleWebhook(webhook, identity, peerSlackIds) {
|
|
|
306
306
|
const payload = {
|
|
307
307
|
...webhook,
|
|
308
308
|
channel_id: event.channel || "",
|
|
309
|
+
// CONFIDENTIALITY: carry Slack's conversation type through to the reader
|
|
310
|
+
// (slack-poller's events-JSON branch). The id prefix is not a reliable type
|
|
311
|
+
// indicator, so this is the only signal that lets the daemon's outcome gate
|
|
312
|
+
// establish a source as public. Omitted when Slack didn't send one.
|
|
313
|
+
...(event.channel_type ? { channel_type: event.channel_type } : {}),
|
|
309
314
|
received_at: new Date().toISOString(),
|
|
310
315
|
source: "cloud-relay",
|
|
311
316
|
};
|
|
@@ -12,6 +12,9 @@ import {
|
|
|
12
12
|
AGENT_REPO_DIR,
|
|
13
13
|
extractSlackAttachments,
|
|
14
14
|
downloadSlackAttachment,
|
|
15
|
+
// One shared decoder for Slack's `channel_type` → the confidentiality fields
|
|
16
|
+
// (channel_type / is_private / is_dm) every ingest path stamps on an item.
|
|
17
|
+
slackChannelPrivacy,
|
|
15
18
|
} from "./utils.mjs";
|
|
16
19
|
|
|
17
20
|
import {
|
|
@@ -82,6 +85,12 @@ async function getMonitoredChannels() {
|
|
|
82
85
|
id: c.id,
|
|
83
86
|
name: c.name || c.id,
|
|
84
87
|
priority: CHANNEL_PRIORITY_OVERRIDES[c.name] || "normal",
|
|
88
|
+
// CONFIDENTIALITY. `types` includes private_channel, and a private
|
|
89
|
+
// channel can carry a `C…` id — the prefix proves nothing. Keep Slack's
|
|
90
|
+
// own verdict so items minted from this channel can carry it downstream
|
|
91
|
+
// to the daemon's fail-closed outcome gate. Only a real boolean counts;
|
|
92
|
+
// anything else stays undefined (unknown ⇒ not established as public).
|
|
93
|
+
is_private: typeof c.is_private === "boolean" ? c.is_private : undefined,
|
|
85
94
|
}));
|
|
86
95
|
channelsCachedAt = Date.now();
|
|
87
96
|
return cachedChannels;
|
|
@@ -318,6 +327,9 @@ export async function pollSlack() {
|
|
|
318
327
|
},
|
|
319
328
|
raw_ref: rawRef,
|
|
320
329
|
channel_id: im.id,
|
|
330
|
+
channel_type: "im",
|
|
331
|
+
is_private: true,
|
|
332
|
+
is_dm: true,
|
|
321
333
|
});
|
|
322
334
|
}
|
|
323
335
|
}
|
|
@@ -411,6 +423,12 @@ export async function pollSlack() {
|
|
|
411
423
|
thread_id: msg.thread_ts || "",
|
|
412
424
|
thread_context: channelThreadContext,
|
|
413
425
|
is_reply: !!msg.thread_ts,
|
|
426
|
+
// Slack's own privacy verdict (see getMonitoredChannels); undefined
|
|
427
|
+
// when unknown so the downstream gate stays fail-closed.
|
|
428
|
+
is_private: channel.is_private,
|
|
429
|
+
channel_type: typeof channel.is_private === "boolean"
|
|
430
|
+
? (channel.is_private ? "group" : "channel")
|
|
431
|
+
: undefined,
|
|
414
432
|
attachments: attachments.length > 0 ? attachments : undefined,
|
|
415
433
|
priority_signals: {
|
|
416
434
|
from_ceo: isCeo,
|
|
@@ -538,6 +556,9 @@ export async function pollSlack() {
|
|
|
538
556
|
mentions_agent: true,
|
|
539
557
|
},
|
|
540
558
|
raw_ref: `slack:${im.id}:${msg.ts}`,
|
|
559
|
+
channel_type: "im",
|
|
560
|
+
is_private: true,
|
|
561
|
+
is_dm: true,
|
|
541
562
|
});
|
|
542
563
|
}
|
|
543
564
|
|
|
@@ -722,6 +743,13 @@ export async function pollSlack() {
|
|
|
722
743
|
mentions_agent: isDmThread || (loadAgent().firstName && new RegExp(loadAgent().firstName, "i").test(replyText)),
|
|
723
744
|
},
|
|
724
745
|
raw_ref: `slack:${channelId}:${reply.ts}`,
|
|
746
|
+
// Same privacy verdict as the parent conversation: an im is private
|
|
747
|
+
// by construction, a channel carries whatever Slack said it is.
|
|
748
|
+
...(isDmThread
|
|
749
|
+
? { channel_type: "im", is_private: true, is_dm: true }
|
|
750
|
+
: (chanInfo && typeof chanInfo.is_private === "boolean"
|
|
751
|
+
? { channel_type: chanInfo.is_private ? "group" : "channel", is_private: chanInfo.is_private }
|
|
752
|
+
: {})),
|
|
725
753
|
});
|
|
726
754
|
}
|
|
727
755
|
} catch (err) {
|
|
@@ -824,6 +852,10 @@ export async function pollSlack() {
|
|
|
824
852
|
mentions_agent: loadAgent().firstName ? new RegExp(loadAgent().firstName, "i").test(content) : false,
|
|
825
853
|
},
|
|
826
854
|
raw_ref: eventRef,
|
|
855
|
+
// Slack's conversation type off the delivered envelope — the only
|
|
856
|
+
// dependable privacy signal (the id prefix is not one). Absent on
|
|
857
|
+
// older payloads, in which case we assert nothing.
|
|
858
|
+
...slackChannelPrivacy(data.channel_type || evt.channel_type),
|
|
827
859
|
_inbox_file: file,
|
|
828
860
|
});
|
|
829
861
|
|
|
@@ -37,7 +37,7 @@ import { existsSync, readFileSync, mkdirSync, appendFileSync } from "node:fs";
|
|
|
37
37
|
import { dirname, join, resolve } from "node:path";
|
|
38
38
|
import { fileURLToPath } from "node:url";
|
|
39
39
|
|
|
40
|
-
import { writeInboxItem, resolvePrivilege, resolveName, AGENT_REPO_DIR } from "./utils.mjs";
|
|
40
|
+
import { writeInboxItem, resolvePrivilege, resolveName, slackChannelPrivacy, AGENT_REPO_DIR } from "./utils.mjs";
|
|
41
41
|
import { handleSlashCommand, postSlashResponse } from "./lib/slash-command-handlers.mjs";
|
|
42
42
|
|
|
43
43
|
/**
|
|
@@ -276,6 +276,20 @@ export function shouldKeepEvent(envelope, { ownSlackId, peerSlackIds, reactions,
|
|
|
276
276
|
// Translation — Slack envelope → poller-compatible inbox item
|
|
277
277
|
// ---------------------------------------------------------------------------
|
|
278
278
|
|
|
279
|
+
/**
|
|
280
|
+
* CONFIDENTIALITY SIGNAL. Slack's conversation-ID prefix is NOT a reliable type
|
|
281
|
+
* indicator — a private channel can carry a `C…` id, and only `D` for a DM is
|
|
282
|
+
* dependable. The dependable signal is the event envelope's own `channel_type`,
|
|
283
|
+
* which this listener used to drop on the floor. We now carry it onto the inbox
|
|
284
|
+
* item, together with the booleans it implies, because the daemon's outcome gate
|
|
285
|
+
* (scripts/daemon/session-outcomes.mjs `outcomeSourceShareable`) requires a
|
|
286
|
+
* POSITIVE public signal before a turn's commitments may become org-visible.
|
|
287
|
+
*
|
|
288
|
+
* Re-exported from ./utils.mjs, the one place the decoding is defined (the
|
|
289
|
+
* Web-API poller stamps the same fields).
|
|
290
|
+
*/
|
|
291
|
+
export { slackChannelPrivacy } from "./utils.mjs";
|
|
292
|
+
|
|
279
293
|
/**
|
|
280
294
|
* Convert a Slack Socket Mode envelope to the same inbox-item shape that
|
|
281
295
|
* `slack-poller.mjs` produces. Pure function; all I/O happens at the caller.
|
|
@@ -307,6 +321,9 @@ export function eventToInboxItem(envelope, { ownSlackId, principalSlackId, agent
|
|
|
307
321
|
const channelLabel = isDm ? `dm/${senderName}` : channelId;
|
|
308
322
|
const verb = event.type === "reaction_added" ? "added" : "removed";
|
|
309
323
|
const targetText = typeof threadContext === "string" ? threadContext : "";
|
|
324
|
+
// A reaction envelope carries no `channel_type`; only the `D` prefix is
|
|
325
|
+
// dependable, so that is all we assert (and only in the private direction).
|
|
326
|
+
const reactionPrivacy = isDm ? { channel_type: "im", is_private: true, is_dm: true } : {};
|
|
310
327
|
return {
|
|
311
328
|
id: `${targetTs.replace(".", "-")}-reaction-${reactor}`,
|
|
312
329
|
service: "slack",
|
|
@@ -329,6 +346,7 @@ export function eventToInboxItem(envelope, { ownSlackId, principalSlackId, agent
|
|
|
329
346
|
raw_ref: `slack:${channelId}:${targetTs}:reaction`,
|
|
330
347
|
source: "socket-mode",
|
|
331
348
|
kind: "reaction",
|
|
349
|
+
...reactionPrivacy,
|
|
332
350
|
};
|
|
333
351
|
}
|
|
334
352
|
|
|
@@ -338,6 +356,12 @@ export function eventToInboxItem(envelope, { ownSlackId, principalSlackId, agent
|
|
|
338
356
|
const channelId = event.channel || "";
|
|
339
357
|
const threadTs = event.thread_ts || "";
|
|
340
358
|
const isReply = !!threadTs && threadTs !== ts;
|
|
359
|
+
// Slack's own conversation type, when the envelope carries one. See
|
|
360
|
+
// `slackChannelPrivacy` — this, not the id prefix, is the privacy signal.
|
|
361
|
+
const privacy = slackChannelPrivacy(event.channel_type);
|
|
362
|
+
// Routing/labelling stays on the historical `D`-prefix test (an `im` always
|
|
363
|
+
// has a `D` id anyway, and promoting an `mpim` to "DM" would change who the
|
|
364
|
+
// agent auto-replies to). `privacy` only informs the confidentiality fields.
|
|
341
365
|
const isDm = typeof channelId === "string" && channelId.startsWith("D");
|
|
342
366
|
|
|
343
367
|
// Friendly labels. We can't resolve a channel name without an API call
|
|
@@ -383,6 +407,8 @@ export function eventToInboxItem(envelope, { ownSlackId, principalSlackId, agent
|
|
|
383
407
|
},
|
|
384
408
|
raw_ref: `slack:${channelId}:${ts}`,
|
|
385
409
|
source: "socket-mode",
|
|
410
|
+
// channel_type / is_private / is_dm — only when Slack actually told us.
|
|
411
|
+
...privacy,
|
|
386
412
|
};
|
|
387
413
|
if (isCc) item.kind = "channel_cc";
|
|
388
414
|
return item;
|
|
@@ -279,6 +279,58 @@ test("eventToInboxItem: thread reply preserves thread_id + is_reply", () => {
|
|
|
279
279
|
assert.equal(item.thread_id, "1735689500.000100");
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
+
test("eventToInboxItem: carries Slack's channel_type as the privacy signal", () => {
|
|
283
|
+
// CONFIDENTIALITY. The listener used to drop `event.channel_type` entirely and
|
|
284
|
+
// derive privacy from the id prefix alone — but Slack mints `C…` ids for
|
|
285
|
+
// PRIVATE channels too, so downstream (the daemon's fail-closed outcome gate)
|
|
286
|
+
// had nothing dependable to admit on. Every kept event now carries the type,
|
|
287
|
+
// and the booleans it implies, verbatim off the envelope.
|
|
288
|
+
const mk = (channel, channelType) => eventToInboxItem(
|
|
289
|
+
makeEnvelope({
|
|
290
|
+
event: {
|
|
291
|
+
user: HUMAN_SLACK_ID,
|
|
292
|
+
channel,
|
|
293
|
+
...(channelType === undefined ? {} : { channel_type: channelType }),
|
|
294
|
+
text: `<@${AGENT_SLACK_ID}> noted`,
|
|
295
|
+
ts: "1735689700.002000",
|
|
296
|
+
},
|
|
297
|
+
}),
|
|
298
|
+
{ ownSlackId: AGENT_SLACK_ID, principalSlackId: PRINCIPAL_SLACK_ID, agentFirstName: "Ravi" }
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const pub = mk("C0CHANNEL1", "channel");
|
|
302
|
+
assert.equal(pub.channel_type, "channel");
|
|
303
|
+
assert.equal(pub.is_private, false);
|
|
304
|
+
assert.equal(pub.is_dm, false);
|
|
305
|
+
|
|
306
|
+
// A private channel WITH a public-looking `C…` id — the leak vector.
|
|
307
|
+
const priv = mk("C0PRIVATE1", "group");
|
|
308
|
+
assert.equal(priv.channel_type, "group");
|
|
309
|
+
assert.equal(priv.is_private, true);
|
|
310
|
+
|
|
311
|
+
const mpim = mk("C0MPIM0001", "mpim");
|
|
312
|
+
assert.equal(mpim.channel_type, "mpim");
|
|
313
|
+
assert.equal(mpim.is_private, true);
|
|
314
|
+
assert.equal(mpim.is_dm, true);
|
|
315
|
+
assert.equal(mpim.channel, "C0MPIM0001", "an mpim is still routed as a channel, not promoted to a DM");
|
|
316
|
+
|
|
317
|
+
const im = mk("DABCDEF01", "im");
|
|
318
|
+
assert.equal(im.channel_type, "im");
|
|
319
|
+
assert.equal(im.is_private, true);
|
|
320
|
+
assert.equal(im.is_dm, true);
|
|
321
|
+
|
|
322
|
+
// No type on the envelope ⇒ assert NOTHING (a defaulted `is_private:false`
|
|
323
|
+
// would forge a public verdict the gate then admits on).
|
|
324
|
+
const unknown = mk("C0CHANNEL1", undefined);
|
|
325
|
+
assert.equal("channel_type" in unknown, false);
|
|
326
|
+
assert.equal("is_private" in unknown, false);
|
|
327
|
+
assert.equal("is_dm" in unknown, false);
|
|
328
|
+
|
|
329
|
+
// An unrecognised type is likewise not decoded into a verdict.
|
|
330
|
+
const weird = mk("C0CHANNEL1", "external_shared");
|
|
331
|
+
assert.equal("is_private" in weird, false);
|
|
332
|
+
});
|
|
333
|
+
|
|
282
334
|
test("eventToInboxItem: first-name mention (no @) still flips mentions_agent", () => {
|
|
283
335
|
// "Hey Ravi, can you …" in a channel — no explicit @-mention but the
|
|
284
336
|
// agent's first name appears. The poller uses this same heuristic.
|
package/scripts/poller/utils.mjs
CHANGED
|
@@ -10,6 +10,35 @@ export const AGENT_REPO_DIR = process.env.AGENT_DIR || join(__dirname, "../..");
|
|
|
10
10
|
export const SOPHIE_AI_DIR = AGENT_REPO_DIR;
|
|
11
11
|
export const ATTACHMENTS_DIR = join(AGENT_REPO_DIR, "state", "inbox", "attachments");
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* CONFIDENTIALITY SIGNAL — decode Slack's conversation type into the fields an
|
|
15
|
+
* inbox item carries. Every Slack ingest path (Socket Mode, the Web-API poller,
|
|
16
|
+
* the cloud relay) routes through this one decoder so the meaning of
|
|
17
|
+
* `channel_type` / `is_private` / `is_dm` is defined in exactly one place.
|
|
18
|
+
*
|
|
19
|
+
* WHY IT EXISTS: Slack's conversation-ID prefix is NOT a reliable type
|
|
20
|
+
* indicator — a private channel can and does carry a `C…` id; only `D` for a DM
|
|
21
|
+
* is dependable. The dependable signal is `channel_type` on the event envelope
|
|
22
|
+
* ("channel" public | "group" private | "im" DM | "mpim" group DM), so that is
|
|
23
|
+
* what we decode and carry to the daemon's fail-closed outcome gate
|
|
24
|
+
* (scripts/daemon/session-outcomes.mjs `outcomeSourceShareable`).
|
|
25
|
+
*
|
|
26
|
+
* Returns `{}` for anything unrecognised or missing: asserting nothing keeps
|
|
27
|
+
* that gate closed, which is the safe direction.
|
|
28
|
+
*
|
|
29
|
+
* @param {unknown} channelType raw Slack `channel_type`
|
|
30
|
+
* @returns {{channel_type?:string, is_private?:boolean, is_dm?:boolean}}
|
|
31
|
+
*/
|
|
32
|
+
export function slackChannelPrivacy(channelType) {
|
|
33
|
+
switch (String(channelType || "").trim().toLowerCase()) {
|
|
34
|
+
case "channel": return { channel_type: "channel", is_private: false, is_dm: false };
|
|
35
|
+
case "group": return { channel_type: "group", is_private: true, is_dm: false };
|
|
36
|
+
case "im": return { channel_type: "im", is_private: true, is_dm: true };
|
|
37
|
+
case "mpim": return { channel_type: "mpim", is_private: true, is_dm: true };
|
|
38
|
+
default: return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
13
42
|
export function writeInboxItem(service, item, agentRoot = AGENT_REPO_DIR) {
|
|
14
43
|
const dir = join(agentRoot, "state", "inbox", service);
|
|
15
44
|
mkdirSync(dir, { recursive: true });
|
|
@@ -159,6 +188,24 @@ raw_ref: "${item.raw_ref || ""}"
|
|
|
159
188
|
yaml += `channel_id: "${item.channel_id}"\n`;
|
|
160
189
|
}
|
|
161
190
|
|
|
191
|
+
// CONFIDENTIALITY SIGNALS. Slack's conversation-ID prefix is not a reliable
|
|
192
|
+
// type indicator (a private channel can carry a `C…` id), so downstream
|
|
193
|
+
// consumers — notably the daemon's fail-closed outcome gate,
|
|
194
|
+
// scripts/daemon/session-outcomes.mjs `outcomeSourceShareable` — need the
|
|
195
|
+
// conversation type/privacy the ingest layer read off the event itself.
|
|
196
|
+
// Written ONLY when actually known: an absent line must stay absent, because
|
|
197
|
+
// `is_private: false` is a positive "this is public" assertion and the reader
|
|
198
|
+
// (inbox-scan-poller.mjs `parseInboxItemYaml`) decodes it as a tri-state.
|
|
199
|
+
if (item.channel_type) {
|
|
200
|
+
yaml += `channel_type: "${item.channel_type}"\n`;
|
|
201
|
+
}
|
|
202
|
+
if (typeof item.is_private === "boolean") {
|
|
203
|
+
yaml += `is_private: ${item.is_private}\n`;
|
|
204
|
+
}
|
|
205
|
+
if (typeof item.is_dm === "boolean") {
|
|
206
|
+
yaml += `is_dm: ${item.is_dm}\n`;
|
|
207
|
+
}
|
|
208
|
+
|
|
162
209
|
// Add event_type if present (e.g., message_changed, reaction)
|
|
163
210
|
if (item.event_type) {
|
|
164
211
|
yaml += `event_type: "${item.event_type}"\n`;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gen-subagent-manifest.mjs — generate `agents/manifest.json`, layer 0's index
|
|
4
|
+
* (Sub-Agent Registry v1 §2.2).
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS SCRIPT EXISTS. `agents/` ships in the npm package and is copied
|
|
7
|
+
* verbatim by `maestro create`, then MERGED (never overwritten) by
|
|
8
|
+
* `UPGRADE_PATHS`. That means an SDK fix to a shipped agent.md can never reach a
|
|
9
|
+
* deployed agent — an upgraded file and a hand-edited one look identical on disk.
|
|
10
|
+
* The manifest's per-agent sha256 breaks the tie: a file whose hash matches a
|
|
11
|
+
* manifest sha is a pristine copy and may be re-materialised; anything else is a
|
|
12
|
+
* human's edit and is sacred. That single fact is what lets `create`'s copy list
|
|
13
|
+
* and `UPGRADE_PATHS` stay EXACTLY as they are — no migration, no breaking change.
|
|
14
|
+
*
|
|
15
|
+
* Run at prepublish and commit the result:
|
|
16
|
+
* node scripts/setup/gen-subagent-manifest.mjs
|
|
17
|
+
* node scripts/setup/gen-subagent-manifest.mjs --check # CI: fail on drift
|
|
18
|
+
*
|
|
19
|
+
* `--check` is the CI gate: it rebuilds the manifest in memory and fails when it
|
|
20
|
+
* differs from the committed one, so a shipped agent.md can never change without
|
|
21
|
+
* its sha changing with it (which would silently reclassify every deployed copy
|
|
22
|
+
* as a local edit and freeze it forever).
|
|
23
|
+
*
|
|
24
|
+
* Exit codes: 0 ok · 1 drift (with --check) · 2 an agent.md failed validation.
|
|
25
|
+
*
|
|
26
|
+
* Node builtins only. ESM.
|
|
27
|
+
*
|
|
28
|
+
* @module scripts/setup/gen-subagent-manifest
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
"use strict";
|
|
32
|
+
|
|
33
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
34
|
+
import { dirname, join, resolve } from "node:path";
|
|
35
|
+
import { fileURLToPath } from "node:url";
|
|
36
|
+
import { buildManifest, manifestPath, readManifest, writeManifest } from "../../lib/subagents/manifest.mjs";
|
|
37
|
+
|
|
38
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
const MAESTRO_ROOT =
|
|
40
|
+
process.env.MAESTRO_ROOT && existsSync(join(process.env.MAESTRO_ROOT, "lib", "subagents", "manifest.mjs"))
|
|
41
|
+
? process.env.MAESTRO_ROOT
|
|
42
|
+
: resolve(HERE, "..", "..");
|
|
43
|
+
|
|
44
|
+
/** Read the package version so the manifest can name the release it describes. */
|
|
45
|
+
function sdkVersion(root) {
|
|
46
|
+
try { return String(JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version || ""); }
|
|
47
|
+
catch { return ""; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compare two manifests ignoring `generatedAt` — a timestamp differing on every
|
|
52
|
+
* run would make `--check` permanently red and train everyone to ignore it.
|
|
53
|
+
*/
|
|
54
|
+
function sameManifest(a, b) {
|
|
55
|
+
const strip = (m) => JSON.stringify({ sdkVersion: m.sdkVersion || "", agents: m.agents || {}, emptyDirs: m.emptyDirs || [] });
|
|
56
|
+
return strip(a || {}) === strip(b || {});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function main(argv = process.argv.slice(2), root = MAESTRO_ROOT) {
|
|
60
|
+
const check = argv.includes("--check");
|
|
61
|
+
const strict = argv.includes("--strict");
|
|
62
|
+
const previous = readManifest(root);
|
|
63
|
+
const { manifest, invalid } = buildManifest({ maestroRoot: root, sdkVersion: sdkVersion(root), previous });
|
|
64
|
+
|
|
65
|
+
for (const bad of invalid) {
|
|
66
|
+
console.error(`[gen-subagent-manifest] agents/${bad.slug}/agent.md is invalid:`);
|
|
67
|
+
for (const e of bad.errors) console.error(` - ${e}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (check) {
|
|
71
|
+
if (!existsSync(manifestPath(root))) {
|
|
72
|
+
console.error(`[gen-subagent-manifest] ${manifestPath(root)} does not exist — run this script and commit it.`);
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
if (!sameManifest(previous, manifest)) {
|
|
76
|
+
console.error("[gen-subagent-manifest] agents/manifest.json is STALE — an agent.md changed without regenerating it.");
|
|
77
|
+
console.error(" fix: node scripts/setup/gen-subagent-manifest.mjs && git add agents/manifest.json");
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
console.log(`[gen-subagent-manifest] up to date (${Object.keys(manifest.agents).length} agents).`);
|
|
81
|
+
return invalid.length && strict ? 2 : 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const p = writeManifest(root, manifest);
|
|
85
|
+
console.log(`[gen-subagent-manifest] wrote ${p} (${Object.keys(manifest.agents).length} agents, sdk ${manifest.sdkVersion || "?"}).`);
|
|
86
|
+
if (manifest.emptyDirs && manifest.emptyDirs.length) {
|
|
87
|
+
console.warn(`[gen-subagent-manifest] ${manifest.emptyDirs.length} agents/ directory(ies) have no agent.md: ${manifest.emptyDirs.join(", ")}`);
|
|
88
|
+
}
|
|
89
|
+
return invalid.length && strict ? 2 : 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Only run when invoked directly (importable for tests).
|
|
93
|
+
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
|
|
94
|
+
process.exit(main());
|
|
95
|
+
}
|