@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,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/execution/match.mjs — bind a directed event to the obligation that covers it.
|
|
3
|
+
*
|
|
4
|
+
* `disposition.decide` takes an `obligation` argument and changes its behaviour
|
|
5
|
+
* sharply on it: with one, the event is planned work and may route up the rungs
|
|
6
|
+
* on the obligation's tool scope and budget; without one, `finish()` caps the
|
|
7
|
+
* rung at ≤1 and opens `PlanDrift{uncovered_event}`. Nothing in the tree has
|
|
8
|
+
* ever supplied that argument — the compiler emits REACT obligations with a
|
|
9
|
+
* `trigger {topic, kind, predicate}` (lib/plan/compile.mjs) and nobody reads
|
|
10
|
+
* them back. Every event therefore looks uncovered, which makes the drift signal
|
|
11
|
+
* useless and pins the whole reactive lane at rung 1.
|
|
12
|
+
*
|
|
13
|
+
* This module is that missing read. It is the second half of the §6.1 step
|
|
14
|
+
* "match a REACT obligation by (topic, kind, predicate)", and it also produces
|
|
15
|
+
* the OTHER half of the same sentence — the proposed REACT obligation an
|
|
16
|
+
* uncovered event should generate, "so the plan learns instead of ossifying".
|
|
17
|
+
*
|
|
18
|
+
* Matching is DETERMINISTIC and explains itself. Two obligations that both match
|
|
19
|
+
* are ordered by specificity and then by key, so the same event always binds to
|
|
20
|
+
* the same obligation, and `why[]` says which lost and why.
|
|
21
|
+
*
|
|
22
|
+
* PURE apart from {@link loadReactObligations}, which reads `config/plan.yaml`
|
|
23
|
+
* through the loader `lib/plan/explain.mjs` already owns.
|
|
24
|
+
*
|
|
25
|
+
* @module lib/execution/match
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
"use strict";
|
|
29
|
+
|
|
30
|
+
import { readPlan } from "../plan/explain.mjs";
|
|
31
|
+
import { validateObligation } from "../plan/schema.mjs";
|
|
32
|
+
import { policyFor } from "./surface-policy.mjs";
|
|
33
|
+
|
|
34
|
+
/** Obligation statuses that may bind an event. `suspended`/`proposed` may not. */
|
|
35
|
+
export const BINDABLE_STATUSES = Object.freeze(["active"]);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Predicate fields a REACT trigger may constrain. Deliberately small and
|
|
39
|
+
* CLOSED: a predicate naming anything else does not match, and says so, rather
|
|
40
|
+
* than being ignored — an unmatchable predicate that silently passes would bind
|
|
41
|
+
* events the plan never intended to cover.
|
|
42
|
+
*/
|
|
43
|
+
export const PREDICATE_FIELDS = Object.freeze([
|
|
44
|
+
"surface",
|
|
45
|
+
"reason",
|
|
46
|
+
"tier",
|
|
47
|
+
"actor",
|
|
48
|
+
"family",
|
|
49
|
+
"kind",
|
|
50
|
+
"topic",
|
|
51
|
+
"channelId",
|
|
52
|
+
"objectiveId",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/** Blank-safe string. */
|
|
56
|
+
function s(v) {
|
|
57
|
+
return v == null ? "" : String(v);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The set of `kind` tokens an event can legitimately answer a trigger with.
|
|
62
|
+
*
|
|
63
|
+
* A raw chain event has chain coordinates (`family:"board", kind:"assigned"`),
|
|
64
|
+
* but the same event reconstructed from a flattened inbox item has the SURFACE
|
|
65
|
+
* as its kind (`task_assigned`) because that is all `raw_ref` preserved. Both
|
|
66
|
+
* must bind the obligation `{topic:"task", kind:"assigned"}` or an event's
|
|
67
|
+
* coverage would depend on which delivery path it happened to arrive by.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} candidate @param {object} verdict @returns {string[]}
|
|
70
|
+
*/
|
|
71
|
+
export function kindAliases(candidate = {}, verdict = {}) {
|
|
72
|
+
const out = new Set();
|
|
73
|
+
const kind = s(candidate.kind);
|
|
74
|
+
const surface = s(verdict.surface);
|
|
75
|
+
if (kind) {
|
|
76
|
+
out.add(kind);
|
|
77
|
+
// "messaging.send" → "send"; a legacy dotted kind that skipped coordsOf.
|
|
78
|
+
if (kind.includes(".")) out.add(kind.slice(kind.indexOf(".") + 1));
|
|
79
|
+
// "task_assigned" → "assigned"
|
|
80
|
+
if (kind.includes("_")) out.add(kind.slice(kind.indexOf("_") + 1));
|
|
81
|
+
}
|
|
82
|
+
if (surface) {
|
|
83
|
+
out.add(surface);
|
|
84
|
+
if (surface.includes("_")) out.add(surface.slice(surface.indexOf("_") + 1));
|
|
85
|
+
}
|
|
86
|
+
out.delete("");
|
|
87
|
+
return Array.from(out);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The flat field view a predicate is evaluated against. */
|
|
91
|
+
export function predicateView(candidate = {}, verdict = {}, extra = {}) {
|
|
92
|
+
const ids = candidate.ids && typeof candidate.ids === "object" ? candidate.ids : {};
|
|
93
|
+
return {
|
|
94
|
+
surface: s(verdict.surface),
|
|
95
|
+
reason: s(verdict.reason),
|
|
96
|
+
tier: s(extra.tier),
|
|
97
|
+
actor: s(candidate.actor),
|
|
98
|
+
family: s(candidate.family),
|
|
99
|
+
kind: s(candidate.kind),
|
|
100
|
+
topic: s(candidate.topic),
|
|
101
|
+
channelId: s(ids.channelId),
|
|
102
|
+
objectiveId: s(extra.objectiveId),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Evaluate a trigger predicate. Returns `{ok, why}`.
|
|
108
|
+
*
|
|
109
|
+
* Accepts `null`/absent (always matches), an object of `field: value` (all must
|
|
110
|
+
* match; a value may be an array, meaning "any of"), and nothing else. A
|
|
111
|
+
* predicate shape this does not understand FAILS the match and names itself in
|
|
112
|
+
* `why` — fail-closed, because the alternative is an obligation binding traffic
|
|
113
|
+
* its author scoped away.
|
|
114
|
+
*
|
|
115
|
+
* @param {object|null|undefined} predicate
|
|
116
|
+
* @param {object} view
|
|
117
|
+
* @returns {{ok:boolean, why:string}}
|
|
118
|
+
*/
|
|
119
|
+
export function evalPredicate(predicate, view) {
|
|
120
|
+
if (predicate == null) return { ok: true, why: "no predicate" };
|
|
121
|
+
if (typeof predicate !== "object" || Array.isArray(predicate)) {
|
|
122
|
+
return { ok: false, why: `predicate is a ${Array.isArray(predicate) ? "array" : typeof predicate}, not a field map — cannot evaluate` };
|
|
123
|
+
}
|
|
124
|
+
const entries = Object.entries(predicate);
|
|
125
|
+
if (entries.length === 0) return { ok: true, why: "empty predicate" };
|
|
126
|
+
for (const [field, want] of entries) {
|
|
127
|
+
if (!PREDICATE_FIELDS.includes(field)) {
|
|
128
|
+
return { ok: false, why: `predicate names unknown field "${field}" (known: ${PREDICATE_FIELDS.join(", ")})` };
|
|
129
|
+
}
|
|
130
|
+
const got = s(view[field]);
|
|
131
|
+
const list = Array.isArray(want) ? want.map(s) : [s(want)];
|
|
132
|
+
if (!list.includes(got)) {
|
|
133
|
+
return { ok: false, why: `predicate ${field}=${list.join("|")} but event has "${got}"` };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { ok: true, why: `predicate matched on ${entries.map(([k]) => k).join(", ")}` };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The REACT obligations in a plan, in key order. */
|
|
140
|
+
export function reactObligations(plan) {
|
|
141
|
+
const list = plan && Array.isArray(plan.obligations) ? plan.obligations : [];
|
|
142
|
+
return list
|
|
143
|
+
.filter((o) => o && o.kind === "REACT")
|
|
144
|
+
.slice()
|
|
145
|
+
.sort((a, b) => (s(a.key) < s(b.key) ? -1 : s(a.key) > s(b.key) ? 1 : 0));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Find the obligation covering one directed event.
|
|
150
|
+
*
|
|
151
|
+
* Specificity order, most specific first: an obligation constraining kind AND a
|
|
152
|
+
* predicate beats one constraining kind alone, which beats a topic-only
|
|
153
|
+
* catch-all. Ties break on the key so the choice is reproducible.
|
|
154
|
+
*
|
|
155
|
+
* @param {object} o
|
|
156
|
+
* @param {object} o.candidate
|
|
157
|
+
* @param {object} o.verdict
|
|
158
|
+
* @param {object[]|object} o.plan a plan object or a bare obligations array
|
|
159
|
+
* @param {string} [o.tier] T0/T1, for predicates that constrain it
|
|
160
|
+
* @returns {{obligation:object|null, why:string[], considered:number, suspended:object[]}}
|
|
161
|
+
*/
|
|
162
|
+
export function matchObligation(o = {}) {
|
|
163
|
+
const why = [];
|
|
164
|
+
const candidate = o.candidate && typeof o.candidate === "object" ? o.candidate : null;
|
|
165
|
+
const verdict = o.verdict && typeof o.verdict === "object" ? o.verdict : null;
|
|
166
|
+
const plan = Array.isArray(o.plan) ? { obligations: o.plan } : o.plan;
|
|
167
|
+
const suspended = [];
|
|
168
|
+
|
|
169
|
+
if (!candidate || !verdict || verdict.directed !== true) {
|
|
170
|
+
why.push("no directed event to match — an undirected event needs no obligation");
|
|
171
|
+
return { obligation: null, why, considered: 0, suspended };
|
|
172
|
+
}
|
|
173
|
+
const reacts = reactObligations(plan);
|
|
174
|
+
if (reacts.length === 0) {
|
|
175
|
+
why.push("the compiled plan contains no REACT obligations — every event will read as uncovered");
|
|
176
|
+
return { obligation: null, why, considered: 0, suspended };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const topic = s(candidate.topic);
|
|
180
|
+
const surface = s(verdict.surface);
|
|
181
|
+
const aliases = kindAliases(candidate, verdict);
|
|
182
|
+
const view = predicateView(candidate, verdict, { tier: o.tier, objectiveId: o.objectiveId });
|
|
183
|
+
|
|
184
|
+
const hits = [];
|
|
185
|
+
for (const ob of reacts) {
|
|
186
|
+
const trig = ob.trigger && typeof ob.trigger === "object" ? ob.trigger : null;
|
|
187
|
+
if (!trig || !trig.topic) continue; // schema already rejects these; skip quietly
|
|
188
|
+
const wantTopic = s(trig.topic);
|
|
189
|
+
// A trigger may be written against the coarse topic ("task") or against the
|
|
190
|
+
// surface ("task_assigned"). Both are legitimate ways to say the same thing.
|
|
191
|
+
if (wantTopic !== topic && wantTopic !== surface) continue;
|
|
192
|
+
|
|
193
|
+
const wantKind = trig.kind == null ? null : s(trig.kind);
|
|
194
|
+
if (wantKind !== null && !aliases.includes(wantKind)) continue;
|
|
195
|
+
|
|
196
|
+
const pred = evalPredicate(trig.predicate, view);
|
|
197
|
+
if (!pred.ok) {
|
|
198
|
+
why.push(`${ob.key}: topic/kind matched but ${pred.why}`);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!BINDABLE_STATUSES.includes(s(ob.status))) {
|
|
203
|
+
// NEVER SILENT. A suspended obligation matching is materially different
|
|
204
|
+
// from nothing matching: the plan DOES cover this event, it is just
|
|
205
|
+
// switched off, and a reader must not confuse that with a coverage gap.
|
|
206
|
+
suspended.push(ob);
|
|
207
|
+
why.push(`${ob.key} covers this event but is status "${s(ob.status) || "(none)"}" → not bindable`);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const specificity = (wantKind !== null ? 2 : 0) + (trig.predicate ? 1 : 0);
|
|
212
|
+
hits.push({ ob, specificity });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (hits.length === 0) {
|
|
216
|
+
why.push(
|
|
217
|
+
suspended.length
|
|
218
|
+
? `no bindable REACT obligation (${suspended.length} matched but suspended)`
|
|
219
|
+
: `no REACT obligation covers topic "${topic}"/surface "${surface}" kind ${JSON.stringify(aliases)}`,
|
|
220
|
+
);
|
|
221
|
+
return { obligation: null, why, considered: reacts.length, suspended };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
hits.sort((a, b) => (b.specificity - a.specificity) || (s(a.ob.key) < s(b.ob.key) ? -1 : 1));
|
|
225
|
+
const winner = hits[0].ob;
|
|
226
|
+
why.push(`bound to ${winner.key} (specificity ${hits[0].specificity} of ${hits.length} match(es))`);
|
|
227
|
+
for (const h of hits.slice(1)) why.push(`also matched, less specific: ${h.ob.key}`);
|
|
228
|
+
return { obligation: winner, why, considered: reacts.length, suspended };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The proposed REACT obligation an uncovered event should generate.
|
|
233
|
+
*
|
|
234
|
+
* §6.1: a T0/T1 event with no matching obligation is handled once at rung ≤1
|
|
235
|
+
* **and** emits both a drift record and a proposed obligation. The drift half
|
|
236
|
+
* already exists in `disposition.finish`; this is the half that lets the plan
|
|
237
|
+
* actually learn — a human reviewing `state/plan/proposed.jsonl` sees a
|
|
238
|
+
* ready-to-adopt obligation rather than a complaint.
|
|
239
|
+
*
|
|
240
|
+
* Emitted with `status:"proposed"` and an EMPTY `uses[]`: the capability law
|
|
241
|
+
* (§5.1) says an obligation may not cite a capability without a reachable
|
|
242
|
+
* manifest entry, and nothing here has probed one. It is validated against the
|
|
243
|
+
* real schema before being returned, so an invalid proposal is dropped loudly
|
|
244
|
+
* rather than written to the proposal file for a human to trip over.
|
|
245
|
+
*
|
|
246
|
+
* @param {object} o { candidate, verdict, tier }
|
|
247
|
+
* @returns {{obligation:object|null, errors:string[], why:string}}
|
|
248
|
+
*/
|
|
249
|
+
export function proposeReact(o = {}) {
|
|
250
|
+
const candidate = o.candidate && typeof o.candidate === "object" ? o.candidate : {};
|
|
251
|
+
const verdict = o.verdict && typeof o.verdict === "object" ? o.verdict : {};
|
|
252
|
+
const surface = s(verdict.surface);
|
|
253
|
+
const topic = s(candidate.topic) || surface;
|
|
254
|
+
if (!topic) {
|
|
255
|
+
return { obligation: null, errors: ["no topic or surface to propose against"], why: "unclassified event" };
|
|
256
|
+
}
|
|
257
|
+
const kind = s(candidate.kind) || surface || null;
|
|
258
|
+
const slug = (v) => s(v).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
259
|
+
const key = `react.${slug(topic)}${kind ? `.${slug(kind)}` : ""}`;
|
|
260
|
+
const policy = policyFor(surface);
|
|
261
|
+
|
|
262
|
+
const obligation = {
|
|
263
|
+
key,
|
|
264
|
+
kind: "REACT",
|
|
265
|
+
source: { origin: "react-standard", ref: null, proposed_from: { surface, reason: s(verdict.reason), tier: s(o.tier) } },
|
|
266
|
+
trigger: { topic, kind, predicate: surface ? { surface } : null },
|
|
267
|
+
status: "proposed",
|
|
268
|
+
uses: [],
|
|
269
|
+
allowed_tools: [],
|
|
270
|
+
action_classes: Array.from(policy.actionClasses || []),
|
|
271
|
+
offline_safe: policy.offlineSafe !== false,
|
|
272
|
+
budget_cents_per_period: null,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const errors = validateObligation(obligation);
|
|
276
|
+
if (errors.length) {
|
|
277
|
+
return { obligation: null, errors, why: `proposal failed schema validation: ${errors.join("; ")}` };
|
|
278
|
+
}
|
|
279
|
+
return { obligation, errors: [], why: `proposing ${key} to cover the uncovered ${surface || topic} event` };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Load the REACT obligations from `config/plan.yaml`.
|
|
284
|
+
*
|
|
285
|
+
* Async only because the YAML loader is a dynamic import — `lib/plan/explain.mjs`
|
|
286
|
+
* takes the same approach so there is one plan-reading path, not two. Never
|
|
287
|
+
* throws: a missing or unparseable plan returns an empty list WITH a reason, and
|
|
288
|
+
* an agent with no compiled plan legitimately has none (§5.9 — until a human
|
|
289
|
+
* adopts, the agent runs standard cadences only).
|
|
290
|
+
*
|
|
291
|
+
* @param {string} agentRoot
|
|
292
|
+
* @param {object} [deps]
|
|
293
|
+
* @returns {Promise<{obligations:object[], enforcement:string|null, degraded:boolean, reason:string|null}>}
|
|
294
|
+
*/
|
|
295
|
+
export async function loadReactObligations(agentRoot, deps = {}) {
|
|
296
|
+
let yaml = deps.yaml || null;
|
|
297
|
+
if (!yaml) {
|
|
298
|
+
try {
|
|
299
|
+
yaml = (await import("js-yaml")).default;
|
|
300
|
+
} catch (err) {
|
|
301
|
+
return {
|
|
302
|
+
obligations: [],
|
|
303
|
+
enforcement: null,
|
|
304
|
+
degraded: true,
|
|
305
|
+
reason: `js-yaml unavailable: ${err && err.message ? err.message : String(err)}`,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const plan = (deps.readPlan || readPlan)(agentRoot, { ...deps, yaml });
|
|
310
|
+
if (!plan) {
|
|
311
|
+
return { obligations: [], enforcement: null, degraded: true, reason: "config/plan.yaml absent or unparseable" };
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
obligations: reactObligations(plan),
|
|
315
|
+
enforcement: plan.enforcement ? s(plan.enforcement) : null,
|
|
316
|
+
degraded: false,
|
|
317
|
+
reason: null,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export default {
|
|
322
|
+
matchObligation,
|
|
323
|
+
proposeReact,
|
|
324
|
+
reactObligations,
|
|
325
|
+
loadReactObligations,
|
|
326
|
+
kindAliases,
|
|
327
|
+
predicateView,
|
|
328
|
+
evalPredicate,
|
|
329
|
+
BINDABLE_STATUSES,
|
|
330
|
+
PREDICATE_FIELDS,
|
|
331
|
+
};
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/execution/match.test.mjs — binding events to the compiled plan.
|
|
3
|
+
*
|
|
4
|
+
* The negatives here are the ones that keep the plan honest: a suspended
|
|
5
|
+
* obligation must NOT bind (and must not look like a coverage gap either), an
|
|
6
|
+
* unparseable predicate must fail closed rather than matching everything, and an
|
|
7
|
+
* event genuinely nobody planned for must produce a proposal the schema accepts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
matchObligation,
|
|
15
|
+
proposeReact,
|
|
16
|
+
reactObligations,
|
|
17
|
+
kindAliases,
|
|
18
|
+
evalPredicate,
|
|
19
|
+
predicateView,
|
|
20
|
+
loadReactObligations,
|
|
21
|
+
PREDICATE_FIELDS,
|
|
22
|
+
} from "./match.mjs";
|
|
23
|
+
import { validateObligation } from "../plan/schema.mjs";
|
|
24
|
+
import { fromInboxItem } from "./intake.mjs";
|
|
25
|
+
import { decide } from "./disposition.mjs";
|
|
26
|
+
|
|
27
|
+
const ME = "mem_self";
|
|
28
|
+
|
|
29
|
+
function ob(over = {}) {
|
|
30
|
+
return {
|
|
31
|
+
key: "react.task.assigned",
|
|
32
|
+
kind: "REACT",
|
|
33
|
+
source: { origin: "react-standard" },
|
|
34
|
+
trigger: { topic: "task", kind: "assigned", predicate: null },
|
|
35
|
+
status: "active",
|
|
36
|
+
uses: [],
|
|
37
|
+
allowed_tools: [],
|
|
38
|
+
action_classes: [],
|
|
39
|
+
offline_safe: true,
|
|
40
|
+
...over,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function boardEvent() {
|
|
45
|
+
return {
|
|
46
|
+
candidate: {
|
|
47
|
+
seq: 90,
|
|
48
|
+
family: "board",
|
|
49
|
+
kind: "assigned",
|
|
50
|
+
entityId: "task_1",
|
|
51
|
+
actor: "mem_peer",
|
|
52
|
+
at: "2026-08-11T09:00:00.000Z",
|
|
53
|
+
topic: "task",
|
|
54
|
+
surfaces: ["task_assigned"],
|
|
55
|
+
ids: { taskId: "task_1", channelId: "chan_1" },
|
|
56
|
+
directTo: [ME],
|
|
57
|
+
payload: {},
|
|
58
|
+
},
|
|
59
|
+
verdict: { directed: true, surface: "task_assigned", reason: "assignee" },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// aliases + predicates
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
test("kind aliases bridge chain coordinates and surface names", () => {
|
|
68
|
+
const a = kindAliases({ kind: "assigned" }, { surface: "task_assigned" });
|
|
69
|
+
assert.ok(a.includes("assigned"));
|
|
70
|
+
assert.ok(a.includes("task_assigned"));
|
|
71
|
+
// The reconstructed-from-disk form must reach the same obligation.
|
|
72
|
+
const b = kindAliases({ kind: "task_assigned" }, { surface: "task_assigned" });
|
|
73
|
+
assert.ok(b.includes("assigned"), "an event rebuilt from a flattened item must still bind");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a predicate matches on a known field, including any-of lists", () => {
|
|
77
|
+
const view = predicateView({ actor: "mem_peer", topic: "task" }, { surface: "task_assigned", reason: "assignee" });
|
|
78
|
+
assert.equal(evalPredicate(null, view).ok, true);
|
|
79
|
+
assert.equal(evalPredicate({}, view).ok, true);
|
|
80
|
+
assert.equal(evalPredicate({ surface: "task_assigned" }, view).ok, true);
|
|
81
|
+
assert.equal(evalPredicate({ reason: ["assignee", "reviewer"] }, view).ok, true);
|
|
82
|
+
assert.equal(evalPredicate({ reason: "reviewer" }, view).ok, false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("NEGATIVE: a predicate this module cannot evaluate FAILS CLOSED", () => {
|
|
86
|
+
const view = predicateView({}, {});
|
|
87
|
+
const unknown = evalPredicate({ mood: "urgent" }, view);
|
|
88
|
+
assert.equal(unknown.ok, false, "an unknown field must not be ignored — that would widen the obligation");
|
|
89
|
+
assert.ok(unknown.why.includes("mood"));
|
|
90
|
+
assert.equal(evalPredicate("reason=mention", view).ok, false, "a string predicate is not a field map");
|
|
91
|
+
assert.equal(evalPredicate([{ surface: "dm" }], view).ok, false);
|
|
92
|
+
for (const f of PREDICATE_FIELDS) assert.equal(evalPredicate({ [f]: "" }, view).ok, true, `${f} should compare blank-to-blank`);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// matching
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
test("an event binds the obligation whose trigger covers it", () => {
|
|
100
|
+
const r = matchObligation({ ...boardEvent(), plan: [ob()] });
|
|
101
|
+
assert.equal(r.obligation.key, "react.task.assigned");
|
|
102
|
+
assert.ok(r.why.join(" ").includes("bound to react.task.assigned"));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a trigger may be written against the surface as well as the coarse topic", () => {
|
|
106
|
+
const r = matchObligation({ ...boardEvent(), plan: [ob({ key: "react.surface", trigger: { topic: "task_assigned", kind: null } })] });
|
|
107
|
+
assert.equal(r.obligation.key, "react.surface");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("the most specific match wins, deterministically", () => {
|
|
111
|
+
const broad = ob({ key: "react.task.any", trigger: { topic: "task", kind: null, predicate: null } });
|
|
112
|
+
const narrow = ob({ key: "react.task.assigned", trigger: { topic: "task", kind: "assigned", predicate: { reason: "assignee" } } });
|
|
113
|
+
const a = matchObligation({ ...boardEvent(), plan: [broad, narrow] });
|
|
114
|
+
const b = matchObligation({ ...boardEvent(), plan: [narrow, broad] });
|
|
115
|
+
assert.equal(a.obligation.key, "react.task.assigned");
|
|
116
|
+
assert.equal(b.obligation.key, a.obligation.key, "input order must not change the binding");
|
|
117
|
+
assert.ok(a.why.some((w) => w.includes("also matched, less specific")));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("NEGATIVE: a suspended obligation does not bind, and does not read as a coverage gap", () => {
|
|
121
|
+
const r = matchObligation({ ...boardEvent(), plan: [ob({ status: "suspended" })] });
|
|
122
|
+
assert.equal(r.obligation, null);
|
|
123
|
+
assert.deepEqual(r.suspended.map((x) => x.key), ["react.task.assigned"]);
|
|
124
|
+
assert.ok(r.why.join(" ").includes('status "suspended"'), "the reason must distinguish suspended from uncovered");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("NEGATIVE: a proposed obligation is not bindable either", () => {
|
|
128
|
+
const r = matchObligation({ ...boardEvent(), plan: [ob({ status: "proposed" })] });
|
|
129
|
+
assert.equal(r.obligation, null);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("NEGATIVE: an undirected event is never matched", () => {
|
|
133
|
+
const r = matchObligation({
|
|
134
|
+
candidate: boardEvent().candidate,
|
|
135
|
+
verdict: { directed: false, reason: "ambient_channel", surface: null },
|
|
136
|
+
plan: [ob({ trigger: { topic: "task", kind: null } })],
|
|
137
|
+
});
|
|
138
|
+
assert.equal(r.obligation, null);
|
|
139
|
+
assert.ok(r.why[0].includes("no directed event"));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("NEGATIVE: a plan with no REACT obligations says so rather than failing silently", () => {
|
|
143
|
+
const r = matchObligation({ ...boardEvent(), plan: [{ key: "schedule.x", kind: "SCHEDULE" }] });
|
|
144
|
+
assert.equal(r.obligation, null);
|
|
145
|
+
assert.ok(r.why.join(" ").includes("no REACT obligations"));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("SCHEDULE and OUTCOME obligations are never considered for an inbound event", () => {
|
|
149
|
+
const list = reactObligations({ obligations: [ob(), { key: "outcome.x", kind: "OUTCOME" }, { key: "schedule.x", kind: "SCHEDULE" }] });
|
|
150
|
+
assert.deepEqual(list.map((x) => x.key), ["react.task.assigned"]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// binding changes the decision
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
test("binding an obligation lifts the rung cap the uncovered path imposes", () => {
|
|
158
|
+
const ev = boardEvent();
|
|
159
|
+
const need = { steps: 8, filesystem: true };
|
|
160
|
+
const uncovered = decide({ ...ev, me: ME, history: null, need });
|
|
161
|
+
const covered = decide({
|
|
162
|
+
...ev,
|
|
163
|
+
me: ME,
|
|
164
|
+
history: null,
|
|
165
|
+
need,
|
|
166
|
+
obligation: ob({ objective_id: "obj_1" }),
|
|
167
|
+
});
|
|
168
|
+
assert.ok(uncovered.rung <= 1, "an uncovered event must be capped at rung ≤1");
|
|
169
|
+
assert.ok(covered.rung > uncovered.rung, "a planned event may route to the rung the work actually needs");
|
|
170
|
+
assert.equal(covered.obligationKey, "react.task.assigned");
|
|
171
|
+
assert.ok(uncovered.drift && uncovered.drift.kind === "uncovered_event");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// proposals
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
test("an uncovered event proposes a schema-valid REACT obligation", () => {
|
|
179
|
+
const ev = boardEvent();
|
|
180
|
+
const p = proposeReact({ ...ev, tier: "T0" });
|
|
181
|
+
assert.ok(p.obligation, p.why);
|
|
182
|
+
assert.deepEqual(validateObligation(p.obligation), [], "the proposal must satisfy the real schema");
|
|
183
|
+
assert.equal(p.obligation.status, "proposed", "nothing an agent proposes is active");
|
|
184
|
+
assert.deepEqual(p.obligation.uses, [], "an unprobed proposal may not cite a capability (§5.1)");
|
|
185
|
+
assert.equal(p.obligation.trigger.topic, "task");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("a proposal inherits the surface's blast radius, so email proposals are external", () => {
|
|
189
|
+
const norm = fromInboxItem(
|
|
190
|
+
{ raw_ref: "cohort:email:mail_1:5", kind: "email", service: "cohort", timestamp: "2026-08-11T09:00:00.000Z", content: "hi", priority_signals: {} },
|
|
191
|
+
{ me: ME },
|
|
192
|
+
);
|
|
193
|
+
const p = proposeReact({ candidate: norm.candidate, verdict: norm.verdict, tier: "T0" });
|
|
194
|
+
assert.ok(p.obligation.action_classes.includes("external"));
|
|
195
|
+
assert.equal(p.obligation.offline_safe, false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("a proposal that would be invalid is dropped loudly, never written", () => {
|
|
199
|
+
const p = proposeReact({ candidate: {}, verdict: {} });
|
|
200
|
+
assert.equal(p.obligation, null);
|
|
201
|
+
assert.ok(p.errors.length > 0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("proposed keys are stable slugs, so the same gap proposes the same obligation twice", () => {
|
|
205
|
+
const ev = boardEvent();
|
|
206
|
+
const a = proposeReact({ ...ev, tier: "T0" });
|
|
207
|
+
const b = proposeReact({ ...ev, tier: "T1" });
|
|
208
|
+
assert.equal(a.obligation.key, b.obligation.key);
|
|
209
|
+
assert.match(a.obligation.key, /^[a-z0-9][a-z0-9._-]*$/);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// loading
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
test("a missing plan.yaml degrades with a reason instead of throwing", async () => {
|
|
217
|
+
const r = await loadReactObligations("/nonexistent/agent/root");
|
|
218
|
+
assert.deepEqual(r.obligations, []);
|
|
219
|
+
assert.equal(r.degraded, true);
|
|
220
|
+
assert.ok(r.reason, "a degradation must always carry a reason");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("a plan is read through the one loader, and its REACT obligations come back", async () => {
|
|
224
|
+
const plan = {
|
|
225
|
+
enforcement: "observe_only",
|
|
226
|
+
obligations: [ob(), { key: "schedule.a", kind: "SCHEDULE" }],
|
|
227
|
+
};
|
|
228
|
+
const r = await loadReactObligations("/anywhere", {
|
|
229
|
+
readPlan: () => plan,
|
|
230
|
+
yaml: { load: () => plan },
|
|
231
|
+
});
|
|
232
|
+
assert.equal(r.degraded, false);
|
|
233
|
+
assert.equal(r.enforcement, "observe_only");
|
|
234
|
+
assert.deepEqual(r.obligations.map((x) => x.key), ["react.task.assigned"]);
|
|
235
|
+
});
|