@cohortapp/agent-sdk 2.4.1 → 2.5.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 +9 -0
- package/lib/backlog.mjs +35 -0
- package/lib/backlog.test.mjs +36 -0
- package/lib/channels/contract.mjs +1 -0
- package/lib/channels/contract.test.mjs +2 -1
- package/lib/channels/inbox-item.mjs +54 -0
- package/lib/comms/send-gate.mjs +56 -1
- package/lib/comms/send-gate.test.mjs +56 -0
- package/lib/execution/disposition.mjs +62 -2
- package/lib/execution/disposition.test.mjs +54 -0
- package/lib/execution/drive.mjs +1 -1
- package/lib/execution/effects.mjs +282 -24
- package/lib/execution/effects.test.mjs +112 -0
- package/lib/execution/index.mjs +1 -0
- package/lib/execution/intake.mjs +43 -9
- package/lib/execution/intake.test.mjs +46 -0
- package/lib/execution/pipeline.mjs +5 -0
- package/lib/execution/surface-policy.mjs +80 -30
- package/lib/goals/classify.mjs +49 -5
- package/lib/goals/classify.test.mjs +58 -0
- package/lib/goals/collaborate.mjs +131 -17
- package/lib/goals/collaborate.test.mjs +16 -4
- package/lib/goals/loop.mjs +160 -9
- package/lib/goals/loop.test.mjs +129 -3
- package/lib/kpi-sensors.mjs +666 -0
- package/lib/kpi-sensors.test.mjs +275 -0
- package/lib/kpi.mjs +23 -0
- package/lib/mandate/audit.mjs +3 -0
- package/lib/mandate/contract.mjs +277 -0
- package/lib/mandate/contract.test.mjs +185 -0
- package/lib/mandate/derive.mjs +49 -5
- package/lib/mandate/derive.test.mjs +7 -1
- package/lib/mandate/model.mjs +10 -1
- package/lib/mandate/model.test.mjs +22 -3
- package/lib/mandate/refresh.mjs +53 -5
- package/lib/mandate/refresh.test.mjs +83 -1
- package/lib/org/doctor.mjs +66 -0
- package/lib/org/doctor.test.mjs +73 -1
- package/lib/org/inbound/directedness.mjs +119 -1
- package/lib/org/inbound/directedness.test.mjs +67 -0
- package/lib/org/inbound/facts.mjs +132 -9
- package/lib/org/inbound/facts.test.mjs +96 -0
- package/lib/org/inbound/hydrate.mjs +40 -0
- package/lib/org/inbound/index.test.mjs +83 -0
- package/lib/org/inbound/project.mjs +8 -0
- package/lib/org/inbound/surfaces.mjs +20 -0
- package/lib/org/param-contract.mjs +16 -2
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +214 -2
- package/lib/org/protocol.test.mjs +11 -2
- package/lib/org/push.mjs +213 -49
- package/lib/org/push.test.mjs +112 -10
- package/lib/plan/compile.mjs +85 -8
- package/lib/plan/compile.test.mjs +82 -0
- package/lib/plan/emit.test.mjs +6 -1
- package/lib/setup/sections/mandate.mjs +43 -1
- package/lib/subagents/schema.mjs +14 -2
- package/lib/subagents/schema.test.mjs +22 -0
- package/package.json +1 -1
- package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
- package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/ci/conformance-org-api.mjs +16 -0
- package/scripts/ci/journey-approval-escalation.mjs +341 -0
- package/scripts/daemon/agent-daemon.mjs +582 -28
- package/scripts/daemon/cadence-handlers.mjs +273 -17
- package/scripts/daemon/cadence-handlers.test.mjs +101 -0
- package/scripts/daemon/execution-ladder.test.mjs +430 -0
- package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
- package/scripts/daemon/maestro-daemon.mjs +53 -0
- package/scripts/daemon/prompt-builder.mjs +47 -0
- package/scripts/daemon/responder.mjs +70 -3
- package/scripts/poller/imap-client.mjs +20 -1
- package/scripts/poller/inbox-scan-poller.mjs +15 -0
- package/scripts/poller/utils.mjs +51 -0
- package/scripts/setup/generate-capability.mjs +120 -11
- package/scripts/setup/generate-capability.test.mjs +134 -0
- package/scripts/setup/generate-plan.mjs +6 -1
- package/scripts/setup/repair-subagent-frontmatter.mjs +231 -0
|
@@ -626,12 +626,37 @@ function writeMessagingCursor(agentRoot, cursor) {
|
|
|
626
626
|
}
|
|
627
627
|
|
|
628
628
|
/**
|
|
629
|
-
* messaging-inbound guard (guarded): poll the Cohort
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
629
|
+
* messaging-inbound guard (guarded): poll the Cohort org event ledger for
|
|
630
|
+
* everything DIRECTED AT this agent and ROUTE each one into the existing daemon
|
|
631
|
+
* inbox pipeline by writing a legacy inbox item under state/inbox/cohort/ — so
|
|
632
|
+
* the agent reacts to a Cohort @mention exactly like a Slack/Telegram one
|
|
633
|
+
* (classifier → directed-gate → responder → dispatcher).
|
|
634
|
+
*
|
|
635
|
+
* ── WIDE, NOT messaging+calling (2026-08) ──
|
|
636
|
+
* This used to call `lib/org/messaging.pullInbound`, which pins
|
|
637
|
+
* `family=messaging,calling` on its events URL and recognises exactly two things
|
|
638
|
+
* (a message in a private room I am in, a call in one of my rooms). Every other
|
|
639
|
+
* surface hq emits — board/task, files & doc comments, decisions, escalations,
|
|
640
|
+
* approvals, handoffs, calendar, email — was read off the wire and thrown away,
|
|
641
|
+
* so a task assigned to this agent reached NOBODY: hq's in-process responder
|
|
642
|
+
* stands down for any member whose daemon beat inside 100s (hq 4d762d30) and
|
|
643
|
+
* the daemon was structurally deaf to the family.
|
|
644
|
+
*
|
|
645
|
+
* The live path is now `lib/org/inbound.pullWideInbound`, which returns the SAME
|
|
646
|
+
* `{events, nextCursor}` MessageEvent contract (plus `stats`), so everything
|
|
647
|
+
* below — eventToInboxItem, writeInboxItem, the cursor discipline — is unchanged.
|
|
648
|
+
*
|
|
649
|
+
* ADDRESSING STAYS ACL-SAFE. The org `/v1/events` feed is org-wide and REDACTED
|
|
650
|
+
* by design: a frame carries `mentionCount`, never the mentioned ids. The wide
|
|
651
|
+
* reader therefore never widens a payload; it resolves "is this for me" by
|
|
652
|
+
* JOINING the redacted frame against the agent's OWN entitled reads
|
|
653
|
+
* (`messaging.channels`, `messaging.history.mentions`, `board.context`,
|
|
654
|
+
* `approval.get`, `files.get`, `calendar.list`). A room this seat is not in is
|
|
655
|
+
* never paged, never hydrated, never delivered.
|
|
656
|
+
*
|
|
657
|
+
* Set MESSAGING_INBOUND_WIDE=0 to fall back to the old two-family reader (an
|
|
658
|
+
* operator escape hatch for an hq that is misbehaving on a family) — the
|
|
659
|
+
* fallback is LOGGED, never silent.
|
|
635
660
|
*
|
|
636
661
|
* The reactive daemon OWNS inbox dispatch, so the default decision is `inline`
|
|
637
662
|
* (we route the items but don't escalate — escalating would double-respond, the
|
|
@@ -655,7 +680,20 @@ async function guardMessagingInbound({ event, agentRoot, log }, opts = {}) {
|
|
|
655
680
|
return { ok: true, decision: "inline", cadence: event.cadence, reason: "org messaging not configured", routed: 0 };
|
|
656
681
|
}
|
|
657
682
|
|
|
658
|
-
|
|
683
|
+
// WIDE by default. The narrow reader is kept reachable as an operator
|
|
684
|
+
// escape hatch, but taking it is a DEGRADATION and is logged as one.
|
|
685
|
+
const wide = process.env.MESSAGING_INBOUND_WIDE !== "0";
|
|
686
|
+
let pull = opts.pullImpl;
|
|
687
|
+
if (!pull) {
|
|
688
|
+
if (wide) {
|
|
689
|
+
pull = (await import("../../lib/org/inbound/index.mjs")).pullWideInbound;
|
|
690
|
+
} else {
|
|
691
|
+
pull = (await import("../../lib/org/messaging.mjs")).pullInbound;
|
|
692
|
+
if (typeof log === "function") {
|
|
693
|
+
log("warn", "[messaging-inbound] MESSAGING_INBOUND_WIDE=0 — using the narrow messaging+calling reader; board, task, calendar, escalation, approval, file and decision events WILL NOT arrive");
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
659
697
|
const eventToInboxItem =
|
|
660
698
|
opts.toInboxImpl || (await import("../../lib/channels/inbox-item.mjs")).eventToInboxItem;
|
|
661
699
|
const writeInboxItem = opts.writeImpl || (await import("../poller/utils.mjs")).writeInboxItem;
|
|
@@ -671,28 +709,70 @@ async function guardMessagingInbound({ event, agentRoot, log }, opts = {}) {
|
|
|
671
709
|
process.env.COHORT_AGENT_ID ||
|
|
672
710
|
(cfg && cfg.org && cfg.org.cohort && cfg.org.cohort.agentId) ||
|
|
673
711
|
undefined;
|
|
674
|
-
|
|
712
|
+
// Display names for prose-mention matching ("Isla, can you…" with no @).
|
|
713
|
+
// The wide reader only ever uses these to match text it was ALREADY entitled
|
|
714
|
+
// to read, so this cannot widen the aperture.
|
|
715
|
+
let myNames = opts.myNames;
|
|
716
|
+
if (myNames == null) {
|
|
717
|
+
try {
|
|
718
|
+
const { readIdentity } = await import("../../lib/org/registry.mjs");
|
|
719
|
+
const id = readIdentity(agentRoot) || {};
|
|
720
|
+
myNames = [id.firstName, id.fullName, id.preferredName].filter(
|
|
721
|
+
(n) => typeof n === "string" && n.trim(),
|
|
722
|
+
);
|
|
723
|
+
} catch { myNames = []; }
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const res = await pull({
|
|
727
|
+
cfg,
|
|
728
|
+
agentId,
|
|
729
|
+
cursor,
|
|
730
|
+
fetchImpl: opts.fetchImpl,
|
|
731
|
+
myNames,
|
|
732
|
+
// The wide reader logs every degradation through this; without it a failed
|
|
733
|
+
// roster read, a capped page or a dropped hydration is invisible.
|
|
734
|
+
log: typeof log === "function" ? log : undefined,
|
|
735
|
+
});
|
|
675
736
|
const events = (res && Array.isArray(res.events)) ? res.events : [];
|
|
676
737
|
|
|
677
738
|
let routed = 0;
|
|
739
|
+
const failed = [];
|
|
678
740
|
for (const ev of events) {
|
|
679
741
|
try {
|
|
680
742
|
const item = eventToInboxItem(ev);
|
|
681
743
|
writeInboxItem("cohort", item, agentRoot);
|
|
682
744
|
routed += 1;
|
|
683
|
-
} catch {
|
|
745
|
+
} catch (err) {
|
|
746
|
+
// One bad event must not drop the rest — but it must not vanish either.
|
|
747
|
+
// A silent catch here is exactly how six directed DMs went missing.
|
|
748
|
+
failed.push(`${(ev && ev.kind) || "?"}:${(ev && ev.message_id) || "?"} (${err && err.message ? err.message : String(err)})`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
if (failed.length && typeof log === "function") {
|
|
752
|
+
log("warn", `[messaging-inbound] ${failed.length} directed item(s) could NOT be written to the inbox: ${failed.slice(0, 5).join("; ")}`);
|
|
684
753
|
}
|
|
685
754
|
|
|
686
755
|
// Advance the cursor AFTER writing (at-least-once; writeInboxItem dedupes).
|
|
687
756
|
const next = res && res.nextCursor != null ? res.nextCursor : cursor;
|
|
688
757
|
if (next !== cursor) writeMessagingCursor(agentRoot, next);
|
|
689
758
|
|
|
690
|
-
|
|
691
|
-
|
|
759
|
+
// The wide reader hands back what it spent and what it dropped. Surfacing it
|
|
760
|
+
// is the difference between "the agent is quiet" and "the agent is deaf".
|
|
761
|
+
const stats = res && res.stats && typeof res.stats === "object" ? res.stats : null;
|
|
762
|
+
if (typeof log === "function") {
|
|
763
|
+
if (routed) {
|
|
764
|
+
const bySurface = stats && stats.bySurface
|
|
765
|
+
? Object.entries(stats.bySurface).map(([k, v]) => `${k}×${v}`).join(", ")
|
|
766
|
+
: "";
|
|
767
|
+
log("info", `[messaging-inbound] routed ${routed} Cohort item(s) into the inbox pipeline${bySurface ? ` (${bySurface})` : ""}`);
|
|
768
|
+
}
|
|
769
|
+
if (stats && Array.isArray(stats.degraded) && stats.degraded.length) {
|
|
770
|
+
log("warn", `[messaging-inbound] DEGRADED facts this tick: ${stats.degraded.join(", ")} — directedness for those surfaces failed CLOSED (items may have been dropped)`);
|
|
771
|
+
}
|
|
692
772
|
}
|
|
693
773
|
|
|
694
774
|
if (process.env.MESSAGING_INBOUND_ESCALATE === "1" && routed > 0) {
|
|
695
|
-
return { ok: true, decision: "escalate", cadence: event.cadence, reason: `${routed} directed item(s)`, routed };
|
|
775
|
+
return { ok: true, decision: "escalate", cadence: event.cadence, reason: `${routed} directed item(s)`, routed, stats };
|
|
696
776
|
}
|
|
697
777
|
return {
|
|
698
778
|
ok: true,
|
|
@@ -700,10 +780,19 @@ async function guardMessagingInbound({ event, agentRoot, log }, opts = {}) {
|
|
|
700
780
|
cadence: event.cadence,
|
|
701
781
|
reason: routed ? "routed to inbox (reactive-daemon-owns-dispatch)" : "no new directed items",
|
|
702
782
|
routed,
|
|
783
|
+
wide,
|
|
784
|
+
stats,
|
|
703
785
|
note: routed ? "Set MESSAGING_INBOUND_ESCALATE=1 to escalate instead of routing to the inbox." : undefined,
|
|
704
786
|
};
|
|
705
787
|
} catch (err) {
|
|
706
|
-
|
|
788
|
+
// FAIL-OPEN IS FINE; SILENT IS NOT. A throw here means the agent received
|
|
789
|
+
// NOTHING this tick — the guard returning `ok:true` used to make that read
|
|
790
|
+
// as a healthy quiet org in every log and dashboard.
|
|
791
|
+
const message = err && err.message ? err.message : String(err);
|
|
792
|
+
if (typeof log === "function") {
|
|
793
|
+
log("error", `[messaging-inbound] pull FAILED (${message}) — 0 items routed this tick; nothing arrived from ANY surface`);
|
|
794
|
+
}
|
|
795
|
+
return { ok: true, decision: "inline", cadence: event.cadence, reason: "messaging-inbound-error", routed: 0, error: message };
|
|
707
796
|
}
|
|
708
797
|
}
|
|
709
798
|
|
|
@@ -943,17 +1032,69 @@ async function guardBrandSteward({ event, agentRoot, log }, opts = {}) {
|
|
|
943
1032
|
* @param {object} ctx - { event, agentRoot, log }
|
|
944
1033
|
* @param {object} [opts] - injectable seams: { runImpl, refreshImpl, cfg, enforcement, now, fetchImpl }
|
|
945
1034
|
*/
|
|
1035
|
+
/** Keep ONLY the org credential. See the note at step 0 — a resolver's extra
|
|
1036
|
+
* keys (notably `agentRoot`) must never reach a deps bag by accident. */
|
|
1037
|
+
function credsOnly(c) {
|
|
1038
|
+
return c ? { base: c.base || "", token: c.token || "", orgId: c.orgId || "" } : c;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
946
1041
|
async function guardGoalSteward({ event, agentRoot, log }, opts = {}) {
|
|
947
1042
|
const cadence = event && event.cadence ? event.cadence : "goal-steward";
|
|
948
1043
|
const say = typeof log === "function" ? log : () => {};
|
|
949
1044
|
try {
|
|
1045
|
+
// 0. THE ORG CREDENTIAL. Everything below that talks to hq needs it, and
|
|
1046
|
+
// nothing here used to resolve it.
|
|
1047
|
+
//
|
|
1048
|
+
// `refreshMandate`'s default callImpl is `lib/org/client.call(method,
|
|
1049
|
+
// params, {base, token, orgId})` — and `client.mjs` does NOT fall back to
|
|
1050
|
+
// the environment for `base`. Calling it with `{fetchImpl, log, now}`
|
|
1051
|
+
// (which is what this guard did) produced
|
|
1052
|
+
// `{ok:false, error:{code:"BAD_REQUEST", message:"missing base"}}` WITHOUT
|
|
1053
|
+
// a network round-trip, on every tick, forever. `fetchSnapshot` mapped
|
|
1054
|
+
// that to `call-failed:BAD_REQUEST` and `refreshMandate` fell back to the
|
|
1055
|
+
// local derivation — where every objective is `state:'proposed'`, which
|
|
1056
|
+
// compiles no obligations and admits no backlog.
|
|
1057
|
+
//
|
|
1058
|
+
// Net effect, verified live: the goal-steward cadence had NEVER once read
|
|
1059
|
+
// an adopted mandate out of hq. It could not create a single item, and the
|
|
1060
|
+
// only symptom was one warn line that read like an outage.
|
|
1061
|
+
// NARROWED to the three credential fields ON PURPOSE. `resolveOrgToolConfig`
|
|
1062
|
+
// also returns `agentRoot` (its own resolution, which is NOT necessarily this
|
|
1063
|
+
// cadence's root), and spreading the whole object into `runGoalSteward`
|
|
1064
|
+
// silently re-pointed the loop at a different agent directory — it measured
|
|
1065
|
+
// and would have written under the wrong root. Caught in the live run; never
|
|
1066
|
+
// spread a resolver's output into a deps bag wholesale.
|
|
1067
|
+
let conn = opts.conn === undefined ? undefined : credsOnly(opts.conn);
|
|
1068
|
+
if (conn === undefined) {
|
|
1069
|
+
try {
|
|
1070
|
+
const { resolveOrgToolConfig } = await import("../../lib/org/tool-surface.mjs");
|
|
1071
|
+
conn = credsOnly(resolveOrgToolConfig({ agentRoot, orgConfig: opts.cfg }));
|
|
1072
|
+
} catch (err) {
|
|
1073
|
+
conn = null;
|
|
1074
|
+
say("error", `[goal-steward] could not resolve the org connection (${err && err.message ? err.message : err}) — the mandate cannot be refreshed and no sample can be mirrored to hq this tick`);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
if (conn && (!conn.base || !conn.token)) {
|
|
1078
|
+
say(
|
|
1079
|
+
"warn",
|
|
1080
|
+
`[goal-steward] no org credential for this seat (base=${conn.base ? "set" : "MISSING"} token=${conn.token ? "set" : "MISSING"}) — mandate.get and mandate.sample will both fail; the loop runs entirely on local state`,
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
const memberId = opts.memberId || (conn && conn.memberId) || process.env.COHORT_AGENT_ID || null;
|
|
1084
|
+
|
|
950
1085
|
// 1. Refresh the mandate cache first — a goal loop running off a week-old
|
|
951
1086
|
// mandate is worse than one that does not run. Fail-open: the loop's own
|
|
952
1087
|
// staleness ladder is what actually decides what it may do.
|
|
953
1088
|
if (opts.refreshImpl !== null) {
|
|
954
1089
|
try {
|
|
955
1090
|
const refresh = opts.refreshImpl || (await import("../../lib/mandate/refresh.mjs")).refreshMandate;
|
|
956
|
-
const r = await refresh(agentRoot, {
|
|
1091
|
+
const r = await refresh(agentRoot, {
|
|
1092
|
+
...(conn || {}),
|
|
1093
|
+
memberId,
|
|
1094
|
+
fetchImpl: opts.fetchImpl,
|
|
1095
|
+
log: say,
|
|
1096
|
+
now: opts.now,
|
|
1097
|
+
});
|
|
957
1098
|
if (r && r.ok === false) {
|
|
958
1099
|
say("warn", `[goal-steward] mandate refresh failed (${r.reason}) — running on the cached mandate`);
|
|
959
1100
|
}
|
|
@@ -978,8 +1119,98 @@ async function guardGoalSteward({ event, agentRoot, log }, opts = {}) {
|
|
|
978
1119
|
}
|
|
979
1120
|
}
|
|
980
1121
|
|
|
1122
|
+
// 2b. THE SENSOR REGISTRY. Without this the loop measures nothing: every
|
|
1123
|
+
// objective's sensor lookup misses, `measureKpi` returns
|
|
1124
|
+
// {ok:false,"no-implementation"}, no sample is recorded, every gap is
|
|
1125
|
+
// null, and admission's honesty gate (source ∈ method|human) is
|
|
1126
|
+
// unsatisfiable by construction — D3 could not create a single item.
|
|
1127
|
+
// `reachableCapabilities` returns undefined (not an empty Set) when the
|
|
1128
|
+
// manifest is missing, so a broken manifest degrades to "measure without
|
|
1129
|
+
// the reachability check" instead of refusing every objective.
|
|
1130
|
+
let sensors = opts.sensors;
|
|
1131
|
+
let reachable = opts.reachable;
|
|
1132
|
+
if (sensors === undefined || reachable === undefined) {
|
|
1133
|
+
try {
|
|
1134
|
+
const mod = await import("../../lib/kpi-sensors.mjs");
|
|
1135
|
+
if (sensors === undefined) {
|
|
1136
|
+
sensors = mod.buildSensorRegistry({ agentRoot, log: say, now: opts.now, fetchImpl: opts.fetchImpl });
|
|
1137
|
+
}
|
|
1138
|
+
if (reachable === undefined) reachable = mod.reachableCapabilities(agentRoot, { log: say });
|
|
1139
|
+
} catch (err) {
|
|
1140
|
+
say(
|
|
1141
|
+
"error",
|
|
1142
|
+
`[goal-steward] could not build the sensor registry (${err && err.message ? err.message : err}) — every objective will report no-implementation and NOTHING will be measured this tick`,
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// 2c. THE RETURN LEG. `lib/goals/loop.mjs` calls `deps.mirrorSample` for
|
|
1148
|
+
// every sample it records, and hq implements the receiving end in full
|
|
1149
|
+
// (`src/server/methods/mandate/sample.ts`: server-decided `source`
|
|
1150
|
+
// honesty gate, `(orgId, objectiveId, window)` idempotency, gap
|
|
1151
|
+
// computation, chain append). `mirrorSample` had ZERO callers in either
|
|
1152
|
+
// repo — so every number this loop measured stayed on one laptop's disk
|
|
1153
|
+
// and hq's Objective.value / KpiSample series were never written by an
|
|
1154
|
+
// agent at all. The loop learned; the org did not.
|
|
1155
|
+
//
|
|
1156
|
+
// `objectiveId` is hq's own row id, carried on the cached snapshot by
|
|
1157
|
+
// `projectObjective`. A locally-DERIVED objective has no id — those are
|
|
1158
|
+
// `state:'proposed'` and never reach here, but the guard is explicit
|
|
1159
|
+
// rather than sending a request hq would reject.
|
|
1160
|
+
//
|
|
1161
|
+
// Best-effort by construction: the loop already wraps this in try/catch
|
|
1162
|
+
// and logs, and the local measurement is written BEFORE the mirror is
|
|
1163
|
+
// attempted, so an unreachable hq loses nothing.
|
|
1164
|
+
let mirrorSample = opts.mirrorSample;
|
|
1165
|
+
if (mirrorSample === undefined) {
|
|
1166
|
+
if (!conn || !conn.base || !conn.token) {
|
|
1167
|
+
mirrorSample = null;
|
|
1168
|
+
say("warn", "[goal-steward] no org credential — measurements will be recorded LOCALLY ONLY and hq's KPI series will not move");
|
|
1169
|
+
} else {
|
|
1170
|
+
const { call } = await import("../../lib/org/client.mjs");
|
|
1171
|
+
mirrorSample = async ({ objective, value, window, source, evidence }) => {
|
|
1172
|
+
if (!objective || !objective.id) {
|
|
1173
|
+
say("info", `[goal-steward] "${objective && objective.key}" has no hq objective id (locally-derived) — sample kept local`);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
const frame = await call(
|
|
1177
|
+
"mandate.sample",
|
|
1178
|
+
{ objectiveId: objective.id, value, window, evidence: evidence || null },
|
|
1179
|
+
{ ...conn, fetchImpl: opts.fetchImpl },
|
|
1180
|
+
);
|
|
1181
|
+
if (!frame || !frame.ok) {
|
|
1182
|
+
say(
|
|
1183
|
+
"warn",
|
|
1184
|
+
`[goal-steward] mandate.sample rejected "${objective.key}" @ ${window}: ${(frame && frame.error && frame.error.code) || "unknown"}${frame && frame.error && frame.error.message ? ` — ${frame.error.message}` : ""} — the local series is intact`,
|
|
1185
|
+
);
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
const got = frame.result || {};
|
|
1189
|
+
// hq decides `source` itself and says so when it downgraded us. A
|
|
1190
|
+
// silent downgrade is how a seat keeps reporting fabricated `method`
|
|
1191
|
+
// samples forever without learning its sensor is not wired up.
|
|
1192
|
+
if (got.sourceNote) {
|
|
1193
|
+
say("warn", `[goal-steward] hq downgraded the sample source for "${objective.key}" (asked ${source}, got ${got.source}): ${got.sourceNote}`);
|
|
1194
|
+
} else {
|
|
1195
|
+
say("info", `[goal-steward] mirrored "${objective.key}" = ${value} @ ${window} to hq (source ${got.source || source})`);
|
|
1196
|
+
}
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
981
1201
|
const run = opts.runImpl || (await import("../../lib/goals/loop.mjs")).runGoalSteward;
|
|
982
|
-
const report = await run({
|
|
1202
|
+
const report = await run({
|
|
1203
|
+
agentRoot,
|
|
1204
|
+
log: say,
|
|
1205
|
+
enforcement,
|
|
1206
|
+
now: opts.now,
|
|
1207
|
+
sensors,
|
|
1208
|
+
reachable,
|
|
1209
|
+
memberId,
|
|
1210
|
+
...(conn || {}),
|
|
1211
|
+
...(mirrorSample ? { mirrorSample } : {}),
|
|
1212
|
+
...(opts.runDeps || {}),
|
|
1213
|
+
});
|
|
983
1214
|
|
|
984
1215
|
if (!report || report.ok === false) {
|
|
985
1216
|
// Not an error for the bus: "no mandate" is the normal pre-adoption state.
|
|
@@ -998,6 +1229,7 @@ async function guardGoalSteward({ event, agentRoot, log }, opts = {}) {
|
|
|
998
1229
|
const rejected = (report.rejected || []).length;
|
|
999
1230
|
const escalations = (report.escalations || []).length;
|
|
1000
1231
|
const drift = (report.drift || []).length;
|
|
1232
|
+
const scored = (report.interventions || []).reduce((n, i) => n + (i.recorded || 0), 0);
|
|
1001
1233
|
|
|
1002
1234
|
if (report.skipped) {
|
|
1003
1235
|
return { ok: true, decision: "inline", cadence, reason: report.skipped, measured, admitted, drift };
|
|
@@ -1005,7 +1237,7 @@ async function guardGoalSteward({ event, agentRoot, log }, opts = {}) {
|
|
|
1005
1237
|
|
|
1006
1238
|
say(
|
|
1007
1239
|
"info",
|
|
1008
|
-
`[goal-steward] ${enforcement}: measured ${measured}, admitted ${admitted}, rejected ${rejected}, escalations ${escalations}, drift ${drift}`
|
|
1240
|
+
`[goal-steward] ${enforcement}: measured ${measured}, interventions scored ${scored}, admitted ${admitted}, rejected ${rejected}, escalations ${escalations}, drift ${drift}`
|
|
1009
1241
|
);
|
|
1010
1242
|
|
|
1011
1243
|
// 3. Escalate only when a model would actually add something: an open gap
|
|
@@ -1025,7 +1257,7 @@ async function guardGoalSteward({ event, agentRoot, log }, opts = {}) {
|
|
|
1025
1257
|
};
|
|
1026
1258
|
}
|
|
1027
1259
|
|
|
1028
|
-
return { ok: true, decision: "inline", cadence, measured, admitted, rejected, escalations, drift, enforcement };
|
|
1260
|
+
return { ok: true, decision: "inline", cadence, measured, admitted, rejected, escalations, drift, scored, enforcement };
|
|
1029
1261
|
} catch (err) {
|
|
1030
1262
|
// Fail-open but LOUD: the daemon keeps running, the failure is on the record.
|
|
1031
1263
|
say("error", `[goal-steward] guard threw: ${err && err.message ? err.message : err}`);
|
|
@@ -1255,6 +1487,29 @@ function loadConfigRegistry() {
|
|
|
1255
1487
|
* availability. The hardcoded CADENCE_REGISTRY wins; the config-driven archetype
|
|
1256
1488
|
* cadences (config/.cadence-registry.json) fill in the rest.
|
|
1257
1489
|
*/
|
|
1490
|
+
/**
|
|
1491
|
+
* Cadences already announced as unmetered. The warning is worth saying ONCE per
|
|
1492
|
+
* cadence per process — `getCadenceDef` is called on every tick, and a line per
|
|
1493
|
+
* tick is how a real signal becomes log noise nobody reads.
|
|
1494
|
+
*/
|
|
1495
|
+
const _unmeteredWarned = new Set();
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* A plan-derived cadence arriving with no budget is the LAST hop of the mandate
|
|
1499
|
+
* body joint, and it used to be its quietest: `budgetCents` was simply not set,
|
|
1500
|
+
* the sub-session spawned with no spend cap, and nothing anywhere said so. The
|
|
1501
|
+
* cap genuinely cannot be invented here — but its absence is now stated.
|
|
1502
|
+
*/
|
|
1503
|
+
function _warnUnmetered(name, obligationKey) {
|
|
1504
|
+
if (_unmeteredWarned.has(name)) return;
|
|
1505
|
+
_unmeteredWarned.add(name);
|
|
1506
|
+
console.warn(
|
|
1507
|
+
`[cadence] "${name}"${obligationKey ? ` (${obligationKey})` : ""} carries no budgetCents — ` +
|
|
1508
|
+
"hq published no funding for the objective behind it, so this cadence spawns with NO spend cap. " +
|
|
1509
|
+
"Fund the seat (Employee.payBasis.meteredBudget) or accept it as unmetered."
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1258
1513
|
export function getCadenceDef(name) {
|
|
1259
1514
|
if (CADENCE_REGISTRY[name]) return CADENCE_REGISTRY[name];
|
|
1260
1515
|
const cfg = loadConfigRegistry()[name];
|
|
@@ -1268,6 +1523,7 @@ export function getCadenceDef(name) {
|
|
|
1268
1523
|
if (Array.isArray(cfg.allowedTools) && cfg.allowedTools.length) def.allowedTools = cfg.allowedTools;
|
|
1269
1524
|
if (cfg.guardModule) def.guardModule = cfg.guardModule;
|
|
1270
1525
|
if (Number.isFinite(cfg.budgetCents)) def.budgetCents = cfg.budgetCents;
|
|
1526
|
+
else _warnUnmetered(name, cfg.obligationKey);
|
|
1271
1527
|
if (cfg.obligationKey) def.obligationKey = cfg.obligationKey;
|
|
1272
1528
|
return def;
|
|
1273
1529
|
}
|
|
@@ -615,3 +615,104 @@ test("brand-steward is registered guarded and cannot reach the admin foundation
|
|
|
615
615
|
}
|
|
616
616
|
assert.ok(def.allowedTools.includes("design_propose_change"), "proposing IS the lane");
|
|
617
617
|
});
|
|
618
|
+
|
|
619
|
+
// ---------------------------------------------------------------------------
|
|
620
|
+
// messaging-inbound is WIDE (JOINT 3) — the LIVE default, not an injected stub
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* The regression this pins: `guardMessagingInbound` used to default to
|
|
625
|
+
* `lib/org/messaging.pullInbound`, whose events URL hardcodes
|
|
626
|
+
* `family=messaging,calling`. Board, calendar, escalation, approval, decision,
|
|
627
|
+
* files and handoff events were read off the wire and discarded, so a task
|
|
628
|
+
* assigned to this agent reached NOBODY — hq stands down for a live daemon
|
|
629
|
+
* (4d762d30) and the daemon was structurally deaf to the family.
|
|
630
|
+
*
|
|
631
|
+
* No `pullImpl` here ON PURPOSE: the point is which reader the REAL default
|
|
632
|
+
* resolves to. Only the network is faked.
|
|
633
|
+
*/
|
|
634
|
+
test("messaging-inbound (JOINT 3): the DEFAULT reader is wide — no family filter, and a board assignment arrives", async () => {
|
|
635
|
+
const root = tmpRoot();
|
|
636
|
+
const urls = [];
|
|
637
|
+
const written = [];
|
|
638
|
+
const ME = "member-me";
|
|
639
|
+
|
|
640
|
+
const json = (body) => ({
|
|
641
|
+
ok: true,
|
|
642
|
+
status: 200,
|
|
643
|
+
headers: { get: () => "application/json" },
|
|
644
|
+
text: async () => JSON.stringify(body),
|
|
645
|
+
json: async () => body,
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
const fetchImpl = async (url, init) => {
|
|
649
|
+
const u = String(url);
|
|
650
|
+
urls.push(`${(init && init.method) || "GET"} ${u}`);
|
|
651
|
+
if (u.includes("/events?")) {
|
|
652
|
+
return json({
|
|
653
|
+
events: [
|
|
654
|
+
{
|
|
655
|
+
seq: 501, family: "board", kind: "item.assigned", entity_id: "task-9",
|
|
656
|
+
actor: "member-boss", at: "2026-08-11T09:00:00Z",
|
|
657
|
+
// REDACTED BY DESIGN: no mention list, no recipient list.
|
|
658
|
+
payload: { actor: "member-boss", assignee: ME, assignedBy: "member-boss" },
|
|
659
|
+
},
|
|
660
|
+
],
|
|
661
|
+
nextCursor: 501,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
if (u.includes("board.context")) {
|
|
665
|
+
return json({ ok: true, result: { tasks: [{ id: "task-9", title: "Ship the transport joint", assigneeId: ME, col: "todo" }] } });
|
|
666
|
+
}
|
|
667
|
+
if (u.includes("messaging.channels")) return json({ ok: true, result: { channels: [] } });
|
|
668
|
+
return json({ ok: true, result: {} });
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
const res = await guardMessagingInbound(
|
|
672
|
+
{ event: { cadence: "messaging-inbound" }, agentRoot: root, log: () => {} },
|
|
673
|
+
{
|
|
674
|
+
cfg: { org: { cohort: { enabled: true, base: "https://x.test", token: "t" } } },
|
|
675
|
+
agentId: ME,
|
|
676
|
+
fetchImpl,
|
|
677
|
+
toInboxImpl: (e) => ({ id: e.id, service: "cohort", kind: e.kind, content: e.text }),
|
|
678
|
+
writeImpl: (service, item) => written.push({ service, item }),
|
|
679
|
+
},
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
const eventsUrl = urls.find((u) => u.includes("/events?"));
|
|
683
|
+
assert.ok(eventsUrl, "the ledger was read");
|
|
684
|
+
assert.ok(
|
|
685
|
+
!/family=/.test(eventsUrl),
|
|
686
|
+
`the events read must NOT pin a family — got ${eventsUrl}`,
|
|
687
|
+
);
|
|
688
|
+
assert.equal(res.wide, true, "the wide reader is the default, not an opt-in");
|
|
689
|
+
assert.equal(res.routed, 1, "a board assignment reaches the inbox pipeline");
|
|
690
|
+
assert.equal(written[0].item.kind, "task_assigned", "and carries the routing hint the classifier needs");
|
|
691
|
+
assert.equal(res.stats.bySurface.task_assigned, 1);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test("messaging-inbound (JOINT 3): MESSAGING_INBOUND_WIDE=0 falls back to the narrow reader — and SAYS SO", async () => {
|
|
695
|
+
const prev = process.env.MESSAGING_INBOUND_WIDE;
|
|
696
|
+
process.env.MESSAGING_INBOUND_WIDE = "0";
|
|
697
|
+
const logs = [];
|
|
698
|
+
try {
|
|
699
|
+
const res = await guardMessagingInbound(
|
|
700
|
+
{ event: { cadence: "messaging-inbound" }, agentRoot: tmpRoot(), log: (lvl, m) => logs.push(`${lvl}:${m}`) },
|
|
701
|
+
{
|
|
702
|
+
cfg: { org: { cohort: { enabled: true, base: "https://x.test", token: "t" } } },
|
|
703
|
+
agentId: "member-me",
|
|
704
|
+
// The narrow reader pins family=messaging,calling on its URL; assert the
|
|
705
|
+
// degradation is announced rather than inferred from an empty inbox.
|
|
706
|
+
fetchImpl: async () => ({ ok: true, status: 200, headers: { get: () => "application/json" }, text: async () => JSON.stringify({ events: [], nextCursor: 0 }), json: async () => ({ events: [], nextCursor: 0 }) }),
|
|
707
|
+
},
|
|
708
|
+
);
|
|
709
|
+
assert.equal(res.wide, false);
|
|
710
|
+
assert.ok(
|
|
711
|
+
logs.some((l) => l.startsWith("warn:") && /WILL NOT arrive/.test(l)),
|
|
712
|
+
`a silent narrowing is the bug; got ${JSON.stringify(logs)}`,
|
|
713
|
+
);
|
|
714
|
+
} finally {
|
|
715
|
+
if (prev === undefined) delete process.env.MESSAGING_INBOUND_WIDE;
|
|
716
|
+
else process.env.MESSAGING_INBOUND_WIDE = prev;
|
|
717
|
+
}
|
|
718
|
+
});
|