@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
|
@@ -48,6 +48,7 @@ import { fileURLToPath } from "node:url";
|
|
|
48
48
|
import { spawn } from "node:child_process";
|
|
49
49
|
import { recoverStaleClaims } from "../../lib/cadence-bus.mjs";
|
|
50
50
|
import { isQuarterStartMonth } from "../../lib/cadences.mjs";
|
|
51
|
+
import { loadOrgConfig } from "../../lib/org/client.mjs";
|
|
51
52
|
|
|
52
53
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
53
54
|
|
|
@@ -389,13 +390,40 @@ async function guardQuarterlySelfAssessment({ now } = {}) {
|
|
|
389
390
|
|
|
390
391
|
/** Read config/agent.json fail-open (for the org legs). Returns null on any error. */
|
|
391
392
|
function loadAgentCfg(agentRoot) {
|
|
393
|
+
let agent = null;
|
|
392
394
|
try {
|
|
393
395
|
const p = join(agentRoot, "config", "agent.json");
|
|
394
|
-
if (
|
|
395
|
-
return JSON.parse(readFileSync(p, "utf8"));
|
|
396
|
+
if (existsSync(p)) agent = JSON.parse(readFileSync(p, "utf8"));
|
|
396
397
|
} catch {
|
|
397
|
-
|
|
398
|
+
agent = null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ORG ENROLMENT LIVES IN config/org.yaml, NOT config/agent.json.
|
|
402
|
+
//
|
|
403
|
+
// Every org-facing guard below gates on `cfg.org.cohort` and bails out with
|
|
404
|
+
// "org messaging not configured" when it is absent. `config/agent.json` has no
|
|
405
|
+
// `org` key at all — enrolment is written to `config/org.yaml` by
|
|
406
|
+
// `maestro setup` and read by lib/org/client.loadOrgConfig — so all SIX of
|
|
407
|
+
// those guards were dead on every enrolled agent, silently and permanently.
|
|
408
|
+
//
|
|
409
|
+
// Observed in production: a human DM'd an agent repeatedly and the
|
|
410
|
+
// messaging-inbound cadence ran ~every 45s, each time deciding "org messaging
|
|
411
|
+
// not configured" and routing nothing. The agent looked healthy the whole time.
|
|
412
|
+
//
|
|
413
|
+
// This is the SAME bug class loadOrgConfig was introduced to fix ("the daemon
|
|
414
|
+
// reading collective.yaml by mistake") — fixed there, missed here. Merging at
|
|
415
|
+
// this one chokepoint fixes every guard at once rather than per-guard.
|
|
416
|
+
let org = null;
|
|
417
|
+
try {
|
|
418
|
+
org = loadOrgConfig(agentRoot);
|
|
419
|
+
} catch {
|
|
420
|
+
org = null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (org && org.org) {
|
|
424
|
+
return { ...(agent || {}), org: { ...((agent && agent.org) || {}), ...org.org } };
|
|
398
425
|
}
|
|
426
|
+
return agent;
|
|
399
427
|
}
|
|
400
428
|
|
|
401
429
|
/**
|
|
@@ -633,7 +661,17 @@ async function guardMessagingInbound({ event, agentRoot, log }, opts = {}) {
|
|
|
633
661
|
const writeInboxItem = opts.writeImpl || (await import("../poller/utils.mjs")).writeInboxItem;
|
|
634
662
|
|
|
635
663
|
const cursor = readMessagingCursor(agentRoot);
|
|
636
|
-
|
|
664
|
+
// Pass the agent id explicitly. It is what suppresses the agent's OWN
|
|
665
|
+
// outbound echoes — without it `me` is null, every echo-filter branch is
|
|
666
|
+
// skipped, and the agent re-ingests its own replies as fresh inbound (a
|
|
667
|
+
// conversation with itself). config/org.yaml does not carry an agentId, so
|
|
668
|
+
// env is the reliable source on a real machine.
|
|
669
|
+
const agentId =
|
|
670
|
+
opts.agentId ||
|
|
671
|
+
process.env.COHORT_AGENT_ID ||
|
|
672
|
+
(cfg && cfg.org && cfg.org.cohort && cfg.org.cohort.agentId) ||
|
|
673
|
+
undefined;
|
|
674
|
+
const res = await pull({ cfg, agentId, cursor, fetchImpl: opts.fetchImpl });
|
|
637
675
|
const events = (res && Array.isArray(res.events)) ? res.events : [];
|
|
638
676
|
|
|
639
677
|
let routed = 0;
|
|
@@ -879,6 +917,122 @@ async function guardBrandSteward({ event, agentRoot, log }, opts = {}) {
|
|
|
879
917
|
}
|
|
880
918
|
}
|
|
881
919
|
|
|
920
|
+
// ---------------------------------------------------------------------------
|
|
921
|
+
// goal-steward (D3, SPEC §6.3) — the self-directed reconciliation loop
|
|
922
|
+
// ---------------------------------------------------------------------------
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Refresh the cached mandate, then run one goal-steward pass INLINE.
|
|
926
|
+
*
|
|
927
|
+
* Guarded rather than escalate on purpose. The whole measure → gap → decide →
|
|
928
|
+
* admit → classify → route pass is deterministic local computation plus sensor
|
|
929
|
+
* reads; there is nothing for a model to do unless a gap needs *candidates*
|
|
930
|
+
* that the deterministic planner cannot produce. So:
|
|
931
|
+
*
|
|
932
|
+
* - no adopted mandate → inline, zero cost, one log line
|
|
933
|
+
* - every objective within tolerance → inline
|
|
934
|
+
* - a gap that produced admitted work → inline (the work is already
|
|
935
|
+
* queued + routed)
|
|
936
|
+
* - a gap the loop wants richer candidates
|
|
937
|
+
* for (observe-only, or a repeatedly
|
|
938
|
+
* failed intervention) → escalate to a planning session
|
|
939
|
+
*
|
|
940
|
+
* Never throws: every failure path returns `{ok:false, …}` with a reason so the
|
|
941
|
+
* consumer can archive/DLQ, and every degradation is logged.
|
|
942
|
+
*
|
|
943
|
+
* @param {object} ctx - { event, agentRoot, log }
|
|
944
|
+
* @param {object} [opts] - injectable seams: { runImpl, refreshImpl, cfg, enforcement, now, fetchImpl }
|
|
945
|
+
*/
|
|
946
|
+
async function guardGoalSteward({ event, agentRoot, log }, opts = {}) {
|
|
947
|
+
const cadence = event && event.cadence ? event.cadence : "goal-steward";
|
|
948
|
+
const say = typeof log === "function" ? log : () => {};
|
|
949
|
+
try {
|
|
950
|
+
// 1. Refresh the mandate cache first — a goal loop running off a week-old
|
|
951
|
+
// mandate is worse than one that does not run. Fail-open: the loop's own
|
|
952
|
+
// staleness ladder is what actually decides what it may do.
|
|
953
|
+
if (opts.refreshImpl !== null) {
|
|
954
|
+
try {
|
|
955
|
+
const refresh = opts.refreshImpl || (await import("../../lib/mandate/refresh.mjs")).refreshMandate;
|
|
956
|
+
const r = await refresh(agentRoot, { fetchImpl: opts.fetchImpl, log: say, now: opts.now });
|
|
957
|
+
if (r && r.ok === false) {
|
|
958
|
+
say("warn", `[goal-steward] mandate refresh failed (${r.reason}) — running on the cached mandate`);
|
|
959
|
+
}
|
|
960
|
+
} catch (err) {
|
|
961
|
+
say("warn", `[goal-steward] mandate refresh threw (${err && err.message ? err.message : err}) — running on the cached mandate`);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// 2. Enforcement posture. OBSERVE unless config/plan.yaml says otherwise —
|
|
966
|
+
// the loop must not create work on day one.
|
|
967
|
+
let enforcement = opts.enforcement;
|
|
968
|
+
if (enforcement === undefined) {
|
|
969
|
+
enforcement = "observe";
|
|
970
|
+
try {
|
|
971
|
+
const p = join(agentRoot, "config", "plan.yaml");
|
|
972
|
+
if (existsSync(p)) {
|
|
973
|
+
const m = readFileSync(p, "utf-8").match(/^\s*enforcement:\s*["']?(\w+)["']?/m);
|
|
974
|
+
if (m && m[1] === "active") enforcement = "active";
|
|
975
|
+
}
|
|
976
|
+
} catch (err) {
|
|
977
|
+
say("warn", `[goal-steward] could not read config/plan.yaml enforcement (${err && err.message ? err.message : err}) — staying observe-only`);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const run = opts.runImpl || (await import("../../lib/goals/loop.mjs")).runGoalSteward;
|
|
982
|
+
const report = await run({ agentRoot, log: say, enforcement, now: opts.now, ...(opts.runDeps || {}) });
|
|
983
|
+
|
|
984
|
+
if (!report || report.ok === false) {
|
|
985
|
+
// Not an error for the bus: "no mandate" is the normal pre-adoption state.
|
|
986
|
+
return {
|
|
987
|
+
ok: true,
|
|
988
|
+
decision: "inline",
|
|
989
|
+
cadence,
|
|
990
|
+
reason: (report && report.skipped) || "goal loop could not run",
|
|
991
|
+
measured: 0,
|
|
992
|
+
admitted: 0,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const measured = (report.measured || []).length;
|
|
997
|
+
const admitted = (report.admitted || []).length;
|
|
998
|
+
const rejected = (report.rejected || []).length;
|
|
999
|
+
const escalations = (report.escalations || []).length;
|
|
1000
|
+
const drift = (report.drift || []).length;
|
|
1001
|
+
|
|
1002
|
+
if (report.skipped) {
|
|
1003
|
+
return { ok: true, decision: "inline", cadence, reason: report.skipped, measured, admitted, drift };
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
say(
|
|
1007
|
+
"info",
|
|
1008
|
+
`[goal-steward] ${enforcement}: measured ${measured}, admitted ${admitted}, rejected ${rejected}, escalations ${escalations}, drift ${drift}`
|
|
1009
|
+
);
|
|
1010
|
+
|
|
1011
|
+
// 3. Escalate only when a model would actually add something: an open gap
|
|
1012
|
+
// that produced no admitted work and no escalation of its own.
|
|
1013
|
+
const unserved = (report.decisions || []).filter(
|
|
1014
|
+
(d) => d.action === "create" && !(report.admitted || []).some((a) => a.objectiveKey === d.objectiveKey)
|
|
1015
|
+
);
|
|
1016
|
+
if (unserved.length > 0 && enforcement === "active") {
|
|
1017
|
+
return {
|
|
1018
|
+
ok: true,
|
|
1019
|
+
decision: "escalate",
|
|
1020
|
+
cadence,
|
|
1021
|
+
reason: `${unserved.length} objective(s) have an open gap the deterministic planner could not serve`,
|
|
1022
|
+
objectives: unserved.map((d) => d.objectiveKey),
|
|
1023
|
+
measured,
|
|
1024
|
+
admitted,
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
return { ok: true, decision: "inline", cadence, measured, admitted, rejected, escalations, drift, enforcement };
|
|
1029
|
+
} catch (err) {
|
|
1030
|
+
// Fail-open but LOUD: the daemon keeps running, the failure is on the record.
|
|
1031
|
+
say("error", `[goal-steward] guard threw: ${err && err.message ? err.message : err}`);
|
|
1032
|
+
return { ok: false, decision: "inline", cadence, error: String(err && err.message ? err.message : err) };
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
882
1036
|
// ---------------------------------------------------------------------------
|
|
883
1037
|
// Registry
|
|
884
1038
|
// ---------------------------------------------------------------------------
|
|
@@ -1060,6 +1214,23 @@ export const CADENCE_REGISTRY = {
|
|
|
1060
1214
|
"design_propose_change", "messaging_send",
|
|
1061
1215
|
],
|
|
1062
1216
|
},
|
|
1217
|
+
|
|
1218
|
+
"goal-steward": {
|
|
1219
|
+
mode: "guarded",
|
|
1220
|
+
guard: guardGoalSteward,
|
|
1221
|
+
prompt: "schedules/triggers/goal-steward.md",
|
|
1222
|
+
description:
|
|
1223
|
+
"The self-directed loop (SPEC §6.3): measure every adopted objective whose window is open, compute the gap against target/tolerance/trend, and turn a real gap into real, provenance-linked backlog — classified SELF/COLLABORATE/HANDOFF/NEEDS_REVIEW and routed through the existing handoff/lease/approval primitives. Guarded: the whole pass runs inline; it escalates only when an open gap needs candidates the deterministic planner cannot produce. Observe-only until config/plan.yaml sets enforcement: active.",
|
|
1224
|
+
// Reads + the collaboration primitives. No shell, no send-to-external:
|
|
1225
|
+
// the loop proposes and routes; anything gated goes through approval.
|
|
1226
|
+
allowedTools: [
|
|
1227
|
+
"Read", "Grep", "Glob", "LS",
|
|
1228
|
+
"mandate_get", "mandate_propose", "mandate_sample",
|
|
1229
|
+
"board_create", "board_comment", "board_context",
|
|
1230
|
+
"handoff_offer", "approval_request", "escalation_ask",
|
|
1231
|
+
"task_create", "messaging_send",
|
|
1232
|
+
],
|
|
1233
|
+
},
|
|
1063
1234
|
};
|
|
1064
1235
|
|
|
1065
1236
|
// Optional config-driven cadence registry (config/.cadence-registry.json),
|
|
@@ -1088,7 +1259,17 @@ export function getCadenceDef(name) {
|
|
|
1088
1259
|
if (CADENCE_REGISTRY[name]) return CADENCE_REGISTRY[name];
|
|
1089
1260
|
const cfg = loadConfigRegistry()[name];
|
|
1090
1261
|
if (cfg && cfg.prompt) {
|
|
1091
|
-
|
|
1262
|
+
// EXTENDED registry shape (plan compiler, lib/plan/emit.mjs): a plan-derived
|
|
1263
|
+
// cadence carries its own tool scope, guard and budget. Passing these through
|
|
1264
|
+
// is what lets lib/session-permissions.resolveAllowlist() scope the spawn
|
|
1265
|
+
// instead of falling back to the conservative default — without it, every
|
|
1266
|
+
// derived cadence would run wider than its obligation declares.
|
|
1267
|
+
const def = { mode: cfg.mode || "escalate", prompt: cfg.prompt, description: "archetype/plan cadence (config-driven)" };
|
|
1268
|
+
if (Array.isArray(cfg.allowedTools) && cfg.allowedTools.length) def.allowedTools = cfg.allowedTools;
|
|
1269
|
+
if (cfg.guardModule) def.guardModule = cfg.guardModule;
|
|
1270
|
+
if (Number.isFinite(cfg.budgetCents)) def.budgetCents = cfg.budgetCents;
|
|
1271
|
+
if (cfg.obligationKey) def.obligationKey = cfg.obligationKey;
|
|
1272
|
+
return def;
|
|
1092
1273
|
}
|
|
1093
1274
|
return null;
|
|
1094
1275
|
}
|
|
@@ -1118,4 +1299,5 @@ export {
|
|
|
1118
1299
|
guardMessagingInbound,
|
|
1119
1300
|
guardDirectoryHygiene,
|
|
1120
1301
|
guardBrandSteward,
|
|
1302
|
+
guardGoalSteward,
|
|
1121
1303
|
};
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/daemon/goal-steward-cadence.test.mjs — the wiring that makes the
|
|
3
|
+
* self-directed loop actually RUN.
|
|
4
|
+
*
|
|
5
|
+
* Before this, `lib/goals/loop.mjs` existed and nothing ever called it: no
|
|
6
|
+
* standard cadence, no registry entry, no plist. A loop nobody triggers is a
|
|
7
|
+
* design document. These tests pin the trigger down:
|
|
8
|
+
*
|
|
9
|
+
* - `goal-steward` is a STANDARD cadence, so every agent gets a plist for it
|
|
10
|
+
* - it is GUARDED, so an agent with no adopted mandate costs ~nothing
|
|
11
|
+
* - it refreshes the mandate BEFORE measuring
|
|
12
|
+
* - it defaults to observe-only, whatever the caller forgot to pass
|
|
13
|
+
* - it escalates only when an open gap needs candidates it cannot produce
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
"use strict";
|
|
17
|
+
|
|
18
|
+
import { test } from "node:test";
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
import { CADENCE_REGISTRY, getCadenceDef, guardGoalSteward } from "./cadence-handlers.mjs";
|
|
25
|
+
import { STANDARD_CADENCES } from "../../lib/cadences.mjs";
|
|
26
|
+
|
|
27
|
+
function makeRoot() {
|
|
28
|
+
const root = mkdtempSync(join(tmpdir(), "goal-cadence-"));
|
|
29
|
+
mkdirSync(join(root, "config"), { recursive: true });
|
|
30
|
+
return root;
|
|
31
|
+
}
|
|
32
|
+
function cleanup(root) { try { rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
33
|
+
|
|
34
|
+
/** Baseline seams: no network, no real loop. */
|
|
35
|
+
function seams(over = {}) {
|
|
36
|
+
return {
|
|
37
|
+
refreshImpl: async () => ({ ok: true, source: "server", tier: "fresh" }),
|
|
38
|
+
runImpl: async () => ({ ok: true, measured: [], admitted: [], rejected: [], escalations: [], drift: [], decisions: [] }),
|
|
39
|
+
...over,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Registration
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
test("goal-steward is a STANDARD cadence, so every agent gets one", () => {
|
|
48
|
+
const c = STANDARD_CADENCES.find((x) => x.id === "goal-steward");
|
|
49
|
+
assert.ok(c, "the loop must have a trigger or it never runs");
|
|
50
|
+
assert.equal(c.scope, "standard");
|
|
51
|
+
assert.equal(c.mode, "guarded");
|
|
52
|
+
// Daily, on a calendar slot — not a hot interval.
|
|
53
|
+
assert.deepEqual(c.calendar, { hour: 7, minute: 15 });
|
|
54
|
+
assert.equal(c.prompt, "schedules/triggers/goal-steward.md");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("the daemon can route the cadence it is about to be sent", () => {
|
|
58
|
+
const def = getCadenceDef("goal-steward");
|
|
59
|
+
assert.ok(def, "an unroutable cadence DLQs every tick");
|
|
60
|
+
assert.equal(def.mode, "guarded");
|
|
61
|
+
assert.equal(typeof def.guard, "function");
|
|
62
|
+
assert.equal(CADENCE_REGISTRY["goal-steward"].guard, guardGoalSteward);
|
|
63
|
+
// Scoped tool surface: reads + collaboration primitives, no shell.
|
|
64
|
+
const tools = CADENCE_REGISTRY["goal-steward"].allowedTools;
|
|
65
|
+
assert.ok(Array.isArray(tools) && tools.length > 0);
|
|
66
|
+
assert.ok(tools.includes("approval_request"), "gated work must be able to reach the approval path");
|
|
67
|
+
assert.ok(!tools.includes("Bash"), "the goal loop has no business running shell");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// The guard
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
test("the mandate is refreshed BEFORE the loop measures anything", async () => {
|
|
75
|
+
const root = makeRoot();
|
|
76
|
+
try {
|
|
77
|
+
const order = [];
|
|
78
|
+
await guardGoalSteward({ event: { cadence: "goal-steward" }, agentRoot: root, log: () => {} }, seams({
|
|
79
|
+
refreshImpl: async () => { order.push("refresh"); return { ok: true }; },
|
|
80
|
+
runImpl: async () => { order.push("run"); return { ok: true, measured: [], admitted: [], decisions: [] }; },
|
|
81
|
+
}));
|
|
82
|
+
assert.deepEqual(order, ["refresh", "run"], "measuring against a stale mandate is worse than not measuring");
|
|
83
|
+
} finally { cleanup(root); }
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("enforcement defaults to OBSERVE when config/plan.yaml says nothing", async () => {
|
|
87
|
+
const root = makeRoot();
|
|
88
|
+
try {
|
|
89
|
+
let seen = null;
|
|
90
|
+
await guardGoalSteward({ event: {}, agentRoot: root, log: () => {} }, seams({
|
|
91
|
+
runImpl: async (d) => { seen = d.enforcement; return { ok: true, measured: [], admitted: [], decisions: [] }; },
|
|
92
|
+
}));
|
|
93
|
+
assert.equal(seen, "observe", "a loop that starts by writing is how you lose a fleet");
|
|
94
|
+
} finally { cleanup(root); }
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("enforcement:active in config/plan.yaml turns the loop on", async () => {
|
|
98
|
+
const root = makeRoot();
|
|
99
|
+
try {
|
|
100
|
+
writeFileSync(join(root, "config", "plan.yaml"), "version: 1\nenforcement: active\nobligations: []\n", "utf-8");
|
|
101
|
+
let seen = null;
|
|
102
|
+
await guardGoalSteward({ event: {}, agentRoot: root, log: () => {} }, seams({
|
|
103
|
+
runImpl: async (d) => { seen = d.enforcement; return { ok: true, measured: [], admitted: [], decisions: [] }; },
|
|
104
|
+
}));
|
|
105
|
+
assert.equal(seen, "active");
|
|
106
|
+
} finally { cleanup(root); }
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("any other enforcement value stays observe — fail-closed on the write path", async () => {
|
|
110
|
+
const root = makeRoot();
|
|
111
|
+
try {
|
|
112
|
+
writeFileSync(join(root, "config", "plan.yaml"), "enforcement: yes-please\n", "utf-8");
|
|
113
|
+
let seen = null;
|
|
114
|
+
await guardGoalSteward({ event: {}, agentRoot: root, log: () => {} }, seams({
|
|
115
|
+
runImpl: async (d) => { seen = d.enforcement; return { ok: true, measured: [], admitted: [], decisions: [] }; },
|
|
116
|
+
}));
|
|
117
|
+
assert.equal(seen, "observe");
|
|
118
|
+
} finally { cleanup(root); }
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("no adopted mandate completes INLINE — an idle seat costs one cache read", async () => {
|
|
122
|
+
const root = makeRoot();
|
|
123
|
+
try {
|
|
124
|
+
const r = await guardGoalSteward({ event: {}, agentRoot: root, log: () => {} }, seams({
|
|
125
|
+
runImpl: async () => ({ ok: false, skipped: "no mandate cache — this seat has never fetched an adopted mandate" }),
|
|
126
|
+
}));
|
|
127
|
+
assert.equal(r.ok, true, "a pre-adoption seat is not a bus failure");
|
|
128
|
+
assert.equal(r.decision, "inline");
|
|
129
|
+
assert.match(r.reason, /never fetched an adopted mandate/);
|
|
130
|
+
} finally { cleanup(root); }
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("a satisfied mandate completes inline and never spawns a session", async () => {
|
|
134
|
+
const root = makeRoot();
|
|
135
|
+
try {
|
|
136
|
+
const r = await guardGoalSteward({ event: {}, agentRoot: root, log: () => {} }, seams({
|
|
137
|
+
runImpl: async () => ({
|
|
138
|
+
ok: true, measured: [{ objectiveKey: "pipeline", value: 4 }], admitted: [], rejected: [],
|
|
139
|
+
escalations: [], drift: [], decisions: [{ objectiveKey: "pipeline", action: "none" }],
|
|
140
|
+
}),
|
|
141
|
+
}));
|
|
142
|
+
assert.equal(r.decision, "inline");
|
|
143
|
+
assert.equal(r.measured, 1);
|
|
144
|
+
assert.equal(r.admitted, 0);
|
|
145
|
+
} finally { cleanup(root); }
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("a gap the deterministic planner SERVED completes inline — the work is already queued", async () => {
|
|
149
|
+
const root = makeRoot();
|
|
150
|
+
try {
|
|
151
|
+
writeFileSync(join(root, "config", "plan.yaml"), "enforcement: active\n", "utf-8");
|
|
152
|
+
const r = await guardGoalSteward({ event: {}, agentRoot: root, log: () => {} }, seams({
|
|
153
|
+
runImpl: async () => ({
|
|
154
|
+
ok: true,
|
|
155
|
+
measured: [{ objectiveKey: "pipeline", value: 1 }],
|
|
156
|
+
admitted: [{ objectiveKey: "pipeline", title: "Book calls" }],
|
|
157
|
+
rejected: [], escalations: [], drift: [],
|
|
158
|
+
decisions: [{ objectiveKey: "pipeline", action: "create" }],
|
|
159
|
+
}),
|
|
160
|
+
}));
|
|
161
|
+
assert.equal(r.decision, "inline", "no model is needed when the work already exists");
|
|
162
|
+
assert.equal(r.admitted, 1);
|
|
163
|
+
} finally { cleanup(root); }
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("an UNSERVED open gap escalates to a planning session, naming the objectives", async () => {
|
|
167
|
+
const root = makeRoot();
|
|
168
|
+
try {
|
|
169
|
+
writeFileSync(join(root, "config", "plan.yaml"), "enforcement: active\n", "utf-8");
|
|
170
|
+
const r = await guardGoalSteward({ event: {}, agentRoot: root, log: () => {} }, seams({
|
|
171
|
+
runImpl: async () => ({
|
|
172
|
+
ok: true,
|
|
173
|
+
measured: [{ objectiveKey: "pipeline", value: 1 }],
|
|
174
|
+
admitted: [], rejected: [], escalations: [], drift: [],
|
|
175
|
+
decisions: [{ objectiveKey: "pipeline", action: "create" }],
|
|
176
|
+
}),
|
|
177
|
+
}));
|
|
178
|
+
assert.equal(r.decision, "escalate");
|
|
179
|
+
assert.deepEqual(r.objectives, ["pipeline"]);
|
|
180
|
+
} finally { cleanup(root); }
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("OBSERVE-ONLY never escalates, even with an unserved gap", async () => {
|
|
184
|
+
const root = makeRoot();
|
|
185
|
+
try {
|
|
186
|
+
const r = await guardGoalSteward({ event: {}, agentRoot: root, log: () => {} }, seams({
|
|
187
|
+
runImpl: async () => ({
|
|
188
|
+
ok: true, measured: [], admitted: [], rejected: [], escalations: [], drift: [],
|
|
189
|
+
decisions: [{ objectiveKey: "pipeline", action: "create" }],
|
|
190
|
+
}),
|
|
191
|
+
}));
|
|
192
|
+
assert.equal(r.decision, "inline", "the observe window must not spend money on planning sessions");
|
|
193
|
+
} finally { cleanup(root); }
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Failure containment
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
test("SERVER UNREACHABLE: a failed mandate refresh degrades to the cache, loudly", async () => {
|
|
201
|
+
const root = makeRoot();
|
|
202
|
+
try {
|
|
203
|
+
const logs = [];
|
|
204
|
+
const r = await guardGoalSteward({ event: {}, agentRoot: root, log: (l, m) => logs.push(`${l}:${m}`) }, seams({
|
|
205
|
+
refreshImpl: async () => ({ ok: false, reason: "call-threw:ECONNREFUSED" }),
|
|
206
|
+
}));
|
|
207
|
+
assert.equal(r.ok, true, "a partition must not stop the loop from running on cache");
|
|
208
|
+
assert.ok(logs.some((l) => /refresh failed/.test(l)));
|
|
209
|
+
} finally { cleanup(root); }
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("SERVER UNREACHABLE: a THROWING refresh is contained and logged", async () => {
|
|
213
|
+
const root = makeRoot();
|
|
214
|
+
try {
|
|
215
|
+
const logs = [];
|
|
216
|
+
const r = await guardGoalSteward({ event: {}, agentRoot: root, log: (l, m) => logs.push(`${l}:${m}`) }, seams({
|
|
217
|
+
refreshImpl: async () => { throw new Error("ECONNREFUSED"); },
|
|
218
|
+
}));
|
|
219
|
+
assert.equal(r.ok, true);
|
|
220
|
+
assert.ok(logs.some((l) => /refresh threw/.test(l)));
|
|
221
|
+
} finally { cleanup(root); }
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("a THROWING loop returns ok:false with the error — fail-open but never silent", async () => {
|
|
225
|
+
const root = makeRoot();
|
|
226
|
+
try {
|
|
227
|
+
const logs = [];
|
|
228
|
+
const r = await guardGoalSteward({ event: {}, agentRoot: root, log: (l, m) => logs.push(`${l}:${m}`) }, seams({
|
|
229
|
+
runImpl: async () => { throw new Error("loop exploded"); },
|
|
230
|
+
}));
|
|
231
|
+
assert.equal(r.ok, false);
|
|
232
|
+
assert.match(r.error, /loop exploded/);
|
|
233
|
+
assert.ok(logs.some((l) => l.startsWith("error:")), "the daemon keeps running, the failure is on the record");
|
|
234
|
+
} finally { cleanup(root); }
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("the guard never throws, whatever it is handed", async () => {
|
|
238
|
+
const r = await guardGoalSteward({ event: null, agentRoot: "/nonexistent-xyz", log: null }, seams({
|
|
239
|
+
runImpl: async () => null,
|
|
240
|
+
}));
|
|
241
|
+
assert.equal(typeof r.ok, "boolean");
|
|
242
|
+
assert.equal(r.cadence, "goal-steward");
|
|
243
|
+
});
|
|
@@ -37,7 +37,36 @@
|
|
|
37
37
|
import { readdirSync, readFileSync, renameSync, existsSync } from "node:fs";
|
|
38
38
|
import { join } from "node:path";
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
import { signalInbox } from "./inbox-wake.mjs";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Fallback service list, used only when `state/inbox/` cannot be enumerated.
|
|
44
|
+
*
|
|
45
|
+
* It used to be the ONLY list, and it was written before the channel-bus and
|
|
46
|
+
* org services existed: `cohort`, `orgmail` and `telegram` were never in it. A
|
|
47
|
+
* deferred Cohort DM (the second message of any rapid-fire burst) therefore had
|
|
48
|
+
* NO promote path at all — `promoteDeferred` looked in six directories, none of
|
|
49
|
+
* which was `state/inbox/cohort/`, found nothing, and the message stayed
|
|
50
|
+
* `.deferred` forever. The list is now derived from disk (below) so a new
|
|
51
|
+
* service can never be silently un-promotable again.
|
|
52
|
+
*/
|
|
53
|
+
const FALLBACK_SERVICE_DIRS = ["slack", "gmail", "calendar", "internal", "sms", "whatsapp"];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every service inbox that exists on disk. Derived, not enumerated — the
|
|
57
|
+
* directories ARE the registry (the daemon creates one per configured service).
|
|
58
|
+
* @param {string} agentRoot
|
|
59
|
+
* @returns {string[]}
|
|
60
|
+
*/
|
|
61
|
+
function serviceDirs(agentRoot) {
|
|
62
|
+
try {
|
|
63
|
+
return readdirSync(join(agentRoot, "state", "inbox"), { withFileTypes: true })
|
|
64
|
+
.filter((e) => e.isDirectory())
|
|
65
|
+
.map((e) => e.name);
|
|
66
|
+
} catch {
|
|
67
|
+
return FALLBACK_SERVICE_DIRS;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
41
70
|
|
|
42
71
|
/**
|
|
43
72
|
* Rename a live inbox file to its `.deferred` suffix so the scanner
|
|
@@ -142,7 +171,7 @@ export function promoteDeferred(channel, agentRoot) {
|
|
|
142
171
|
const result = { promoted: 0, bundled: 0, service: null };
|
|
143
172
|
if (!channel || !agentRoot) return result;
|
|
144
173
|
|
|
145
|
-
for (const service of
|
|
174
|
+
for (const service of serviceDirs(agentRoot)) {
|
|
146
175
|
const inboxDir = join(agentRoot, "state", "inbox", service);
|
|
147
176
|
if (!existsSync(inboxDir)) continue;
|
|
148
177
|
|
|
@@ -206,5 +235,19 @@ export function promoteDeferred(channel, agentRoot) {
|
|
|
206
235
|
}
|
|
207
236
|
}
|
|
208
237
|
|
|
238
|
+
// The promoted item is live on disk RIGHT NOW, and this runs on the
|
|
239
|
+
// dispatcher's session-close path — i.e. the moment a burst's next message
|
|
240
|
+
// becomes answerable. Without a nudge it waits for the daemon's next inbox
|
|
241
|
+
// sweep, which is the single largest term in the reply latency of a
|
|
242
|
+
// back-to-back conversation. Free and idempotent: a wake with no work costs
|
|
243
|
+
// one readdir (scripts/daemon/inbox-wake.mjs).
|
|
244
|
+
if (result.promoted > 0) {
|
|
245
|
+
try {
|
|
246
|
+
signalInbox("promote-deferred");
|
|
247
|
+
} catch {
|
|
248
|
+
/* the wake is an optimisation — never let it break the promote */
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
209
252
|
return result;
|
|
210
253
|
}
|
|
@@ -240,3 +240,59 @@ test("L7: latest-wins uses top-level timestamp, not a body `timestamp:` line", (
|
|
|
240
240
|
rmSync(root, { recursive: true, force: true });
|
|
241
241
|
}
|
|
242
242
|
});
|
|
243
|
+
|
|
244
|
+
// --- service-dir discovery (the cohort/orgmail regression) -------------------
|
|
245
|
+
|
|
246
|
+
/** Write a deferred item into ANY service dir (creating it). */
|
|
247
|
+
function writeDeferredIn(root, service, name, { channel_id, timestamp, id }) {
|
|
248
|
+
mkdirSync(join(root, "state", "inbox", service), { recursive: true });
|
|
249
|
+
const body = [
|
|
250
|
+
`id: "${id}"`,
|
|
251
|
+
`service: "${service}"`,
|
|
252
|
+
`channel_id: "${channel_id}"`,
|
|
253
|
+
`timestamp: "${timestamp}"`,
|
|
254
|
+
`content: |`,
|
|
255
|
+
` body`,
|
|
256
|
+
"",
|
|
257
|
+
].join("\n");
|
|
258
|
+
writeFileSync(join(root, "state", "inbox", service, name), body);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
test("promoteDeferred finds services the hardcoded list never knew about (cohort/orgmail)", () => {
|
|
262
|
+
const root = makeAgentRoot();
|
|
263
|
+
try {
|
|
264
|
+
writeDeferredIn(root, "cohort", "cohort-msg.yaml.deferred", {
|
|
265
|
+
channel_id: "ch_build",
|
|
266
|
+
timestamp: "2026-08-11T00:00:00Z",
|
|
267
|
+
id: "cohort-msg",
|
|
268
|
+
});
|
|
269
|
+
const r = promoteDeferred("ch_build", root);
|
|
270
|
+
assert.equal(r.promoted, 1, "a deferred Cohort message was previously un-promotable forever");
|
|
271
|
+
assert.equal(r.service, "cohort");
|
|
272
|
+
assert.deepEqual(readdirSync(join(root, "state", "inbox", "cohort")), ["cohort-msg.yaml"]);
|
|
273
|
+
} finally {
|
|
274
|
+
rmSync(root, { recursive: true, force: true });
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("promoteDeferred signals the inbox wake so the promoted item is not stuck behind the poll", async () => {
|
|
279
|
+
const { waitForInboxWake, resetInboxWake } = await import("./inbox-wake.mjs");
|
|
280
|
+
const root = makeAgentRoot();
|
|
281
|
+
resetInboxWake();
|
|
282
|
+
try {
|
|
283
|
+
writeDeferredIn(root, "cohort", "burst-2.yaml.deferred", {
|
|
284
|
+
channel_id: "ch_dm",
|
|
285
|
+
timestamp: "2026-08-11T00:00:01Z",
|
|
286
|
+
id: "burst-2",
|
|
287
|
+
});
|
|
288
|
+
// scanMs is disabled so ONLY the in-process signal can settle this.
|
|
289
|
+
const waiting = waitForInboxWake({ agentRoot: root, timeoutMs: 3_000, scanMs: 600_000 });
|
|
290
|
+
setTimeout(() => promoteDeferred("ch_dm", root), 20);
|
|
291
|
+
const woke = await waiting;
|
|
292
|
+
assert.equal(woke.woke, true);
|
|
293
|
+
assert.equal(woke.service, "cohort");
|
|
294
|
+
} finally {
|
|
295
|
+
resetInboxWake();
|
|
296
|
+
rmSync(root, { recursive: true, force: true });
|
|
297
|
+
}
|
|
298
|
+
});
|