@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
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model.test.mjs — the pillar spine: validation, provenance, anti-Goodhart.
|
|
3
|
+
* Run: `node --test lib/mandate/model.test.mjs`
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
objectivesOf,
|
|
13
|
+
validateMandateBody,
|
|
14
|
+
buildTree,
|
|
15
|
+
ancestry,
|
|
16
|
+
pillarFor,
|
|
17
|
+
provenanceOf,
|
|
18
|
+
adoptedObjectives,
|
|
19
|
+
isSelfAdopted,
|
|
20
|
+
collaboratorsOf,
|
|
21
|
+
} from "./model.mjs";
|
|
22
|
+
|
|
23
|
+
const ME = "mem_self";
|
|
24
|
+
const BOSS = "mem_boss";
|
|
25
|
+
|
|
26
|
+
function tree(over = {}) {
|
|
27
|
+
return {
|
|
28
|
+
memberId: ME,
|
|
29
|
+
objectives: [
|
|
30
|
+
{ key: "growth", kind: "PILLAR", parentKey: null, charterSectionId: "cs_12", state: "active", adoptedById: BOSS, source: { origin: "charter", ref: "cs_12" } },
|
|
31
|
+
{
|
|
32
|
+
key: "pipeline",
|
|
33
|
+
kind: "OBJECTIVE",
|
|
34
|
+
parentKey: "growth",
|
|
35
|
+
state: "active",
|
|
36
|
+
metric: "pipeline_coverage_x",
|
|
37
|
+
target: 3,
|
|
38
|
+
tolerance: 0.2,
|
|
39
|
+
direction: "up",
|
|
40
|
+
cadence: "weekly",
|
|
41
|
+
sensor: { capability: "crm_list_deals", params: {}, source: "method" },
|
|
42
|
+
adoptedById: BOSS,
|
|
43
|
+
collaborators: [{ memberId: "mem_peer", role: "REVIEWER" }],
|
|
44
|
+
...over.pipeline,
|
|
45
|
+
},
|
|
46
|
+
{ key: "win-rate", kind: "GOAL", parentKey: "pipeline", state: "proposed", metric: "win_rate", target: null, cadence: "monthly" },
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test("objectivesOf accepts a cache record, a bare body, or an array", () => {
|
|
52
|
+
const body = tree();
|
|
53
|
+
assert.equal(objectivesOf(body).length, 3);
|
|
54
|
+
assert.equal(objectivesOf({ body }).length, 3);
|
|
55
|
+
assert.equal(objectivesOf(body.objectives).length, 3);
|
|
56
|
+
assert.deepEqual(objectivesOf(null), []);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a well-formed tree validates and yields exactly the adopted, measurable nodes", () => {
|
|
60
|
+
const body = tree();
|
|
61
|
+
const v = validateMandateBody(body, { ownerMemberId: ME });
|
|
62
|
+
assert.equal(v.ok, true, v.errors.join("; "));
|
|
63
|
+
const adopted = adoptedObjectives(body, { ownerMemberId: ME, badKeys: v.badKeys });
|
|
64
|
+
assert.deepEqual(adopted.map((o) => o.key), ["pipeline"], "the PILLAR is not measurable; the GOAL is only proposed");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("ANTI-GOODHART: a seat that adopted its own objective is an ERROR and drives no work", () => {
|
|
68
|
+
const body = tree({ pipeline: { adoptedById: ME } });
|
|
69
|
+
const v = validateMandateBody(body, { ownerMemberId: ME });
|
|
70
|
+
assert.equal(v.ok, false);
|
|
71
|
+
assert.match(v.errors.join(" "), /self-adopted/);
|
|
72
|
+
assert.ok(v.badKeys.includes("pipeline"));
|
|
73
|
+
assert.deepEqual(adoptedObjectives(body, { ownerMemberId: ME }), [], "a self-adopted objective may never drive work");
|
|
74
|
+
assert.equal(isSelfAdopted(body.objectives[1], ME), true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("an active objective with no target / no metric fails closed", () => {
|
|
78
|
+
const body = tree({ pipeline: { target: null, metric: null } });
|
|
79
|
+
const v = validateMandateBody(body, { ownerMemberId: ME });
|
|
80
|
+
assert.equal(v.ok, false);
|
|
81
|
+
assert.match(v.errors.join(" "), /no metric/);
|
|
82
|
+
assert.match(v.errors.join(" "), /no numeric target/);
|
|
83
|
+
assert.ok(v.badKeys.includes("pipeline"));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("structural errors: bad kind, missing parent, illegal parentage, duplicate key, cycle", () => {
|
|
87
|
+
const v = validateMandateBody({
|
|
88
|
+
memberId: ME,
|
|
89
|
+
objectives: [
|
|
90
|
+
{ key: "a", kind: "WIDGET", state: "proposed" },
|
|
91
|
+
{ key: "b", kind: "GOAL", parentKey: "nope", state: "proposed" },
|
|
92
|
+
{ key: "c", kind: "PILLAR", parentKey: "d", state: "proposed" },
|
|
93
|
+
{ key: "d", kind: "PILLAR", parentKey: null, state: "proposed" },
|
|
94
|
+
{ key: "d", kind: "PILLAR", parentKey: null, state: "proposed" },
|
|
95
|
+
{ key: "x", kind: "OBJECTIVE", parentKey: "y", state: "proposed" },
|
|
96
|
+
{ key: "y", kind: "OBJECTIVE", parentKey: "x", state: "proposed" },
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
const joined = v.errors.join(" | ");
|
|
100
|
+
assert.match(joined, /unknown kind "WIDGET"/);
|
|
101
|
+
assert.match(joined, /parent "nope" not found/);
|
|
102
|
+
assert.match(joined, /PILLAR may not hang from a PILLAR/);
|
|
103
|
+
assert.match(joined, /duplicate key "d"/);
|
|
104
|
+
assert.match(joined, /cycle in the objective tree/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("an active objective with no adoptedById WARNS (unverifiable) but is not an error", () => {
|
|
108
|
+
const body = tree({ pipeline: { adoptedById: null } });
|
|
109
|
+
const v = validateMandateBody(body, { ownerMemberId: ME });
|
|
110
|
+
assert.equal(v.ok, true, v.errors.join("; "));
|
|
111
|
+
assert.match(v.warnings.join(" "), /carries no adoptedById/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("provenance walks node → objective → pillar → charter clause, offline", () => {
|
|
115
|
+
const body = tree();
|
|
116
|
+
assert.deepEqual(ancestry(body, "win-rate").map((n) => n.key), ["win-rate", "pipeline", "growth"]);
|
|
117
|
+
assert.equal(pillarFor(body, "win-rate").key, "growth");
|
|
118
|
+
const p = provenanceOf(body, "pipeline");
|
|
119
|
+
assert.deepEqual(p.chain, ["pipeline", "growth"]);
|
|
120
|
+
assert.equal(p.clause, "cs_12");
|
|
121
|
+
assert.equal(p.adoptedBy, BOSS);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("buildTree nests children and surfaces orphans as roots rather than dropping them", () => {
|
|
125
|
+
const roots = buildTree({ objectives: [
|
|
126
|
+
{ key: "p", kind: "PILLAR" },
|
|
127
|
+
{ key: "o", kind: "OBJECTIVE", parentKey: "p" },
|
|
128
|
+
{ key: "orphan", kind: "GOAL", parentKey: "vanished" },
|
|
129
|
+
] });
|
|
130
|
+
assert.deepEqual(roots.map((r) => r.key).sort(), ["orphan", "p"]);
|
|
131
|
+
assert.equal(roots.find((r) => r.key === "p").children[0].key, "o");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("collaboratorsOf normalises strings, objects and casing", () => {
|
|
135
|
+
assert.deepEqual(collaboratorsOf({ collaborators: ["m1", { memberId: "m2", role: "reviewer" }, { id: "m3" }, null] }), [
|
|
136
|
+
{ memberId: "m1", role: "CONTRIBUTOR" },
|
|
137
|
+
{ memberId: "m2", role: "REVIEWER" },
|
|
138
|
+
{ memberId: "m3", role: "CONTRIBUTOR" },
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("badKeys are excluded from adoptedObjectives so a broken node cannot drive work", () => {
|
|
143
|
+
const body = tree();
|
|
144
|
+
assert.deepEqual(adoptedObjectives(body, { ownerMemberId: ME, badKeys: ["pipeline"] }), []);
|
|
145
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/mandate/refresh.mjs — "derived at init from charter/role, refreshed on a
|
|
3
|
+
* cadence" — the one function both callers use.
|
|
4
|
+
*
|
|
5
|
+
* Init (`lib/setup/sections/mandate.mjs`) and the `goal-steward` cadence want
|
|
6
|
+
* the same thing with different fallbacks:
|
|
7
|
+
*
|
|
8
|
+
* ONLINE `mandate.get` returns the seat's ADOPTED snapshot → cache it with
|
|
9
|
+
* `source:"server"`. That snapshot is authoritative; the local
|
|
10
|
+
* derivation is not consulted.
|
|
11
|
+
* OFFLINE no org, an org that does not carry the `mandate` family yet, or a
|
|
12
|
+
* failed call → derive locally from charter + strategy + archetype
|
|
13
|
+
* (`lib/mandate/derive.mjs`) and cache it with `source:"local"`.
|
|
14
|
+
* EVERY derived objective is `state:'proposed'`, and proposed
|
|
15
|
+
* objectives compile no obligations and admit no backlog — so the
|
|
16
|
+
* offline path can never cause the agent to invent its own targets.
|
|
17
|
+
*
|
|
18
|
+
* The degradation is always REPORTED (`{degraded, reason}` plus a log line).
|
|
19
|
+
* The specific case worth naming: maestro's vendored `lib/org/protocol.mjs` does
|
|
20
|
+
* not yet carry the `mandate` family, so `client.call("mandate.get", …)` returns
|
|
21
|
+
* `NOT_FOUND: unknown method` WITHOUT touching the network. That is a vendoring
|
|
22
|
+
* gap, not an outage, and it is logged as such rather than looking like a dead
|
|
23
|
+
* server.
|
|
24
|
+
*
|
|
25
|
+
* @module lib/mandate/refresh
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
"use strict";
|
|
29
|
+
|
|
30
|
+
import { readCache, writeCache, stalenessTier, checksumOf } from "./cache.mjs";
|
|
31
|
+
import { deriveMandate, toMandateBody } from "./derive.mjs";
|
|
32
|
+
import { validateMandateBody } from "./model.mjs";
|
|
33
|
+
|
|
34
|
+
/** The protocol method that returns a seat's adopted snapshot. */
|
|
35
|
+
export const GET_METHOD = "mandate.get";
|
|
36
|
+
|
|
37
|
+
function logOf(deps) {
|
|
38
|
+
return deps && typeof deps.log === "function" ? deps.log : () => {};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fetch the adopted snapshot from the org. Never throws; every failure mode is
|
|
43
|
+
* a distinct, reportable reason.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} deps - { callImpl, base, token, orgId, fetchImpl, memberId, log }
|
|
46
|
+
* @returns {Promise<{ok:boolean, snapshot:object|null, reason:string}>}
|
|
47
|
+
*/
|
|
48
|
+
export async function fetchSnapshot(deps = {}) {
|
|
49
|
+
const callImpl =
|
|
50
|
+
typeof deps.callImpl === "function"
|
|
51
|
+
? deps.callImpl
|
|
52
|
+
: async (method, params) => {
|
|
53
|
+
const { call } = await import("../org/client.mjs");
|
|
54
|
+
return call(method, params, { base: deps.base, token: deps.token, orgId: deps.orgId, fetchImpl: deps.fetchImpl });
|
|
55
|
+
};
|
|
56
|
+
let frame;
|
|
57
|
+
try {
|
|
58
|
+
frame = await callImpl(GET_METHOD, { memberId: deps.memberId || undefined });
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return { ok: false, snapshot: null, reason: `call-threw:${err && err.message ? err.message : err}` };
|
|
61
|
+
}
|
|
62
|
+
if (frame && frame.ok) {
|
|
63
|
+
const r = frame.result || {};
|
|
64
|
+
return { ok: true, snapshot: { version: Number(r.version) || 0, checksum: r.checksum || null, body: r.body || r }, reason: "fetched" };
|
|
65
|
+
}
|
|
66
|
+
const code = (frame && frame.error && frame.error.code) || "unknown";
|
|
67
|
+
const msg = (frame && frame.error && frame.error.message) || "";
|
|
68
|
+
if (code === "NOT_FOUND" && /unknown method/i.test(msg)) {
|
|
69
|
+
return { ok: false, snapshot: null, reason: "protocol-missing-mandate-family" };
|
|
70
|
+
}
|
|
71
|
+
return { ok: false, snapshot: null, reason: `call-failed:${code}` };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Refresh (or first-time populate) the local mandate cache.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} agentRoot
|
|
78
|
+
* @param {object} [deps] - {
|
|
79
|
+
* now, log, memberId, callImpl?, base?, token?, orgId?, fetchImpl?,
|
|
80
|
+
* offline?:boolean, // skip the fetch entirely (init --headless)
|
|
81
|
+
* derivation?: { // inputs for the local fallback
|
|
82
|
+
* charterSections, strategy, kpiCategories, priorities, manifest, overrides
|
|
83
|
+
* }
|
|
84
|
+
* }
|
|
85
|
+
* @returns {Promise<{
|
|
86
|
+
* ok:boolean, source:'server'|'local'|'cache', degraded:boolean, reason:string,
|
|
87
|
+
* changed:boolean, tier:string, record:object|null, counts?:object, degradations?:object[]
|
|
88
|
+
* }>}
|
|
89
|
+
*/
|
|
90
|
+
export async function refreshMandate(agentRoot, deps = {}) {
|
|
91
|
+
const log = logOf(deps);
|
|
92
|
+
const prev = readCache(agentRoot);
|
|
93
|
+
const prevChecksum = prev && !prev.corrupt ? prev.checksum : null;
|
|
94
|
+
|
|
95
|
+
// --- online path ---------------------------------------------------------
|
|
96
|
+
if (!deps.offline) {
|
|
97
|
+
const got = await fetchSnapshot(deps);
|
|
98
|
+
if (got.ok && got.snapshot && got.snapshot.body) {
|
|
99
|
+
const body = got.snapshot.body;
|
|
100
|
+
const local = checksumOf(body);
|
|
101
|
+
if (got.snapshot.checksum && got.snapshot.checksum !== local) {
|
|
102
|
+
log(
|
|
103
|
+
"warn",
|
|
104
|
+
`[mandate] snapshot checksum mismatch (server=${String(got.snapshot.checksum).slice(0, 12)} local=${local.slice(0, 12)}) — caching anyway; re-vendor lib/org/protocol.mjs if this persists`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const record = writeCache(
|
|
108
|
+
agentRoot,
|
|
109
|
+
{ version: got.snapshot.version, checksum: local, body, source: "server" },
|
|
110
|
+
{ now: deps.now ? () => new Date(deps.now()).toISOString() : undefined }
|
|
111
|
+
);
|
|
112
|
+
const validation = validateMandateBody(record, { ownerMemberId: deps.memberId });
|
|
113
|
+
for (const e of validation.errors) log("warn", `[mandate] adopted snapshot is invalid: ${e}`);
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
source: "server",
|
|
117
|
+
degraded: false,
|
|
118
|
+
reason: "fetched the adopted snapshot",
|
|
119
|
+
changed: record.checksum !== prevChecksum,
|
|
120
|
+
tier: stalenessTier(record, deps.now ? deps.now() : Date.now()),
|
|
121
|
+
record,
|
|
122
|
+
validation,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
log(
|
|
126
|
+
"warn",
|
|
127
|
+
got.reason === "protocol-missing-mandate-family"
|
|
128
|
+
? "[mandate] the vendored protocol carries no `mandate` family — falling back to the local derivation. Re-vendor lib/org/protocol.mjs (by hand: sync-protocol is unsafe while maestro main lacks the crm family) to pick up adopted objectives."
|
|
129
|
+
: `[mandate] could not fetch the adopted snapshot (${got.reason}) — falling back to the local derivation`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- offline / fallback path ---------------------------------------------
|
|
134
|
+
const d = deps.derivation || {};
|
|
135
|
+
const derived = deriveMandate({
|
|
136
|
+
charterSections: d.charterSections,
|
|
137
|
+
strategy: d.strategy,
|
|
138
|
+
kpiCategories: d.kpiCategories,
|
|
139
|
+
priorities: d.priorities,
|
|
140
|
+
manifest: d.manifest,
|
|
141
|
+
memberId: deps.memberId,
|
|
142
|
+
overrides: d.overrides,
|
|
143
|
+
});
|
|
144
|
+
for (const deg of derived.degradations || []) {
|
|
145
|
+
log("warn", `[mandate] derivation degradation (${deg.kind}): ${deg.reason}`);
|
|
146
|
+
}
|
|
147
|
+
const body = toMandateBody({ memberId: deps.memberId, objectives: derived.objectives });
|
|
148
|
+
|
|
149
|
+
// Never overwrite a SERVER snapshot with a local derivation: adopted beats
|
|
150
|
+
// proposed, always. A stale server snapshot is still the real mandate.
|
|
151
|
+
if (prev && !prev.corrupt && prev.source === "server") {
|
|
152
|
+
log(
|
|
153
|
+
"info",
|
|
154
|
+
"[mandate] keeping the cached SERVER snapshot; a local derivation never supersedes adopted objectives"
|
|
155
|
+
);
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
source: "cache",
|
|
159
|
+
degraded: true,
|
|
160
|
+
reason: deps.offline ? "offline; kept the cached adopted snapshot" : "fetch failed; kept the cached adopted snapshot",
|
|
161
|
+
changed: false,
|
|
162
|
+
tier: stalenessTier(prev, deps.now ? deps.now() : Date.now()),
|
|
163
|
+
record: prev,
|
|
164
|
+
counts: derived.counts,
|
|
165
|
+
degradations: derived.degradations,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const record = writeCache(
|
|
170
|
+
agentRoot,
|
|
171
|
+
{ version: 0, body, source: "local" },
|
|
172
|
+
{ now: deps.now ? () => new Date(deps.now()).toISOString() : undefined }
|
|
173
|
+
);
|
|
174
|
+
return {
|
|
175
|
+
ok: true,
|
|
176
|
+
source: "local",
|
|
177
|
+
degraded: true,
|
|
178
|
+
reason: deps.offline ? "derived locally (offline)" : "derived locally (the org could not supply an adopted snapshot)",
|
|
179
|
+
changed: record.checksum !== prevChecksum,
|
|
180
|
+
tier: stalenessTier(record, deps.now ? deps.now() : Date.now()),
|
|
181
|
+
record,
|
|
182
|
+
counts: derived.counts,
|
|
183
|
+
degradations: derived.degradations,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export default { GET_METHOD, fetchSnapshot, refreshMandate };
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/mandate/refresh.test.mjs — "derived at init from charter/role, refreshed
|
|
3
|
+
* on a cadence", plus the cache and its staleness ladder.
|
|
4
|
+
*
|
|
5
|
+
* The load-bearing rule under test: **an adopted server snapshot always beats a
|
|
6
|
+
* local derivation.** A partitioned agent falls back to what it can derive, but
|
|
7
|
+
* the moment it has once held a real mandate, no local derivation may overwrite
|
|
8
|
+
* it — otherwise a network blip would silently demote adopted objectives to
|
|
9
|
+
* proposed ones and the agent would quietly stop working toward them.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
"use strict";
|
|
13
|
+
|
|
14
|
+
import { test } from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { refreshMandate, fetchSnapshot, GET_METHOD } from "./refresh.mjs";
|
|
21
|
+
import {
|
|
22
|
+
readCache, writeCache, cachePath, checksumOf, canonical,
|
|
23
|
+
staleSeconds, stalenessTier, permissionsForTier, adoptedObjectives, TIERS,
|
|
24
|
+
} from "./cache.mjs";
|
|
25
|
+
|
|
26
|
+
const NOW = Date.parse("2026-08-11T09:00:00.000Z");
|
|
27
|
+
|
|
28
|
+
function makeRoot() {
|
|
29
|
+
return mkdtempSync(join(tmpdir(), "mandate-refresh-"));
|
|
30
|
+
}
|
|
31
|
+
function cleanup(root) {
|
|
32
|
+
try { rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const BODY = {
|
|
36
|
+
memberId: "mem_self",
|
|
37
|
+
objectives: [
|
|
38
|
+
{ key: "revenue", kind: "PILLAR", state: "active", charterSectionId: "cs_1", adoptedById: "mem_boss" },
|
|
39
|
+
{
|
|
40
|
+
key: "pipeline", kind: "OBJECTIVE", parentKey: "revenue", state: "active",
|
|
41
|
+
metric: "pipeline_coverage_x", target: 3, direction: "up", cadence: "weekly",
|
|
42
|
+
sensor: { capability: "crm_list_deals", source: "method" }, adoptedById: "mem_boss",
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const DERIVATION = {
|
|
48
|
+
charterSections: [{ id: "cs_1", kind: "PILLAR", order: 1, title: "Revenue growth", body: "Own revenue." }],
|
|
49
|
+
kpiCategories: [{ id: "pipeline", name: "Pipeline coverage", examples: ["pipeline_coverage_x"] }],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Cache primitives
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
test("canonical JSON is key-order independent, so the checksum is stable", () => {
|
|
57
|
+
assert.equal(canonical({ b: 1, a: 2 }), canonical({ a: 2, b: 1 }));
|
|
58
|
+
assert.equal(checksumOf({ b: 1, a: 2 }), checksumOf({ a: 2, b: 1 }));
|
|
59
|
+
assert.notEqual(checksumOf({ a: 1 }), checksumOf({ a: 2 }));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a missing cache is null; a CORRUPT cache is reported as corrupt, never as empty", () => {
|
|
63
|
+
const root = makeRoot();
|
|
64
|
+
try {
|
|
65
|
+
assert.equal(readCache(root), null);
|
|
66
|
+
mkdirSync(join(root, "state", "mandate"), { recursive: true });
|
|
67
|
+
writeFileSync(cachePath(root), "{not json", "utf-8");
|
|
68
|
+
const rec = readCache(root);
|
|
69
|
+
assert.equal(rec.corrupt, true);
|
|
70
|
+
assert.ok(rec.reason, "a corrupt cache must carry the parse reason — silence is the bug");
|
|
71
|
+
} finally { cleanup(root); }
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("the staleness ladder degrades on a ramp, not a cliff", () => {
|
|
75
|
+
const at = (secs) => ({ fetchedAt: new Date(NOW - secs * 1000).toISOString() });
|
|
76
|
+
assert.equal(stalenessTier(at(3600), NOW), "fresh");
|
|
77
|
+
assert.equal(stalenessTier(at(TIERS.fresh + 1), NOW), "aging");
|
|
78
|
+
assert.equal(stalenessTier(at(TIERS.aging + 1), NOW), "stale");
|
|
79
|
+
assert.equal(stalenessTier(at(TIERS.stale + 1), NOW), "expired");
|
|
80
|
+
// Never fetched at all is maximally stale, not accidentally fresh.
|
|
81
|
+
assert.equal(staleSeconds(null, NOW), Infinity);
|
|
82
|
+
assert.equal(stalenessTier({}, NOW), "expired");
|
|
83
|
+
|
|
84
|
+
// The ramp itself: OUTCOME work dies first, REACT last.
|
|
85
|
+
assert.deepEqual(permissionsForTier("fresh"), { react: "full", schedule: "full", outcome: "full" });
|
|
86
|
+
assert.equal(permissionsForTier("aging").outcome, "suspended");
|
|
87
|
+
assert.equal(permissionsForTier("aging").react, "full");
|
|
88
|
+
assert.equal(permissionsForTier("stale").react, "offline_safe");
|
|
89
|
+
assert.equal(permissionsForTier("expired").react, "none");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("adoptedObjectives from the cache admits only active nodes", () => {
|
|
93
|
+
const rec = { body: { objectives: [{ key: "a", state: "active" }, { key: "b", state: "proposed" }] } };
|
|
94
|
+
assert.deepEqual(adoptedObjectives(rec).map((o) => o.key), ["a"]);
|
|
95
|
+
assert.deepEqual(adoptedObjectives(null), []);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("writeCache round-trips and stamps a checksum over the body", () => {
|
|
99
|
+
const root = makeRoot();
|
|
100
|
+
try {
|
|
101
|
+
const rec = writeCache(root, { version: 7, body: BODY, source: "server" });
|
|
102
|
+
assert.equal(rec.version, 7);
|
|
103
|
+
assert.equal(rec.checksum, checksumOf(BODY));
|
|
104
|
+
const back = readCache(root);
|
|
105
|
+
assert.deepEqual(back.body, BODY);
|
|
106
|
+
assert.equal(back.source, "server");
|
|
107
|
+
} finally { cleanup(root); }
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// fetchSnapshot — every failure mode is a distinct reason
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
test("fetchSnapshot returns the snapshot on an ok frame", async () => {
|
|
115
|
+
const seen = [];
|
|
116
|
+
const r = await fetchSnapshot({
|
|
117
|
+
memberId: "mem_self",
|
|
118
|
+
callImpl: async (method, params) => {
|
|
119
|
+
seen.push({ method, params });
|
|
120
|
+
return { ok: true, result: { version: 4, checksum: "abc", body: BODY } };
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
assert.equal(seen[0].method, GET_METHOD);
|
|
124
|
+
assert.equal(r.ok, true);
|
|
125
|
+
assert.equal(r.snapshot.version, 4);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("SERVER UNREACHABLE: a thrown call is a distinct, reportable reason", async () => {
|
|
129
|
+
const r = await fetchSnapshot({ callImpl: async () => { throw new Error("ECONNREFUSED"); } });
|
|
130
|
+
assert.equal(r.ok, false);
|
|
131
|
+
assert.match(r.reason, /^call-threw:/);
|
|
132
|
+
assert.match(r.reason, /ECONNREFUSED/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("a protocol without the mandate family is named exactly, not lumped in with a 404", async () => {
|
|
136
|
+
const r = await fetchSnapshot({
|
|
137
|
+
callImpl: async () => ({ ok: false, error: { code: "NOT_FOUND", message: "unknown method mandate.get" } }),
|
|
138
|
+
});
|
|
139
|
+
assert.equal(r.reason, "protocol-missing-mandate-family");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("an ordinary error frame carries its code", async () => {
|
|
143
|
+
const r = await fetchSnapshot({ callImpl: async () => ({ ok: false, error: { code: "FORBIDDEN" } }) });
|
|
144
|
+
assert.equal(r.reason, "call-failed:FORBIDDEN");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// refreshMandate
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
test("the online path caches the adopted snapshot and reports it changed", async () => {
|
|
152
|
+
const root = makeRoot();
|
|
153
|
+
try {
|
|
154
|
+
const r = await refreshMandate(root, {
|
|
155
|
+
memberId: "mem_self",
|
|
156
|
+
now: () => NOW,
|
|
157
|
+
callImpl: async () => ({ ok: true, result: { version: 4, checksum: checksumOf(BODY), body: BODY } }),
|
|
158
|
+
});
|
|
159
|
+
assert.equal(r.ok, true);
|
|
160
|
+
assert.equal(r.source, "server");
|
|
161
|
+
assert.equal(r.degraded, false);
|
|
162
|
+
assert.equal(r.changed, true);
|
|
163
|
+
assert.equal(r.tier, "fresh");
|
|
164
|
+
assert.equal(readCache(root).source, "server");
|
|
165
|
+
|
|
166
|
+
// A second identical refresh must report NO change, so nothing on disk churns.
|
|
167
|
+
const again = await refreshMandate(root, {
|
|
168
|
+
memberId: "mem_self", now: () => NOW,
|
|
169
|
+
callImpl: async () => ({ ok: true, result: { version: 4, checksum: checksumOf(BODY), body: BODY } }),
|
|
170
|
+
});
|
|
171
|
+
assert.equal(again.changed, false);
|
|
172
|
+
} finally { cleanup(root); }
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("SERVER UNREACHABLE, no prior cache: falls back to the local derivation, degraded and LOUD", async () => {
|
|
176
|
+
const root = makeRoot();
|
|
177
|
+
try {
|
|
178
|
+
const logs = [];
|
|
179
|
+
const r = await refreshMandate(root, {
|
|
180
|
+
memberId: "mem_self",
|
|
181
|
+
now: () => NOW,
|
|
182
|
+
log: (lvl, msg) => logs.push(`${lvl}:${msg}`),
|
|
183
|
+
callImpl: async () => { throw new Error("ECONNREFUSED"); },
|
|
184
|
+
derivation: DERIVATION,
|
|
185
|
+
});
|
|
186
|
+
assert.equal(r.ok, true);
|
|
187
|
+
assert.equal(r.source, "local");
|
|
188
|
+
assert.equal(r.degraded, true, "a local derivation is ALWAYS degraded — it is proposed, not adopted");
|
|
189
|
+
assert.ok(r.counts.pillars >= 1);
|
|
190
|
+
assert.ok(logs.some((l) => l.startsWith("warn:")), "the degradation must be logged, never silent");
|
|
191
|
+
|
|
192
|
+
// And critically: everything it derived is PROPOSED, so it drives no work.
|
|
193
|
+
for (const o of readCache(root).body.objectives) assert.equal(o.state, "proposed");
|
|
194
|
+
} finally { cleanup(root); }
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("SERVER UNREACHABLE with a prior SERVER snapshot: the adopted mandate is KEPT, not overwritten", async () => {
|
|
198
|
+
const root = makeRoot();
|
|
199
|
+
try {
|
|
200
|
+
// The agent once held a real, adopted mandate.
|
|
201
|
+
writeCache(root, { version: 4, body: BODY, source: "server" });
|
|
202
|
+
|
|
203
|
+
const logs = [];
|
|
204
|
+
const r = await refreshMandate(root, {
|
|
205
|
+
memberId: "mem_self",
|
|
206
|
+
now: () => NOW,
|
|
207
|
+
log: (lvl, msg) => logs.push(`${lvl}:${msg}`),
|
|
208
|
+
callImpl: async () => { throw new Error("ECONNREFUSED"); },
|
|
209
|
+
derivation: DERIVATION,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
assert.equal(r.source, "cache");
|
|
213
|
+
assert.equal(r.degraded, true);
|
|
214
|
+
assert.equal(r.changed, false);
|
|
215
|
+
// The adopted objectives survive the partition intact.
|
|
216
|
+
const cached = readCache(root);
|
|
217
|
+
assert.equal(cached.source, "server");
|
|
218
|
+
assert.deepEqual(cached.body, BODY);
|
|
219
|
+
assert.equal(cached.body.objectives[1].state, "active");
|
|
220
|
+
assert.ok(logs.some((l) => /never supersedes adopted/.test(l)));
|
|
221
|
+
} finally { cleanup(root); }
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("offline:true skips the fetch entirely and derives locally", async () => {
|
|
225
|
+
const root = makeRoot();
|
|
226
|
+
try {
|
|
227
|
+
let called = 0;
|
|
228
|
+
const r = await refreshMandate(root, {
|
|
229
|
+
memberId: "mem_self", now: () => NOW, offline: true,
|
|
230
|
+
callImpl: async () => { called += 1; return { ok: true, result: { body: BODY } }; },
|
|
231
|
+
derivation: DERIVATION,
|
|
232
|
+
});
|
|
233
|
+
assert.equal(called, 0, "offline must not touch the network");
|
|
234
|
+
assert.equal(r.source, "local");
|
|
235
|
+
assert.match(r.reason, /offline/);
|
|
236
|
+
} finally { cleanup(root); }
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("a checksum mismatch is warned about but still cached — the mandate is not lost to a vendoring drift", async () => {
|
|
240
|
+
const root = makeRoot();
|
|
241
|
+
try {
|
|
242
|
+
const logs = [];
|
|
243
|
+
const r = await refreshMandate(root, {
|
|
244
|
+
memberId: "mem_self", now: () => NOW,
|
|
245
|
+
log: (lvl, msg) => logs.push(`${lvl}:${msg}`),
|
|
246
|
+
callImpl: async () => ({ ok: true, result: { version: 4, checksum: "sha256:not-the-real-one", body: BODY } }),
|
|
247
|
+
});
|
|
248
|
+
assert.equal(r.source, "server");
|
|
249
|
+
assert.ok(logs.some((l) => /checksum mismatch/.test(l)));
|
|
250
|
+
// The LOCAL checksum is what gets stored, so drift detection stays honest.
|
|
251
|
+
assert.equal(readCache(root).checksum, checksumOf(BODY));
|
|
252
|
+
} finally { cleanup(root); }
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("an invalid adopted snapshot is cached but every error is surfaced", async () => {
|
|
256
|
+
const root = makeRoot();
|
|
257
|
+
try {
|
|
258
|
+
const logs = [];
|
|
259
|
+
const bad = {
|
|
260
|
+
memberId: "mem_self",
|
|
261
|
+
objectives: [
|
|
262
|
+
// Active, measurable, and adopted by its own owner: the Goodhart hole.
|
|
263
|
+
{
|
|
264
|
+
key: "self-graded", kind: "OBJECTIVE", state: "active", metric: "m", target: 1,
|
|
265
|
+
cadence: "weekly", adoptedById: "mem_self",
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
};
|
|
269
|
+
const r = await refreshMandate(root, {
|
|
270
|
+
memberId: "mem_self", now: () => NOW,
|
|
271
|
+
log: (lvl, msg) => logs.push(`${lvl}:${msg}`),
|
|
272
|
+
callImpl: async () => ({ ok: true, result: { version: 5, body: bad } }),
|
|
273
|
+
});
|
|
274
|
+
assert.equal(r.ok, true);
|
|
275
|
+
assert.equal(r.validation.ok, false);
|
|
276
|
+
assert.ok(r.validation.errors.some((e) => /self-adopted/.test(e)));
|
|
277
|
+
assert.ok(logs.some((l) => /invalid/.test(l)), "an invalid snapshot must be logged, not quietly trusted");
|
|
278
|
+
} finally { cleanup(root); }
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("a corrupt cache does not block a fresh server refresh", async () => {
|
|
282
|
+
const root = makeRoot();
|
|
283
|
+
try {
|
|
284
|
+
mkdirSync(join(root, "state", "mandate"), { recursive: true });
|
|
285
|
+
writeFileSync(cachePath(root), "}}}garbage", "utf-8");
|
|
286
|
+
const r = await refreshMandate(root, {
|
|
287
|
+
memberId: "mem_self", now: () => NOW,
|
|
288
|
+
callImpl: async () => ({ ok: true, result: { version: 4, body: BODY } }),
|
|
289
|
+
});
|
|
290
|
+
assert.equal(r.source, "server");
|
|
291
|
+
assert.equal(JSON.parse(readFileSync(cachePath(root), "utf-8")).version, 4);
|
|
292
|
+
} finally { cleanup(root); }
|
|
293
|
+
});
|
package/lib/mcp/server.test.mjs
CHANGED
|
@@ -145,19 +145,19 @@ test("tools/list: full active surface (email tools present — family is vendore
|
|
|
145
145
|
const r = await h.request("tools/list", {});
|
|
146
146
|
const tools = r.result.tools;
|
|
147
147
|
assert.equal(tools.length, getOrgTools().length, "one MCP tool per active table entry");
|
|
148
|
-
assert.equal(tools.length,
|
|
148
|
+
assert.equal(tools.length, 108, "email + artifact + desk families vendored → 108 tools");
|
|
149
149
|
const names = tools.map((t) => t.name);
|
|
150
|
-
for (const expected of ["org_whoami", "org_describe", "org_rpc", "org_read", "messaging_send", "board_ready", "email_send", "email_inbox", "artifact_create", "artifact_act", "artifact_catalog", "email_mailboxes", "email_draft_send", "files_list", "calendar_find_a_time", "crm_list_deals", "books_reports", "meetings_recap_file"]) {
|
|
150
|
+
for (const expected of ["org_whoami", "org_describe", "org_rpc", "org_read", "messaging_send", "task_assign", "board_ready", "email_send", "email_inbox", "artifact_create", "artifact_act", "artifact_catalog", "email_mailboxes", "email_draft_send", "files_list", "calendar_find_a_time", "crm_list_deals", "books_reports", "meetings_recap_file"]) {
|
|
151
151
|
assert.ok(names.includes(expected), `${expected} listed`);
|
|
152
152
|
}
|
|
153
153
|
assert.ok(tools.every((t) => t.inputSchema && t.inputSchema.type === "object"));
|
|
154
154
|
h.server.stop();
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
test("tools/list honours the email gate (emailAvailable:false →
|
|
157
|
+
test("tools/list honours the email gate (emailAvailable:false → 103 tools)", async () => {
|
|
158
158
|
const h = harness({ emailAvailable: false });
|
|
159
159
|
const r = await h.request("tools/list", {});
|
|
160
|
-
assert.equal(r.result.tools.length,
|
|
160
|
+
assert.equal(r.result.tools.length, 103, "the 5 own-mailbox email tools drop out");
|
|
161
161
|
const names = new Set(r.result.tools.map((t) => t.name));
|
|
162
162
|
// The five own-mailbox tools (email:true) are gated out …
|
|
163
163
|
for (const gated of ["email_send", "email_inbox", "email_message", "email_thread", "email_mark_read"]) {
|
package/lib/org/approvals.mjs
CHANGED
|
@@ -301,15 +301,27 @@ export async function requestApproval(o = {}) {
|
|
|
301
301
|
}
|
|
302
302
|
|
|
303
303
|
const c = configFromAgent(o.cfg);
|
|
304
|
+
// WIRE CONTRACT (hq `methods/approval/request.ts`): the gated action's class is
|
|
305
|
+
// `actionClass` and it is REQUIRED — this helper sent `kind`, so hq threw
|
|
306
|
+
// "actionClass is required" and the whole SDK-side governance gate filed ZERO
|
|
307
|
+
// approvals. Likewise the digest is `payloadHash` (not `payload_hash`) and the
|
|
308
|
+
// expiry is an absolute `expiresAt` (not a relative `expiresInMs`).
|
|
309
|
+
// `requester` is derived server-side from the authenticated actor and is never
|
|
310
|
+
// read from the body. The legacy names ride along for an older server; the
|
|
311
|
+
// canonical ones are what hq validates.
|
|
304
312
|
const params = {
|
|
305
313
|
id,
|
|
314
|
+
actionClass: kind,
|
|
306
315
|
kind,
|
|
307
316
|
subject,
|
|
317
|
+
payloadHash: payloadHash,
|
|
308
318
|
payload_hash: payloadHash,
|
|
309
|
-
requester: o.requester || c.orgId || undefined,
|
|
310
319
|
};
|
|
311
320
|
if (o.itemId) params.itemId = String(o.itemId);
|
|
312
|
-
if (Number.isFinite(o.expiresInMs))
|
|
321
|
+
if (Number.isFinite(o.expiresInMs)) {
|
|
322
|
+
params.expiresAt = new Date(Date.now() + Number(o.expiresInMs)).toISOString();
|
|
323
|
+
params.expiresInMs = o.expiresInMs;
|
|
324
|
+
}
|
|
313
325
|
|
|
314
326
|
const frame = await approvalRequest(params, {
|
|
315
327
|
base: c.base,
|