@cohortapp/agent-sdk 2.4.0 → 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/enroll-from-cohort.mjs +22 -2
- package/lib/setup/enroll-from-cohort.test.mjs +25 -0
- 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
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/kpi-sensors.test.mjs — the sensor registry.
|
|
3
|
+
*
|
|
4
|
+
* The load-bearing tests are the NEGATIVES: a sensor that cannot measure must
|
|
5
|
+
* produce an honest `{ok:false, reason}` that `measureKpi` refuses to turn into
|
|
6
|
+
* a sample, and that `admit`'s honesty gate therefore refuses to turn into work.
|
|
7
|
+
* A silent zero here would let an agent invent its own justification for
|
|
8
|
+
* anything, which is the whole reason the honesty gate exists.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
"use strict";
|
|
12
|
+
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
SENSOR_SPECS,
|
|
21
|
+
buildSensorRegistry,
|
|
22
|
+
reachableCapabilities,
|
|
23
|
+
refusalFor,
|
|
24
|
+
hintedCapabilities,
|
|
25
|
+
reduceGeneric,
|
|
26
|
+
firstArray,
|
|
27
|
+
pluck,
|
|
28
|
+
} from "./kpi-sensors.mjs";
|
|
29
|
+
import { measureKpi, recordMeasurement, readSeries } from "./kpi.mjs";
|
|
30
|
+
import { admit } from "./goals/admission.mjs";
|
|
31
|
+
|
|
32
|
+
const NOW = Date.parse("2026-08-11T12:00:00.000Z");
|
|
33
|
+
const now = () => NOW;
|
|
34
|
+
|
|
35
|
+
function root() {
|
|
36
|
+
const d = mkdtempSync(join(tmpdir(), "kpi-sensors-"));
|
|
37
|
+
mkdirSync(join(d, "config"), { recursive: true });
|
|
38
|
+
return d;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A fake org: capability → error-or-result frame. */
|
|
42
|
+
function fakeExec(table, calls = []) {
|
|
43
|
+
return async (name, input) => {
|
|
44
|
+
calls.push({ name, input });
|
|
45
|
+
const hit = table[name];
|
|
46
|
+
if (hit === undefined) return { ok: false, error: { code: "NOT_FOUND", message: `no fake for ${name}` } };
|
|
47
|
+
if (typeof hit === "function") return hit(input);
|
|
48
|
+
return { ok: true, result: hit };
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const OBJ = (capability, params = {}) => ({
|
|
53
|
+
key: "k", kind: "GOAL", state: "active", metric: "m", direction: "up",
|
|
54
|
+
target: 90, tolerance: 2, cadence: "weekly",
|
|
55
|
+
sensor: { capability, params, source: "method" },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Reductions
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
test("board_ready measures an on-time rate and shows what it walked", async () => {
|
|
63
|
+
const calls = [];
|
|
64
|
+
const sensors = buildSensorRegistry({
|
|
65
|
+
agentRoot: root(), now,
|
|
66
|
+
executeImpl: fakeExec({ board_ready: { items: [
|
|
67
|
+
{ id: "a", dueAt: "2026-06-01T00:00:00Z" }, // overdue
|
|
68
|
+
{ id: "b", dueAt: "2026-12-01T00:00:00Z" }, // not yet
|
|
69
|
+
{ id: "c", dueAt: null }, // cannot be late
|
|
70
|
+
{ id: "d", dueAt: null },
|
|
71
|
+
] } }, calls),
|
|
72
|
+
});
|
|
73
|
+
const m = await measureKpi(OBJ("board_ready", { metric: "on_time_rate" }), { sensors, now });
|
|
74
|
+
assert.equal(m.ok, true);
|
|
75
|
+
assert.equal(m.value, 75);
|
|
76
|
+
assert.equal(m.source, "method");
|
|
77
|
+
assert.equal(m.evidence.rowCount, 4);
|
|
78
|
+
assert.equal(m.evidence.detail.overdue, 1);
|
|
79
|
+
assert.equal(m.evidence.detail.withDueDate, 2);
|
|
80
|
+
assert.equal(calls[0].name, "board_ready");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("decision_list distinguishes an honest zero from an absence", async () => {
|
|
84
|
+
const sensors = (table) => buildSensorRegistry({ agentRoot: root(), now, executeImpl: fakeExec(table) });
|
|
85
|
+
|
|
86
|
+
// Rows exist, none undecided → a REAL zero-day backlog.
|
|
87
|
+
const zero = await measureKpi(OBJ("decision_list"), {
|
|
88
|
+
sensors: sensors({ decision_list: { decisions: [{ id: "1", status: "adopted", createdAt: "2026-08-01T00:00:00Z", decidedAt: "2026-08-02T00:00:00Z" }] } }),
|
|
89
|
+
now,
|
|
90
|
+
});
|
|
91
|
+
assert.equal(zero.ok, true);
|
|
92
|
+
assert.equal(zero.value, 0);
|
|
93
|
+
assert.match(zero.evidence.detail.note, /honest zero/);
|
|
94
|
+
|
|
95
|
+
// No rows at all → NOT a zero.
|
|
96
|
+
const none = await measureKpi(OBJ("decision_list"), { sensors: sensors({ decision_list: { decisions: [] } }), now });
|
|
97
|
+
assert.equal(none.ok, false);
|
|
98
|
+
assert.equal(none.value, null);
|
|
99
|
+
assert.equal(none.reason, "no-rows");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("email_inbox pairs a reply to its inbound message and never averages silence in as speed", async () => {
|
|
103
|
+
const sensors = buildSensorRegistry({
|
|
104
|
+
agentRoot: root(), now,
|
|
105
|
+
executeImpl: fakeExec({ email_inbox: { messages: [
|
|
106
|
+
{ id: "1", threadId: "t1", direction: "INBOUND", receivedAt: "2026-08-11T00:00:00Z" },
|
|
107
|
+
{ id: "2", threadId: "t1", direction: "OUTBOUND", sentAt: "2026-08-11T02:00:00Z" },
|
|
108
|
+
{ id: "3", threadId: "t2", direction: "INBOUND", receivedAt: "2026-08-10T00:00:00Z" }, // never answered
|
|
109
|
+
] } }),
|
|
110
|
+
});
|
|
111
|
+
const m = await measureKpi(OBJ("email_inbox"), { sensors, now });
|
|
112
|
+
assert.equal(m.ok, true);
|
|
113
|
+
assert.equal(m.value, 2); // 2h, not 1h — the unanswered one is excluded
|
|
114
|
+
assert.equal(m.evidence.detail.answered, 1);
|
|
115
|
+
assert.equal(m.evidence.detail.unanswered, 1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("crm_list_deals never sends hq an illegal stage filter", async () => {
|
|
119
|
+
const calls = [];
|
|
120
|
+
const sensors = buildSensorRegistry({
|
|
121
|
+
agentRoot: root(), now,
|
|
122
|
+
executeImpl: fakeExec({ crm_list_deals: { deals: [
|
|
123
|
+
{ id: "1", stage: "INBOUND", valueCents: 100_000_000 },
|
|
124
|
+
{ id: "2", stage: "CLOSED", valueCents: 900_000_000 },
|
|
125
|
+
] } }, calls),
|
|
126
|
+
});
|
|
127
|
+
// "open" is not in hq's enum — it must not reach the wire.
|
|
128
|
+
const m = await measureKpi(OBJ("crm_list_deals", { stage: "open" }), { sensors, now });
|
|
129
|
+
assert.deepEqual(calls[0].input, {});
|
|
130
|
+
assert.equal(m.value, 1_000_000); // the CLOSED deal is excluded
|
|
131
|
+
assert.equal(m.evidence.detail.open, 1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("reduceGeneric is the escape hatch: filter an array, sum a field", () => {
|
|
135
|
+
const r = reduceGeneric(
|
|
136
|
+
{ byAgent: [{ agent: "A016", usd: 4384.87 }, { agent: "A017", usd: 3455.27 }] },
|
|
137
|
+
{ arrayPath: "byAgent", where: { key: "agent", equals: "A016" }, sum: "usd" },
|
|
138
|
+
);
|
|
139
|
+
assert.equal(r.value, 4384.87);
|
|
140
|
+
assert.equal(r.rowCount, 1);
|
|
141
|
+
assert.equal(pluck({ a: { b: [1, 2] } }, "a.b.1"), 2);
|
|
142
|
+
assert.equal(firstArray({ x: 1, deals: [1, 2, 3] }).key, "deals");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// THE NEGATIVES
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
test("NEGATIVE: an unregistered capability yields no-implementation and NO sample", async () => {
|
|
150
|
+
const dir = root();
|
|
151
|
+
const sensors = buildSensorRegistry({ agentRoot: dir, now, executeImpl: fakeExec({}) });
|
|
152
|
+
const o = OBJ("messaging_send");
|
|
153
|
+
const m = await measureKpi(o, { sensors, now });
|
|
154
|
+
|
|
155
|
+
assert.equal(m.ok, false);
|
|
156
|
+
assert.equal(m.reason, "no-implementation");
|
|
157
|
+
assert.equal(m.value, null);
|
|
158
|
+
|
|
159
|
+
// The loop only records when m.ok — assert the ledger stays empty either way.
|
|
160
|
+
assert.equal(readSeries(dir, o.key).length, 0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("NEGATIVE: a WRITE capability is refused by name, not silently missing", async () => {
|
|
164
|
+
const sensors = buildSensorRegistry({ agentRoot: root(), now, executeImpl: fakeExec({}) });
|
|
165
|
+
assert.equal(typeof sensors.task_update, "function", "the derivation can pick it, so it must have an answer");
|
|
166
|
+
const m = await measureKpi(OBJ("task_update"), { sensors, now });
|
|
167
|
+
assert.equal(m.ok, false);
|
|
168
|
+
assert.equal(m.reason, "capability-is-not-a-sensor");
|
|
169
|
+
assert.match(m.evidence.detail.why, /may not mutate the org/);
|
|
170
|
+
assert.match(refusalFor("escalation_raise"), /may not mutate the org/);
|
|
171
|
+
assert.match(refusalFor("approval_wait"), /long-poll/);
|
|
172
|
+
assert.equal(refusalFor("board_ready"), null);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("NEGATIVE: a failed org read is a read-failure, never a zero", async () => {
|
|
176
|
+
const dir = root();
|
|
177
|
+
const sensors = buildSensorRegistry({
|
|
178
|
+
agentRoot: dir, now,
|
|
179
|
+
executeImpl: fakeExec({ board_ready: () => ({ ok: false, error: { code: "UNAUTHORIZED", message: "no org credential" } }) }),
|
|
180
|
+
});
|
|
181
|
+
const m = await measureKpi(OBJ("board_ready"), { sensors, now });
|
|
182
|
+
assert.equal(m.ok, false);
|
|
183
|
+
assert.equal(m.reason, "read-failed");
|
|
184
|
+
assert.equal(m.value, null);
|
|
185
|
+
assert.equal(m.evidence.detail.code, "UNAUTHORIZED");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("NEGATIVE: a sensor that throws is caught and reported, not propagated", async () => {
|
|
189
|
+
const sensors = buildSensorRegistry({
|
|
190
|
+
agentRoot: root(), now,
|
|
191
|
+
executeImpl: async () => { throw new Error("socket hang up"); },
|
|
192
|
+
});
|
|
193
|
+
const m = await measureKpi(OBJ("board_ready"), { sensors, now });
|
|
194
|
+
assert.equal(m.ok, false);
|
|
195
|
+
assert.equal(m.reason, "sensor-threw");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("NEGATIVE: an unmeasurable objective cannot reach the admission honesty gate", async () => {
|
|
199
|
+
const dir = root();
|
|
200
|
+
const sensors = buildSensorRegistry({ agentRoot: dir, now, executeImpl: fakeExec({ board_ready: { items: [] } }) });
|
|
201
|
+
const o = OBJ("board_ready");
|
|
202
|
+
|
|
203
|
+
const m = await measureKpi(o, { sensors, now });
|
|
204
|
+
assert.equal(m.ok, false, "empty board → no measurement");
|
|
205
|
+
|
|
206
|
+
// Exactly what the loop does: record ONLY when ok.
|
|
207
|
+
if (m.ok) recordMeasurement(dir, { objectiveKey: o.key, value: m.value, window: "2026-W33", source: m.source }, { now });
|
|
208
|
+
const series = readSeries(dir, o.key);
|
|
209
|
+
assert.equal(series.length, 0);
|
|
210
|
+
|
|
211
|
+
const verdict = admit(
|
|
212
|
+
{ title: "do something", advances: [o.key], why: { reason: "kpi-gap" } },
|
|
213
|
+
{ objective: o, gap: { gap: 15, withinTolerance: false, trend: "unknown" }, latestSample: series[series.length - 1] || null, openItems: [], staleness: { outcome: "full" } },
|
|
214
|
+
);
|
|
215
|
+
assert.equal(verdict.admitted, false);
|
|
216
|
+
assert.equal(verdict.gate, "honesty");
|
|
217
|
+
rmSync(dir, { recursive: true, force: true });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("a MEASURED sample satisfies the honesty gate — the joint, end to end", async () => {
|
|
221
|
+
const dir = root();
|
|
222
|
+
const sensors = buildSensorRegistry({
|
|
223
|
+
agentRoot: dir, now,
|
|
224
|
+
executeImpl: fakeExec({ board_ready: { items: [{ id: "a", dueAt: "2026-06-01T00:00:00Z" }, { id: "b", dueAt: null }, { id: "c", dueAt: null }, { id: "d", dueAt: null }] } }),
|
|
225
|
+
});
|
|
226
|
+
const o = OBJ("board_ready");
|
|
227
|
+
const m = await measureKpi(o, { sensors, now });
|
|
228
|
+
assert.equal(m.ok, true);
|
|
229
|
+
recordMeasurement(dir, { objectiveKey: o.key, value: m.value, window: "2026-W33", source: m.source, evidence: m.evidence }, { now });
|
|
230
|
+
|
|
231
|
+
const series = readSeries(dir, o.key);
|
|
232
|
+
const verdict = admit(
|
|
233
|
+
{ title: "raise the on-time rate", advances: [o.key], why: { reason: "kpi-gap" }, estCents: 50 },
|
|
234
|
+
{ objective: o, gap: { gap: 15, withinTolerance: false, trend: "unknown" }, latestSample: series[series.length - 1], openItems: [], remainingCents: 500, staleness: { outcome: "full" } },
|
|
235
|
+
);
|
|
236
|
+
assert.equal(verdict.admitted, true, verdict.reason);
|
|
237
|
+
assert.equal(series[0].evidence.method, "board_ready");
|
|
238
|
+
assert.equal(series[0].evidence.rowCount, 4);
|
|
239
|
+
rmSync(dir, { recursive: true, force: true });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// Reachability — the fail-open that must not become fail-closed
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
test("a missing or empty manifest returns undefined, not an empty Set", () => {
|
|
247
|
+
const dir = root();
|
|
248
|
+
const logs = [];
|
|
249
|
+
const log = (l, m) => logs.push(`${l}:${m}`);
|
|
250
|
+
assert.equal(reachableCapabilities(dir, { log }), undefined);
|
|
251
|
+
assert.ok(logs.some((l) => /no capability manifest/.test(l)), "the degradation is logged");
|
|
252
|
+
|
|
253
|
+
writeFileSync(join(dir, "config", "capability-manifest.json"), JSON.stringify({ entries: [] }));
|
|
254
|
+
assert.equal(reachableCapabilities(dir, { log }), undefined, "zero reachable entries must not refuse every objective");
|
|
255
|
+
|
|
256
|
+
writeFileSync(join(dir, "config", "capability-manifest.json"), "{not json");
|
|
257
|
+
assert.equal(reachableCapabilities(dir, { log }), undefined);
|
|
258
|
+
|
|
259
|
+
writeFileSync(join(dir, "config", "capability-manifest.json"), JSON.stringify({ entries: [{ id: "board_ready", reachable: true }, { id: "task_update", reachable: false }] }));
|
|
260
|
+
const set = reachableCapabilities(dir, { log });
|
|
261
|
+
assert.ok(set.has("board_ready"));
|
|
262
|
+
assert.ok(!set.has("task_update"));
|
|
263
|
+
rmSync(dir, { recursive: true, force: true });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("every capability the mandate derivation can bind has an answer in the registry", () => {
|
|
267
|
+
const sensors = buildSensorRegistry({ agentRoot: root(), now, executeImpl: fakeExec({}) });
|
|
268
|
+
for (const cap of hintedCapabilities()) {
|
|
269
|
+
assert.equal(typeof sensors[cap], "function", `${cap} is hinted by derive.SENSOR_HINTS but has no registry entry`);
|
|
270
|
+
}
|
|
271
|
+
// …and every spec is a READ.
|
|
272
|
+
for (const cap of Object.keys(SENSOR_SPECS)) {
|
|
273
|
+
assert.equal(refusalFor(cap), null, `${cap} has a spec so it must not be refused`);
|
|
274
|
+
}
|
|
275
|
+
});
|
package/lib/kpi.mjs
CHANGED
|
@@ -232,6 +232,12 @@ export function recordIntervention(agentRoot, rec, deps = {}) {
|
|
|
232
232
|
expectedDelta: Number.isFinite(Number(rec && rec.expectedDelta)) ? Number(rec.expectedDelta) : null,
|
|
233
233
|
realizedDelta: Number.isFinite(Number(rec && rec.realizedDelta)) ? Number(rec.realizedDelta) : null,
|
|
234
234
|
measuredAt: (rec && rec.measuredAt) || null,
|
|
235
|
+
// The measurement window the realized delta was scored in, and how it was
|
|
236
|
+
// derived. `(taskId, window)` is the idempotency key the loop dedupes on —
|
|
237
|
+
// without it a second steward pass in the same week double-scores the same
|
|
238
|
+
// intervention and the planner's "this failed before" count drifts up on its own.
|
|
239
|
+
window: (rec && rec.window) || null,
|
|
240
|
+
basis: (rec && rec.basis) || null,
|
|
235
241
|
};
|
|
236
242
|
try {
|
|
237
243
|
const p = interventionsPath(agentRoot);
|
|
@@ -306,6 +312,20 @@ export async function measureKpi(objective, deps = {}) {
|
|
|
306
312
|
return { ok: false, value: null, source: "method", evidence: null, reason: "sensor-threw" };
|
|
307
313
|
}
|
|
308
314
|
|
|
315
|
+
// A sensor that CANNOT measure says so in its own words. Collapsing that into
|
|
316
|
+
// the generic "non-numeric" below would throw away the only diagnostic the
|
|
317
|
+
// operator has — "no-rows" (the org genuinely has nothing), "read-failed"
|
|
318
|
+
// (the org said no), "capability-is-not-a-sensor" (the mandate cited a write
|
|
319
|
+
// tool) and "missing-param" are four different repairs, not one.
|
|
320
|
+
if (raw && typeof raw === "object" && raw.ok === false) {
|
|
321
|
+
const reason = String(raw.reason || "sensor-unavailable");
|
|
322
|
+
logOf(deps)(
|
|
323
|
+
"warn",
|
|
324
|
+
`[kpi] sensor "${sensor.capability}" could not measure "${key}": ${reason}${raw.detail ? ` ${JSON.stringify(raw.detail)}` : ""}`
|
|
325
|
+
);
|
|
326
|
+
return { ok: false, value: null, source: "method", evidence: { method: sensor.capability, params: sensor.params || {}, detail: raw.detail || null }, reason };
|
|
327
|
+
}
|
|
328
|
+
|
|
309
329
|
const value = Number(raw && typeof raw === "object" ? raw.value : raw);
|
|
310
330
|
if (!Number.isFinite(value)) {
|
|
311
331
|
logOf(deps)("warn", `[kpi] sensor "${sensor.capability}" returned a non-numeric result for "${key}"`);
|
|
@@ -321,6 +341,9 @@ export async function measureKpi(objective, deps = {}) {
|
|
|
321
341
|
params: sensor.params || {},
|
|
322
342
|
rowCount: raw && typeof raw === "object" && Number.isFinite(Number(raw.rowCount)) ? Number(raw.rowCount) : null,
|
|
323
343
|
sessionId: (raw && typeof raw === "object" && raw.sessionId) || null,
|
|
344
|
+
// What the reduction actually walked (metric, scope, rows considered).
|
|
345
|
+
// Without it "value: 75" is unauditable a week later.
|
|
346
|
+
detail: (raw && typeof raw === "object" && raw.detail) || null,
|
|
324
347
|
},
|
|
325
348
|
reason: "measured",
|
|
326
349
|
};
|
package/lib/mandate/audit.mjs
CHANGED
|
@@ -41,6 +41,9 @@ export const AUDIT_REL = join("state", "mandate", "audit.jsonl");
|
|
|
41
41
|
export const DECISIONS = Object.freeze([
|
|
42
42
|
"measured",
|
|
43
43
|
"measure_failed",
|
|
44
|
+
// The learning signal: an in-flight intervention scored against the sample
|
|
45
|
+
// that just landed (lib/goals/loop.journalRealizedDeltas → kpi.recordIntervention).
|
|
46
|
+
"intervention_scored",
|
|
44
47
|
"gap",
|
|
45
48
|
"candidate_admitted",
|
|
46
49
|
"candidate_rejected",
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/mandate/contract.mjs — THE MANDATE BODY CONTRACT (agent half).
|
|
3
|
+
*
|
|
4
|
+
* ONE shape, written down twice: here as a JSDoc typedef + runtime validator,
|
|
5
|
+
* and in hq as TypeScript at `src/server/methods/mandate/_contract.ts`. The two
|
|
6
|
+
* files carry the SAME `MANDATE_CONTRACT_VERSION` and the SAME key lists, and
|
|
7
|
+
* each repo's tests pin those literals — so a divergence is a one-line diff
|
|
8
|
+
* instead of an invisible `undefined`.
|
|
9
|
+
*
|
|
10
|
+
* ── WHY THIS FILE EXISTS ──
|
|
11
|
+
* The two repos disagreed on this body and NOTHING errored. hq emitted
|
|
12
|
+
* `{memberId, objectives, proposed, generatedAt}`; `compilePlan` read
|
|
13
|
+
* `{objectives, budgetCentsPerPeriod, collaborators, reactsTo}`. Three of the
|
|
14
|
+
* four keys were simply absent, so `Number.isFinite(mandate.budgetCentsPerPeriod)`
|
|
15
|
+
* was false on every server body and EVERY obligation took a hardcoded 500c
|
|
16
|
+
* allowance against a hardcoded 10000c envelope. No error frame, no log line,
|
|
17
|
+
* no drift row — the plan ran on fictional money and looked perfectly healthy.
|
|
18
|
+
*
|
|
19
|
+
* The rule: a MISSING required key is a contract ERROR (the body cannot be
|
|
20
|
+
* trusted). A key that is PRESENT AND NULL is a warning (hq looked and had
|
|
21
|
+
* nothing — the obligation is genuinely unmetered, and hq says why in
|
|
22
|
+
* `degradations[]`). Those two used to be indistinguishable. They no longer are,
|
|
23
|
+
* and NOTHING in this codebase may substitute a number for either of them.
|
|
24
|
+
*
|
|
25
|
+
* PURE: no fs, no network, no clock. ESM.
|
|
26
|
+
*
|
|
27
|
+
* @module lib/mandate/contract
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
"use strict";
|
|
31
|
+
|
|
32
|
+
/** Bump when a key is added, removed or re-typed. Mirrored in hq. */
|
|
33
|
+
export const MANDATE_CONTRACT_VERSION = 1;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The EXACT top-level key set of a v1 body, sorted. Pinned by `contract.test.mjs`
|
|
37
|
+
* here and by hq's `shared.test.ts` — the two assertions ARE the drift alarm, so
|
|
38
|
+
* keep the literals identical.
|
|
39
|
+
*/
|
|
40
|
+
export const MANDATE_BODY_KEYS = Object.freeze([
|
|
41
|
+
"budgetCentsPerPeriod",
|
|
42
|
+
"budgetPeriod",
|
|
43
|
+
"budgetSource",
|
|
44
|
+
"collaborators",
|
|
45
|
+
"contractVersion",
|
|
46
|
+
"degradations",
|
|
47
|
+
"generatedAt",
|
|
48
|
+
"memberId",
|
|
49
|
+
"objectives",
|
|
50
|
+
"proposed",
|
|
51
|
+
"reactsTo",
|
|
52
|
+
"seatBudgetCents",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Keys `compilePlan` READS. A missing one is what made the compiler invent a
|
|
57
|
+
* number, so a missing one is a hard error — never a warning, never a default.
|
|
58
|
+
*/
|
|
59
|
+
export const MANDATE_REQUIRED_KEYS = Object.freeze([
|
|
60
|
+
"budgetCentsPerPeriod",
|
|
61
|
+
"collaborators",
|
|
62
|
+
"contractVersion",
|
|
63
|
+
"memberId",
|
|
64
|
+
"objectives",
|
|
65
|
+
"reactsTo",
|
|
66
|
+
"seatBudgetCents",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @typedef {"DOER"|"CONTRIBUTOR"|"REVIEWER"|"APPROVER"|"INFORMED"} CollaboratorRole
|
|
71
|
+
*
|
|
72
|
+
* @typedef {object} MandateCollaborator
|
|
73
|
+
* @property {string} memberId
|
|
74
|
+
* @property {CollaboratorRole} role
|
|
75
|
+
* @property {string} source the column the edge came out of (provenance)
|
|
76
|
+
*
|
|
77
|
+
* @typedef {object} MandateDegradation
|
|
78
|
+
* @property {string} field dotted path of the null/empty field
|
|
79
|
+
* @property {string} reason why, in one sentence a human can act on
|
|
80
|
+
*
|
|
81
|
+
* @typedef {object} ObjectiveWire
|
|
82
|
+
* @property {string} id
|
|
83
|
+
* @property {string|null} key
|
|
84
|
+
* @property {string} kind PILLAR|OBJECTIVE|GOAL
|
|
85
|
+
* @property {string} state proposed|active|at_risk|met|missed|retired
|
|
86
|
+
* @property {string} text
|
|
87
|
+
* @property {string|null} memberId
|
|
88
|
+
* @property {string|null} parentId
|
|
89
|
+
* @property {string|null} parentKey the SDK's tree walk joins on KEYS
|
|
90
|
+
* @property {string|null} charterSectionId
|
|
91
|
+
* @property {string|null} workstreamId
|
|
92
|
+
* @property {string|null} metric
|
|
93
|
+
* @property {string|null} unit
|
|
94
|
+
* @property {string} direction
|
|
95
|
+
* @property {number|null} baseline
|
|
96
|
+
* @property {number|null} target
|
|
97
|
+
* @property {number|null} value
|
|
98
|
+
* @property {number|null} tolerance
|
|
99
|
+
* @property {number} weight
|
|
100
|
+
* @property {number} progress
|
|
101
|
+
* @property {string|null} cadence
|
|
102
|
+
* @property {string|null} dueAt
|
|
103
|
+
* @property {object|null} sensor
|
|
104
|
+
* @property {number|null} budgetCentsPerPeriod this node's share of the seat
|
|
105
|
+
* envelope, apportioned by weight
|
|
106
|
+
* @property {MandateCollaborator[]} collaborators
|
|
107
|
+
* @property {number} version
|
|
108
|
+
* @property {string|null} proposedById
|
|
109
|
+
* @property {string|null} adoptedById
|
|
110
|
+
* @property {string|null} adoptedAt
|
|
111
|
+
* @property {string} createdAt
|
|
112
|
+
* @property {string} updatedAt
|
|
113
|
+
*
|
|
114
|
+
* @typedef {object} MandateBody
|
|
115
|
+
* @property {number} contractVersion
|
|
116
|
+
* @property {string} memberId
|
|
117
|
+
* @property {ObjectiveWire[]} objectives adopted — the only compilable nodes
|
|
118
|
+
* @property {ObjectiveWire[]} proposed carried for display; never compiled
|
|
119
|
+
* @property {number|null} budgetCentsPerPeriod default allowance for ONE
|
|
120
|
+
* obligation with no objective behind it. ALWAYS null from hq v1 — hq holds no
|
|
121
|
+
* such column. Present so "unmetered, and hq said so" is distinguishable from
|
|
122
|
+
* "key missing, so I made a number up".
|
|
123
|
+
* @property {number|null} seatBudgetCents the seat's whole envelope per period
|
|
124
|
+
* @property {"monthly"|null} budgetPeriod
|
|
125
|
+
* @property {string} budgetSource provenance of the two figures above
|
|
126
|
+
* @property {MandateCollaborator[]} collaborators seat-level edges
|
|
127
|
+
* @property {object[]} reactsTo ALWAYS [] from hq v1 (no store)
|
|
128
|
+
* @property {MandateDegradation[]} degradations every null above, named
|
|
129
|
+
* @property {string} generatedAt newest row's updatedAt; never a clock
|
|
130
|
+
*/
|
|
131
|
+
|
|
132
|
+
/** Pull the body out of a cache record (`{body}`) or accept a bare body. */
|
|
133
|
+
export function bodyOf(source) {
|
|
134
|
+
if (!source || typeof source !== "object") return null;
|
|
135
|
+
if (source.body && typeof source.body === "object" && !Array.isArray(source.body)) {
|
|
136
|
+
return source.body;
|
|
137
|
+
}
|
|
138
|
+
return source;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Validate a mandate body against the contract. Never throws.
|
|
143
|
+
*
|
|
144
|
+
* @param {object} source a cache record (`{body}`) or a bare body
|
|
145
|
+
* @returns {{
|
|
146
|
+
* ok:boolean, contractVersion:number|null, missing:string[],
|
|
147
|
+
* errors:string[], warnings:string[], unmetered:boolean
|
|
148
|
+
* }}
|
|
149
|
+
* `ok:false` means DO NOT TRUST THE NUMBERS in this body. `unmetered:true`
|
|
150
|
+
* means the body is well-formed and hq truthfully has no budget for it.
|
|
151
|
+
*/
|
|
152
|
+
export function validateMandateContract(source) {
|
|
153
|
+
const errors = [];
|
|
154
|
+
const warnings = [];
|
|
155
|
+
const missing = [];
|
|
156
|
+
const body = bodyOf(source);
|
|
157
|
+
|
|
158
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
contractVersion: null,
|
|
162
|
+
missing: [...MANDATE_REQUIRED_KEYS],
|
|
163
|
+
errors: ["mandate body is not an object"],
|
|
164
|
+
warnings: [],
|
|
165
|
+
unmetered: true,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const k of MANDATE_REQUIRED_KEYS) {
|
|
170
|
+
if (!Object.prototype.hasOwnProperty.call(body, k)) missing.push(k);
|
|
171
|
+
}
|
|
172
|
+
if (missing.length) {
|
|
173
|
+
errors.push(
|
|
174
|
+
`mandate body is missing required key(s): ${missing.join(", ")}. ` +
|
|
175
|
+
"This is a PRE-CONTRACT body (hq older than mandate contract v1, or a " +
|
|
176
|
+
"hand-written fixture). Its budgets cannot be trusted and NOTHING may " +
|
|
177
|
+
"substitute a default for them."
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const version = Number.isFinite(body.contractVersion) ? body.contractVersion : null;
|
|
182
|
+
if (version == null) {
|
|
183
|
+
if (!missing.includes("contractVersion")) {
|
|
184
|
+
errors.push(`contractVersion must be a number (got ${JSON.stringify(body.contractVersion)})`);
|
|
185
|
+
}
|
|
186
|
+
} else if (version > MANDATE_CONTRACT_VERSION) {
|
|
187
|
+
// Forward-compat: a newer server is not an error, but the extra keys are
|
|
188
|
+
// unknown to this compiler and must be announced rather than dropped mute.
|
|
189
|
+
warnings.push(
|
|
190
|
+
`mandate body is contract v${version} but this SDK understands v${MANDATE_CONTRACT_VERSION} — ` +
|
|
191
|
+
"unknown keys are ignored; upgrade the SDK"
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (body.memberId != null && typeof body.memberId !== "string") {
|
|
196
|
+
errors.push("memberId must be a string or null");
|
|
197
|
+
}
|
|
198
|
+
for (const k of ["objectives", "proposed", "collaborators", "reactsTo", "degradations"]) {
|
|
199
|
+
if (Object.prototype.hasOwnProperty.call(body, k) && !Array.isArray(body[k])) {
|
|
200
|
+
errors.push(`${k} must be an array (got ${typeof body[k]})`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const k of ["budgetCentsPerPeriod", "seatBudgetCents"]) {
|
|
204
|
+
if (!Object.prototype.hasOwnProperty.call(body, k)) continue;
|
|
205
|
+
const v = body[k];
|
|
206
|
+
if (v !== null && !Number.isFinite(v)) {
|
|
207
|
+
errors.push(`${k} must be a finite number or null (got ${JSON.stringify(v)})`);
|
|
208
|
+
} else if (v !== null && v < 0) {
|
|
209
|
+
errors.push(`${k} must not be negative (got ${v})`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const seat = Number.isFinite(body.seatBudgetCents) ? body.seatBudgetCents : null;
|
|
214
|
+
const perOb = Number.isFinite(body.budgetCentsPerPeriod) ? body.budgetCentsPerPeriod : null;
|
|
215
|
+
const objectiveBudgets = (Array.isArray(body.objectives) ? body.objectives : [])
|
|
216
|
+
.map((o) => (o && Number.isFinite(o.budgetCentsPerPeriod) ? o.budgetCentsPerPeriod : null))
|
|
217
|
+
.filter((v) => v != null);
|
|
218
|
+
const unmetered = seat == null && perOb == null && objectiveBudgets.length === 0;
|
|
219
|
+
|
|
220
|
+
if (seat == null && !missing.includes("seatBudgetCents")) {
|
|
221
|
+
warnings.push(
|
|
222
|
+
"seatBudgetCents is null — the seat has no funded envelope, so no budget " +
|
|
223
|
+
"ceiling can be enforced. hq's reason is in degradations[]."
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (perOb == null && !missing.includes("budgetCentsPerPeriod")) {
|
|
227
|
+
warnings.push(
|
|
228
|
+
"budgetCentsPerPeriod is null — obligations with no objective behind them " +
|
|
229
|
+
"(standard/archetype cadences, REACTs) compile UNMETERED. They are not " +
|
|
230
|
+
"worth 500c; they are worth an unknown amount."
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (seat != null && objectiveBudgets.length) {
|
|
234
|
+
const sum = objectiveBudgets.reduce((a, b) => a + b, 0);
|
|
235
|
+
if (sum > seat) {
|
|
236
|
+
errors.push(
|
|
237
|
+
`the objective budgets sum to ${sum}c but the seat envelope is ${seat}c — ` +
|
|
238
|
+
"hq apportioned more than it holds"
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// hq states its absences on the wire; echo them so one log line explains the
|
|
244
|
+
// nulls above instead of leaving the operator to guess.
|
|
245
|
+
for (const d of Array.isArray(body.degradations) ? body.degradations : []) {
|
|
246
|
+
if (d && d.field && d.reason) warnings.push(`hq degradation — ${d.field}: ${d.reason}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { ok: errors.length === 0, contractVersion: version, missing, errors, warnings, unmetered };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Validate and LOG. The one call site every consumer should use: the failure
|
|
254
|
+
* mode this contract exists to kill is a quiet one, so the loud path is the
|
|
255
|
+
* default path.
|
|
256
|
+
*
|
|
257
|
+
* @param {object} source
|
|
258
|
+
* @param {(level:string,msg:string)=>void} [log]
|
|
259
|
+
* @param {string} [where] a tag for the log line, e.g. "compilePlan"
|
|
260
|
+
* @returns ReturnType<typeof validateMandateContract>
|
|
261
|
+
*/
|
|
262
|
+
export function assertMandateContract(source, log, where = "mandate") {
|
|
263
|
+
const v = validateMandateContract(source);
|
|
264
|
+
const emit = typeof log === "function" ? log : () => {};
|
|
265
|
+
for (const e of v.errors) emit("error", `[${where}] mandate contract violation: ${e}`);
|
|
266
|
+
for (const w of v.warnings) emit("warn", `[${where}] ${w}`);
|
|
267
|
+
return v;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export default {
|
|
271
|
+
MANDATE_CONTRACT_VERSION,
|
|
272
|
+
MANDATE_BODY_KEYS,
|
|
273
|
+
MANDATE_REQUIRED_KEYS,
|
|
274
|
+
bodyOf,
|
|
275
|
+
validateMandateContract,
|
|
276
|
+
assertMandateContract,
|
|
277
|
+
};
|