@cohortapp/agent-sdk 2.3.2 → 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/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/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 +58 -22
- package/lib/org/client.test.mjs +3 -1
- 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 +17 -2
- package/lib/org/messaging.mjs +40 -4
- package/lib/org/messaging.test.mjs +40 -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 +8 -3
- 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 +36 -4
- package/scripts/daemon/cadence-handlers.mjs +145 -1
- 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/prompt-builder.mjs +41 -1
- 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/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,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/goals/collaborate.test.mjs — the four dispositions route through the
|
|
3
|
+
* collaboration primitives that ALREADY EXIST.
|
|
4
|
+
*
|
|
5
|
+
* The design claim being tested is a negative one: no new coordination
|
|
6
|
+
* mechanism. COLLABORATE takes a `work-claim` lease, HANDOFF sends a handoff
|
|
7
|
+
* envelope, NEEDS_REVIEW goes through `approval.request` when the action class
|
|
8
|
+
* is gated, and "ask, don't pile on" uses `escalation.*`. If any of these grew
|
|
9
|
+
* a parallel mechanism, these tests are where it shows up.
|
|
10
|
+
*
|
|
11
|
+
* Every test drives the failure side too: an unreachable org must produce
|
|
12
|
+
* `{ok:false, reason}` plus a log line, never a silent success and never a
|
|
13
|
+
* throw into the loop.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
"use strict";
|
|
17
|
+
|
|
18
|
+
import { test } from "node:test";
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
createWorkItem, routeWorkItem, routeSelf, routeCollaborate, routeHandoff,
|
|
23
|
+
routeNeedsReview, raiseEscalation, workResourceId, WORK_LEASE_SCOPE,
|
|
24
|
+
} from "./collaborate.mjs";
|
|
25
|
+
import { classifyWorkItem } from "./classify.mjs";
|
|
26
|
+
|
|
27
|
+
const SELF = "mem_self";
|
|
28
|
+
const PEER = "mem_peer";
|
|
29
|
+
|
|
30
|
+
const ITEM = {
|
|
31
|
+
id: "goal-pipeline-1",
|
|
32
|
+
title: "Book 6 discovery calls",
|
|
33
|
+
objectiveKey: "pipeline",
|
|
34
|
+
obligationKey: "outcome.pipeline",
|
|
35
|
+
priority: "high",
|
|
36
|
+
expectedDelta: 0.5,
|
|
37
|
+
successCriteria: ["6 calls booked"],
|
|
38
|
+
why: { reason: "kpi-gap", objectiveKey: "pipeline", clause: "cs_12" },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function spy(over = {}) {
|
|
42
|
+
const calls = { board: [], handoff: [], approval: [], lease: [], rpc: [], logs: [] };
|
|
43
|
+
const deps = {
|
|
44
|
+
memberId: SELF,
|
|
45
|
+
log: (lvl, msg) => calls.logs.push(`${lvl}:${msg}`),
|
|
46
|
+
createItem: async (p) => { calls.board.push(p); return { ok: true, result: { id: "bi_1" } }; },
|
|
47
|
+
sendHandoff: async (o) => { calls.handoff.push(o); return { sent: true, runId: "run_1", transport: { reason: "offered" } }; },
|
|
48
|
+
requestApproval: async (o) => { calls.approval.push(o); return { requested: true, id: "appr_1" }; },
|
|
49
|
+
claimLease: async (scope, resourceId) => { calls.lease.push({ scope, resourceId }); return { held: true, leaseToken: "lt_1" }; },
|
|
50
|
+
callImpl: async (method, params) => { calls.rpc.push({ method, params }); return { ok: true, result: { id: "r_1" } }; },
|
|
51
|
+
...over,
|
|
52
|
+
};
|
|
53
|
+
return { calls, deps };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Build the classification the loop would have produced. */
|
|
57
|
+
function classify(candidate, ctx = {}) {
|
|
58
|
+
return classifyWorkItem(candidate, { memberId: SELF, ...ctx });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// The board item — the one thing every disposition creates
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
test("workResourceId is stable and objective-scoped", () => {
|
|
66
|
+
const a = workResourceId("pipeline", "Book 6 discovery calls");
|
|
67
|
+
assert.equal(a, workResourceId("pipeline", "Book 6 discovery calls"));
|
|
68
|
+
assert.notEqual(a, workResourceId("pipeline", "A different task"));
|
|
69
|
+
assert.notEqual(a, workResourceId("retention", "Book 6 discovery calls"));
|
|
70
|
+
assert.match(a, /^objective:pipeline:[0-9a-f]{10}$/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("every work item carries the full provenance chain onto the board", async () => {
|
|
74
|
+
const { calls, deps } = spy();
|
|
75
|
+
const r = await createWorkItem(ITEM, deps);
|
|
76
|
+
assert.equal(r.ok, true);
|
|
77
|
+
assert.equal(r.itemId, "bi_1");
|
|
78
|
+
const payload = calls.board[0];
|
|
79
|
+
assert.equal(payload.objectiveKey, "pipeline");
|
|
80
|
+
assert.equal(payload.obligationKey, "outcome.pipeline");
|
|
81
|
+
assert.equal(payload.why.clause, "cs_12");
|
|
82
|
+
assert.equal(payload.assignee, SELF);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("SERVER UNREACHABLE: a thrown board.create is reported, logged, and never throws out", async () => {
|
|
86
|
+
const { calls, deps } = spy({ createItem: async () => { throw new Error("ECONNREFUSED"); } });
|
|
87
|
+
const r = await createWorkItem(ITEM, deps);
|
|
88
|
+
assert.equal(r.ok, false);
|
|
89
|
+
assert.equal(r.itemId, null);
|
|
90
|
+
assert.equal(r.reason, "board-create-threw");
|
|
91
|
+
assert.ok(calls.logs.some((l) => l.startsWith("warn:")), "the failure must be logged");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("SERVER UNREACHABLE: an error FRAME is distinguished from a throw", async () => {
|
|
95
|
+
const { calls, deps } = spy({ createItem: async () => ({ ok: false, error: { code: "RATE_LIMITED" } }) });
|
|
96
|
+
const r = await createWorkItem(ITEM, deps);
|
|
97
|
+
assert.equal(r.ok, false);
|
|
98
|
+
assert.equal(r.reason, "RATE_LIMITED");
|
|
99
|
+
assert.ok(calls.logs.some((l) => /the local backlog item still stands/.test(l)));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// SELF
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
test("SELF creates the board item and nothing else", async () => {
|
|
107
|
+
const { calls, deps } = spy();
|
|
108
|
+
const c = classify({ title: ITEM.title, advances: ["pipeline"] });
|
|
109
|
+
assert.equal(c.disposition, "SELF");
|
|
110
|
+
const r = await routeSelf(ITEM, c, deps);
|
|
111
|
+
assert.equal(r.primitive, "board.create");
|
|
112
|
+
assert.equal(r.ok, true);
|
|
113
|
+
assert.equal(calls.handoff.length, 0);
|
|
114
|
+
assert.equal(calls.approval.length, 0);
|
|
115
|
+
assert.equal(calls.lease.length, 0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("SELF carries a capability gap forward rather than losing it", async () => {
|
|
119
|
+
const { deps } = spy();
|
|
120
|
+
const c = classify(
|
|
121
|
+
{ title: "Do the thing", advances: ["pipeline"], requiredCapabilities: ["stripe_refund"] },
|
|
122
|
+
{ reachable: new Set([]), peers: [] }
|
|
123
|
+
);
|
|
124
|
+
assert.equal(c.disposition, "SELF");
|
|
125
|
+
const r = await routeSelf(ITEM, c, deps);
|
|
126
|
+
assert.ok(r.capabilityGap, "an unreachable capability must travel with the route result");
|
|
127
|
+
assert.ok(r.capabilityGap.uncovered.includes("stripe_refund"));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// COLLABORATE — the existing lease, not a new mechanism
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
test("COLLABORATE claims the work-claim lease BEFORE creating the item", async () => {
|
|
135
|
+
const order = [];
|
|
136
|
+
const { calls, deps } = spy();
|
|
137
|
+
const baseLease = deps.claimLease;
|
|
138
|
+
const baseBoard = deps.createItem;
|
|
139
|
+
deps.claimLease = async (s, r, o) => { order.push("lease"); return baseLease(s, r, o); };
|
|
140
|
+
deps.createItem = async (p) => { order.push("board"); return baseBoard(p); };
|
|
141
|
+
const c = classify({ title: ITEM.title, advances: ["pipeline"], collaborators: [{ memberId: PEER, role: "CONTRIBUTOR" }] });
|
|
142
|
+
assert.equal(c.disposition, "COLLABORATE");
|
|
143
|
+
const r = await routeCollaborate(ITEM, c, deps);
|
|
144
|
+
assert.deepEqual(order, ["lease", "board"], "claiming after creating would race two agents onto one item");
|
|
145
|
+
assert.equal(calls.lease[0].scope, WORK_LEASE_SCOPE);
|
|
146
|
+
assert.equal(r.ok, true);
|
|
147
|
+
// The contributor is told, over the existing board comment method.
|
|
148
|
+
const comment = calls.rpc.find((x) => x.method === "board.comment");
|
|
149
|
+
assert.ok(comment);
|
|
150
|
+
assert.deepEqual(comment.params.mentionedMemberIds, [PEER]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("COLLABORATE: losing the lease stands down — no board item at all", async () => {
|
|
154
|
+
const { calls, deps } = spy({ claimLease: async () => ({ held: false, reason: "held-by-peer" }) });
|
|
155
|
+
const c = classify({ title: ITEM.title, advances: ["pipeline"], collaborators: [{ memberId: PEER, role: "CONTRIBUTOR" }] });
|
|
156
|
+
const r = await routeCollaborate(ITEM, c, deps);
|
|
157
|
+
assert.equal(r.ok, false);
|
|
158
|
+
assert.match(r.reason, /^lease-lost:/);
|
|
159
|
+
assert.equal(calls.board.length, 0, "a peer owns it — creating a second item is the duplicate-work bug");
|
|
160
|
+
assert.ok(calls.logs.some((l) => /standing down/.test(l)));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("COLLABORATE: a lease service that THROWS fails open — loudly, and duplicate work is flagged", async () => {
|
|
164
|
+
const { calls, deps } = spy({ claimLease: async () => { throw new Error("lease service down"); } });
|
|
165
|
+
const c = classify({ title: ITEM.title, advances: ["pipeline"], collaborators: [{ memberId: PEER, role: "CONTRIBUTOR" }] });
|
|
166
|
+
const r = await routeCollaborate(ITEM, c, deps);
|
|
167
|
+
// Fail-open: the work proceeds…
|
|
168
|
+
assert.equal(r.ok, true);
|
|
169
|
+
assert.equal(r.failedOpen, true);
|
|
170
|
+
// …but never silently.
|
|
171
|
+
assert.ok(calls.logs.some((l) => /FAILED OPEN/.test(l) && /duplicate work is possible/.test(l)));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// HANDOFF — the existing envelope
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
test("HANDOFF assigns the item to the target and sends the envelope with full context", async () => {
|
|
179
|
+
const { calls, deps } = spy();
|
|
180
|
+
const c = classify({ title: ITEM.title, advances: ["pipeline"], collaborators: [{ memberId: PEER, role: "DOER" }] });
|
|
181
|
+
assert.equal(c.disposition, "HANDOFF");
|
|
182
|
+
const r = await routeHandoff(ITEM, c, deps);
|
|
183
|
+
assert.equal(r.ok, true);
|
|
184
|
+
assert.equal(r.primitive, "handoff.offer");
|
|
185
|
+
assert.equal(calls.board[0].assignee, PEER, "the board item must be assigned to whoever is doing it");
|
|
186
|
+
const env = calls.handoff[0];
|
|
187
|
+
assert.equal(env.to, PEER);
|
|
188
|
+
assert.equal(env.from, SELF);
|
|
189
|
+
assert.equal(env.itemId, "bi_1");
|
|
190
|
+
assert.equal(env.payload.objectiveKey, "pipeline");
|
|
191
|
+
assert.equal(env.payload.obligationKey, "outcome.pipeline");
|
|
192
|
+
assert.deepEqual(env.payload.successCriteria, ["6 calls booked"]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("HANDOFF that is not sent leaves the item effectively unassigned — and says so", async () => {
|
|
196
|
+
const { calls, deps } = spy({ sendHandoff: async () => ({ sent: false, transport: { reason: "no-channel" } }) });
|
|
197
|
+
const c = classify({ title: ITEM.title, advances: ["pipeline"], collaborators: [{ memberId: PEER, role: "DOER" }] });
|
|
198
|
+
const r = await routeHandoff(ITEM, c, deps);
|
|
199
|
+
assert.equal(r.ok, false);
|
|
200
|
+
assert.equal(r.reason, "no-channel");
|
|
201
|
+
assert.ok(calls.logs.some((l) => /was NOT sent/.test(l)));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("HANDOFF that throws still leaves the work item behind", async () => {
|
|
205
|
+
const { calls, deps } = spy({ sendHandoff: async () => { throw new Error("boom"); } });
|
|
206
|
+
const c = classify({ title: ITEM.title, advances: ["pipeline"], collaborators: [{ memberId: PEER, role: "DOER" }] });
|
|
207
|
+
const r = await routeHandoff(ITEM, c, deps);
|
|
208
|
+
assert.equal(r.ok, false);
|
|
209
|
+
assert.equal(r.reason, "handoff-threw");
|
|
210
|
+
assert.equal(r.itemId, "bi_1", "the work must not evaporate because the envelope failed");
|
|
211
|
+
assert.equal(calls.board.length, 1);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// NEEDS_REVIEW — approvals are the gate, and the gate holds
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
test("a gated action class goes through approval.request, carrying the classification", async () => {
|
|
219
|
+
const { calls, deps } = spy();
|
|
220
|
+
const c = classify({ title: "Wire the money", advances: ["pipeline"], actionClasses: ["financial"] });
|
|
221
|
+
assert.equal(c.disposition, "NEEDS_REVIEW");
|
|
222
|
+
const r = await routeNeedsReview({ ...ITEM, title: "Wire the money" }, c, deps);
|
|
223
|
+
assert.equal(r.primitive, "approval.request");
|
|
224
|
+
assert.equal(r.ok, true);
|
|
225
|
+
assert.deepEqual(calls.approval[0].classification, ["financial"]);
|
|
226
|
+
assert.equal(calls.approval[0].requester, SELF);
|
|
227
|
+
assert.equal(calls.approval[0].itemId, "bi_1");
|
|
228
|
+
assert.equal(calls.approval[0].payload.why.clause, "cs_12");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("APPROVAL GATE HOLDS: an approval that cannot be requested BLOCKS the work", async () => {
|
|
232
|
+
const { calls, deps } = spy({ requestApproval: async () => ({ requested: false, id: null }) });
|
|
233
|
+
const c = classify({ title: "Send the external note", advances: ["pipeline"], actionClasses: ["external"] });
|
|
234
|
+
const r = await routeNeedsReview(ITEM, c, deps);
|
|
235
|
+
assert.equal(r.ok, false);
|
|
236
|
+
assert.equal(r.blocked, true, "a gated action with no approval must NOT execute");
|
|
237
|
+
assert.ok(calls.logs.some((l) => /stays BLOCKED, not executed/.test(l)));
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("APPROVAL GATE HOLDS: a throwing approval service also blocks", async () => {
|
|
241
|
+
const { deps } = spy({ requestApproval: async () => { throw new Error("503"); } });
|
|
242
|
+
const c = classify({ title: "Delete the bucket", advances: ["pipeline"], actionClasses: ["irreversible"] });
|
|
243
|
+
const r = await routeNeedsReview(ITEM, c, deps);
|
|
244
|
+
assert.equal(r.ok, false);
|
|
245
|
+
assert.equal(r.reason, "approval-threw");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("a plain named reviewer does NOT consume an approval — it degrades to a review envelope, logged", async () => {
|
|
249
|
+
const { calls, deps } = spy();
|
|
250
|
+
const c = classify({ title: ITEM.title, advances: ["pipeline"], collaborators: [{ memberId: PEER, role: "REVIEWER" }] });
|
|
251
|
+
assert.equal(c.disposition, "NEEDS_REVIEW");
|
|
252
|
+
assert.deepEqual(c.gatedClasses, []);
|
|
253
|
+
const r = await routeNeedsReview(ITEM, c, deps);
|
|
254
|
+
assert.equal(calls.approval.length, 0, "a review is not an approval — do not burn the approval path on it");
|
|
255
|
+
// Either the first-class method (once hq ships it) or the logged fallback.
|
|
256
|
+
assert.ok(["board.requestReview", "handoff.offer(review)"].includes(r.primitive));
|
|
257
|
+
if (r.primitive === "handoff.offer(review)") {
|
|
258
|
+
assert.equal(r.degraded, true);
|
|
259
|
+
assert.ok(calls.logs.some((l) => /falling back to a REVIEW handoff/.test(l)),
|
|
260
|
+
"a degradation must be announced, not silent");
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Escalation — "ask, don't pile on"
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
test("an escalation carries concrete OPTIONS, not prose", async () => {
|
|
269
|
+
const { calls, deps } = spy();
|
|
270
|
+
const r = await raiseEscalation({
|
|
271
|
+
objectiveKey: "pipeline",
|
|
272
|
+
question: "Coverage is flat at 1.5 against a target of 3. What should change?",
|
|
273
|
+
options: [{ label: "Re-target" }, { label: "Add resourcing" }, "Accept the miss"],
|
|
274
|
+
gap: { gap: 1.5, target: 3, trend: "flat" },
|
|
275
|
+
}, deps);
|
|
276
|
+
assert.equal(r.ok, true);
|
|
277
|
+
const call = calls.rpc[0];
|
|
278
|
+
assert.match(call.method, /^escalation\./);
|
|
279
|
+
assert.equal(call.params.options.length, 3);
|
|
280
|
+
// A bare string option is normalised to {label} so the consumer has one shape.
|
|
281
|
+
assert.deepEqual(call.params.options[2], { label: "Accept the miss" });
|
|
282
|
+
assert.equal(call.params.objectiveKey, "pipeline");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("SERVER UNREACHABLE: a failed escalation is reported, and the question survives in the ledger", async () => {
|
|
286
|
+
const { calls, deps } = spy({ callImpl: async () => { throw new Error("ECONNREFUSED"); } });
|
|
287
|
+
const r = await raiseEscalation({ objectiveKey: "pipeline", question: "?", options: [] }, deps);
|
|
288
|
+
assert.equal(r.ok, false);
|
|
289
|
+
assert.equal(r.reason, "escalation-threw");
|
|
290
|
+
assert.ok(calls.logs.some((l) => l.startsWith("warn:")));
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("an escalation error FRAME still records the question", async () => {
|
|
294
|
+
const { calls, deps } = spy({ callImpl: async () => ({ ok: false, error: { code: "FORBIDDEN" } }) });
|
|
295
|
+
const r = await raiseEscalation({ objectiveKey: "pipeline", question: "?", options: [] }, deps);
|
|
296
|
+
assert.equal(r.ok, false);
|
|
297
|
+
assert.equal(r.reason, "FORBIDDEN");
|
|
298
|
+
assert.ok(calls.logs.some((l) => /still recorded in the audit ledger/.test(l)));
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// The dispatcher
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
test("routeWorkItem dispatches each disposition to its own primitive", async () => {
|
|
306
|
+
for (const [collaborators, actionClasses, expected] of [
|
|
307
|
+
[[], [], "board.create"],
|
|
308
|
+
[[{ memberId: PEER, role: "DOER" }], [], "handoff.offer"],
|
|
309
|
+
[[{ memberId: PEER, role: "CONTRIBUTOR" }], [], "lease+board"],
|
|
310
|
+
[[], ["financial"], "approval.request"],
|
|
311
|
+
]) {
|
|
312
|
+
const { deps } = spy();
|
|
313
|
+
const c = classify({ title: ITEM.title, advances: ["pipeline"], collaborators, actionClasses });
|
|
314
|
+
const r = await routeWorkItem(ITEM, c, deps);
|
|
315
|
+
assert.equal(r.primitive, expected, `${c.disposition} must route to ${expected}`);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("an unknown disposition falls back to SELF rather than dropping the work", async () => {
|
|
320
|
+
const { calls, deps } = spy();
|
|
321
|
+
const r = await routeWorkItem(ITEM, { disposition: "NONSENSE", contributors: [], gatedClasses: [] }, deps);
|
|
322
|
+
assert.equal(r.routed, "SELF");
|
|
323
|
+
assert.equal(calls.board.length, 1);
|
|
324
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/goals/gaps.mjs — the read-only bridge between the goal loop's measured
|
|
3
|
+
* KPI series and the daemon's backlog sweep.
|
|
4
|
+
*
|
|
5
|
+
* The goal-steward (`lib/goals/loop.mjs`) MEASURES and writes
|
|
6
|
+
* `state/kpi/measurements.jsonl`. The daemon's `sweepBacklog()` DISPATCHES.
|
|
7
|
+
* Between them sits exactly one question: "how badly is each objective missed
|
|
8
|
+
* right now?" — so that `lib/backlog.rankBacklog` can float work that advances
|
|
9
|
+
* a lagging objective above work that advances nothing.
|
|
10
|
+
*
|
|
11
|
+
* This module answers that question from local state ONLY: the cached mandate
|
|
12
|
+
* plus the local measurement series. No network, no LLM, no sub-session. The
|
|
13
|
+
* daemon's hot path must never block on hq, and a partitioned agent must still
|
|
14
|
+
* rank its backlog sensibly.
|
|
15
|
+
*
|
|
16
|
+
* Fail-open, never silent: a missing/corrupt cache returns `{}` **and logs**.
|
|
17
|
+
* An empty map means the ranking degrades to exactly today's flat priority
|
|
18
|
+
* ordering, which is the correct fallback.
|
|
19
|
+
*
|
|
20
|
+
* @module lib/goals/gaps
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
"use strict";
|
|
24
|
+
|
|
25
|
+
import { resolveAgentRoot } from "../agent-root.mjs";
|
|
26
|
+
import { readCache, stalenessTier } from "../mandate/cache.mjs";
|
|
27
|
+
import { adoptedObjectives, memberIdOf, validateMandateBody } from "../mandate/model.mjs";
|
|
28
|
+
import { readSeries, gapFor } from "../kpi.mjs";
|
|
29
|
+
|
|
30
|
+
function logOf(deps) {
|
|
31
|
+
return deps && typeof deps.log === "function" ? deps.log : () => {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Latest gap per adopted objective, keyed by objective key.
|
|
36
|
+
*
|
|
37
|
+
* Only ADOPTED objectives are included — a proposed objective drives no work,
|
|
38
|
+
* so it must not influence dispatch order either. Self-adopted objectives are
|
|
39
|
+
* excluded by `adoptedObjectives` (the anti-Goodhart law): a seat cannot float
|
|
40
|
+
* its own backlog by grading itself.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} [agentRoot]
|
|
43
|
+
* @param {object} [deps] - { cacheRecord?, readSeries?, now?, log?, memberId? }
|
|
44
|
+
* @returns {Object<string, object>} objectiveKey → lib/kpi.gapFor() output
|
|
45
|
+
*/
|
|
46
|
+
export function readLatestGaps(agentRoot, deps = {}) {
|
|
47
|
+
const log = logOf(deps);
|
|
48
|
+
let root;
|
|
49
|
+
try {
|
|
50
|
+
root = resolveAgentRoot(agentRoot);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
log("warn", `[goals] cannot resolve the agent root for gap ranking: ${err && err.message ? err.message : err}`);
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let record;
|
|
57
|
+
try {
|
|
58
|
+
record = deps.cacheRecord !== undefined ? deps.cacheRecord : readCache(root);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
log("warn", `[goals] mandate cache unreadable — backlog falls back to flat priority order: ${err && err.message ? err.message : err}`);
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
if (!record) {
|
|
64
|
+
// Not an error: an agent with no adopted mandate is the normal pre-adoption
|
|
65
|
+
// state. Logged at info so it is visible, not silent.
|
|
66
|
+
log("info", "[goals] no mandate cache — backlog ranks on flat priority order");
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
if (record.corrupt) {
|
|
70
|
+
log("warn", `[goals] mandate cache is corrupt (${record.reason}) — backlog falls back to flat priority order`);
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A mandate nobody has refreshed in over a week must not keep steering
|
|
75
|
+
// dispatch: the staleness ladder suspends OUTCOME work at >72h, and ranking
|
|
76
|
+
// on a week-old target is worse than not ranking at all.
|
|
77
|
+
const tier = stalenessTier(record, deps.now ? deps.now() : Date.now());
|
|
78
|
+
if (tier === "expired") {
|
|
79
|
+
log("warn", "[goals] mandate cache is expired (>7d) — backlog falls back to flat priority order");
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const memberId = deps.memberId || memberIdOf(record) || null;
|
|
84
|
+
let objectives;
|
|
85
|
+
try {
|
|
86
|
+
const validation = validateMandateBody(record, { ownerMemberId: memberId });
|
|
87
|
+
objectives = adoptedObjectives(record, { ownerMemberId: memberId, badKeys: validation.badKeys });
|
|
88
|
+
} catch (err) {
|
|
89
|
+
log("warn", `[goals] mandate did not validate for gap ranking: ${err && err.message ? err.message : err}`);
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const read = typeof deps.readSeries === "function" ? deps.readSeries : (r, k) => readSeries(r, k);
|
|
94
|
+
const out = {};
|
|
95
|
+
for (const objective of objectives) {
|
|
96
|
+
if (!objective || !objective.key) continue;
|
|
97
|
+
try {
|
|
98
|
+
const series = read(root, objective.key) || [];
|
|
99
|
+
const gap = gapFor(objective, series, deps);
|
|
100
|
+
// An unmeasured or stale objective contributes no urgency. Recording it
|
|
101
|
+
// with a zeroed gap keeps the map complete for callers that want to
|
|
102
|
+
// report coverage without letting it distort the ranking.
|
|
103
|
+
out[objective.key] = gap.stale ? { ...gap, normalizedGap: 0 } : gap;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
log("warn", `[goals] could not compute the gap for "${objective.key}": ${err && err.message ? err.message : err}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export default { readLatestGaps };
|