@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,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/goals/admission.mjs — the gates a self-directed candidate must clear.
|
|
3
|
+
*
|
|
4
|
+
* An agent that turns every gap into tasks is a spam generator. These are the
|
|
5
|
+
* hard gates between "the number is off" and "work exists", and they are all
|
|
6
|
+
* pure functions so the decision is reproducible from the audit line alone.
|
|
7
|
+
*
|
|
8
|
+
* honesty the latest sample's source ∈ {method, human}. An `llm`-sourced
|
|
9
|
+
* number is ADVISORY and can never create work. (The Goodhart gate:
|
|
10
|
+
* an agent that can invent its own measurement can invent its own
|
|
11
|
+
* justification for anything.)
|
|
12
|
+
* adoption the objective is `active` — adopted by somebody who is not its
|
|
13
|
+
* owner. A proposed objective admits nothing.
|
|
14
|
+
* wip ≤ MAX_OPEN_PER_OBJECTIVE open items per objective, and
|
|
15
|
+
* ≤ MAX_NEW_PER_RUN new items per steward run.
|
|
16
|
+
* trend gap > tolerance AND (nothing already in flight OR the trend has
|
|
17
|
+
* been flat for ≥ 2 periods). An improving number needs patience,
|
|
18
|
+
* not more tasks.
|
|
19
|
+
* budget estimated cost ≤ the remaining envelope.
|
|
20
|
+
* dedupe not a near-duplicate of an open item on the same objective.
|
|
21
|
+
* provenance non-empty `advances[]` and a populated `why`.
|
|
22
|
+
* freshness the mandate cache is fresh enough for OUTCOME work (§6.4).
|
|
23
|
+
*
|
|
24
|
+
* Rejected candidates are RETURNED with their gate and reason, never dropped:
|
|
25
|
+
* the loop audits every rejection. A silently dropped candidate is
|
|
26
|
+
* indistinguishable from a bug.
|
|
27
|
+
*
|
|
28
|
+
* PURE. No fs, no network, no clock (the clock arrives as `ctx.now`).
|
|
29
|
+
*
|
|
30
|
+
* @module lib/goals/admission
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
"use strict";
|
|
34
|
+
|
|
35
|
+
import { WORK_CREATING_SOURCES } from "../kpi.mjs";
|
|
36
|
+
|
|
37
|
+
/** Ordered gate names — evaluated in this order, first failure wins. */
|
|
38
|
+
export const GATES = Object.freeze([
|
|
39
|
+
"freshness",
|
|
40
|
+
"adoption",
|
|
41
|
+
"honesty",
|
|
42
|
+
"provenance",
|
|
43
|
+
"dedupe",
|
|
44
|
+
"trend",
|
|
45
|
+
"wip",
|
|
46
|
+
"budget",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/** Default caps. Overridable per call so an operator can tune without a fork. */
|
|
50
|
+
export const MAX_OPEN_PER_OBJECTIVE = 3;
|
|
51
|
+
export const MAX_NEW_PER_RUN = 2;
|
|
52
|
+
|
|
53
|
+
/** Jaccard similarity over word sets — the dedupe heuristic. Pure. */
|
|
54
|
+
export function titleSimilarity(a, b) {
|
|
55
|
+
const wa = new Set(String(a || "").toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2));
|
|
56
|
+
const wb = new Set(String(b || "").toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2));
|
|
57
|
+
if (wa.size === 0 || wb.size === 0) return 0;
|
|
58
|
+
let inter = 0;
|
|
59
|
+
for (const w of wa) if (wb.has(w)) inter += 1;
|
|
60
|
+
return inter / (wa.size + wb.size - inter);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Similarity at or above this counts as a duplicate. */
|
|
64
|
+
export const DUPLICATE_THRESHOLD = 0.6;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The decision table (SPEC §6.3). What should a gap PRODUCE, before any
|
|
68
|
+
* per-candidate gate runs?
|
|
69
|
+
*
|
|
70
|
+
* gap ≤ tolerance → "none" (leave it alone)
|
|
71
|
+
* gap > tolerance, nothing in flight → "create" (make work)
|
|
72
|
+
* gap > tolerance, work in flight, flat ≥2 periods → "escalate" (ask, don't pile on)
|
|
73
|
+
* gap > tolerance, improving → "log" (patience)
|
|
74
|
+
* sensor stale (≥2× cadence) → "drift" (fix the sensor first)
|
|
75
|
+
*
|
|
76
|
+
* @param {object} gap - lib/kpi.gapFor() output
|
|
77
|
+
* @param {object} [o] @param {number} [o.coverage] open items already on this objective
|
|
78
|
+
* @returns {{action:'none'|'create'|'escalate'|'log'|'drift', reason:string}}
|
|
79
|
+
*/
|
|
80
|
+
export function decideForGap(gap, o = {}) {
|
|
81
|
+
if (!gap || gap.gap === null) return { action: "drift", reason: "no measurement to decide on" };
|
|
82
|
+
if (gap.stale) return { action: "drift", reason: `sensor is stale (${(gap.stalenessPeriods || 0).toFixed(1)}× cadence with no sample)` };
|
|
83
|
+
if (gap.withinTolerance) return { action: "none", reason: "gap is within tolerance" };
|
|
84
|
+
const coverage = Number.isFinite(o.coverage) ? o.coverage : 0;
|
|
85
|
+
if (coverage === 0) return { action: "create", reason: "gap exceeds tolerance and nothing is in flight" };
|
|
86
|
+
if (gap.trend === "improving") return { action: "log", reason: "gap exceeds tolerance but the trend is improving with work in flight" };
|
|
87
|
+
if (gap.trend === "flat" || gap.trend === "worsening") {
|
|
88
|
+
return {
|
|
89
|
+
action: "escalate",
|
|
90
|
+
reason: `${coverage} item(s) already in flight and the trend is ${gap.trend} — more tasks will not help; ask for a decision`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { action: "log", reason: "work is in flight and the trend is not yet established" };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Run the gates against one candidate.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} candidate - { title, advances[], why, estCents?, expectedDelta? }
|
|
100
|
+
* @param {object} ctx - {
|
|
101
|
+
* objective, gap, latestSample, openItems?[], admittedItems?[], admittedThisRun?:number,
|
|
102
|
+
* remainingCents?, staleness?:{outcome:string}, maxOpenPerObjective?, maxNewPerRun?
|
|
103
|
+
* }
|
|
104
|
+
* @returns {{admitted:boolean, gate:string|null, reason:string, checks:Array<{gate:string, ok:boolean, detail:string}>}}
|
|
105
|
+
*/
|
|
106
|
+
export function admit(candidate = {}, ctx = {}) {
|
|
107
|
+
const checks = [];
|
|
108
|
+
const fail = (gate, reason) => {
|
|
109
|
+
checks.push({ gate, ok: false, detail: reason });
|
|
110
|
+
return { admitted: false, gate, reason, checks };
|
|
111
|
+
};
|
|
112
|
+
const pass = (gate, detail) => checks.push({ gate, ok: true, detail });
|
|
113
|
+
|
|
114
|
+
// --- freshness: OUTCOME work is suspended past 24h of mandate staleness ----
|
|
115
|
+
const outcomeAllowed = !ctx.staleness || ctx.staleness.outcome === "full";
|
|
116
|
+
if (!outcomeAllowed) {
|
|
117
|
+
return fail(
|
|
118
|
+
"freshness",
|
|
119
|
+
`mandate cache is too stale for self-directed work (outcome=${ctx.staleness && ctx.staleness.outcome})`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
pass("freshness", "mandate cache is fresh");
|
|
123
|
+
|
|
124
|
+
// --- adoption -------------------------------------------------------------
|
|
125
|
+
const obj = ctx.objective || {};
|
|
126
|
+
if (obj.state !== "active") {
|
|
127
|
+
return fail("adoption", `objective "${obj.key || "?"}" is ${obj.state || "unknown"}, not active — a proposed objective admits no work`);
|
|
128
|
+
}
|
|
129
|
+
pass("adoption", `objective "${obj.key}" is active`);
|
|
130
|
+
|
|
131
|
+
// --- honesty --------------------------------------------------------------
|
|
132
|
+
const source = ctx.latestSample && ctx.latestSample.source;
|
|
133
|
+
if (!WORK_CREATING_SOURCES.includes(String(source))) {
|
|
134
|
+
return fail(
|
|
135
|
+
"honesty",
|
|
136
|
+
`latest sample source is "${source || "none"}" — only ${WORK_CREATING_SOURCES.join("/")} samples may create work`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
pass("honesty", `latest sample is ${source}-sourced`);
|
|
140
|
+
|
|
141
|
+
// --- provenance -----------------------------------------------------------
|
|
142
|
+
const advances = [].concat(candidate.advances || []).filter(Boolean);
|
|
143
|
+
if (advances.length === 0) return fail("provenance", "candidate advances no objective");
|
|
144
|
+
if (!candidate.why || !candidate.why.reason) {
|
|
145
|
+
return fail("provenance", "candidate carries no `why.reason` — a machine-created task must say why it exists");
|
|
146
|
+
}
|
|
147
|
+
pass("provenance", `advances ${advances.join(", ")}`);
|
|
148
|
+
|
|
149
|
+
// Shared by dedupe, trend and wip below.
|
|
150
|
+
const openItems = Array.isArray(ctx.openItems) ? ctx.openItems : [];
|
|
151
|
+
|
|
152
|
+
// --- dedupe ---------------------------------------------------------------
|
|
153
|
+
// BEFORE the trend gate, deliberately. Duplication can only happen when there
|
|
154
|
+
// ARE open items — and any open item makes `decideForGap` return non-"create",
|
|
155
|
+
// so with dedupe placed after trend the open-items half of this gate could
|
|
156
|
+
// never fire at all. (The original comment here rationalised that as "the
|
|
157
|
+
// reachable case is admittedItems", which conceded the bug rather than fixing
|
|
158
|
+
// it.) Order also matters for the OPERATOR: "you already have this open" is a
|
|
159
|
+
// precise, actionable refusal; "work is in flight and the trend is not yet
|
|
160
|
+
// established" is the vaguer truth that happens to also be true.
|
|
161
|
+
const against = [...openItems, ...(Array.isArray(ctx.admittedItems) ? ctx.admittedItems : [])];
|
|
162
|
+
for (const item of against) {
|
|
163
|
+
const sim = titleSimilarity(candidate.title, item && (item.title || item.text));
|
|
164
|
+
if (sim >= DUPLICATE_THRESHOLD) {
|
|
165
|
+
return fail("dedupe", `"${candidate.title}" is ${(sim * 100).toFixed(0)}% similar to "${item.title || item.text}"`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
pass("dedupe", `no near-duplicate among ${against.length} comparable item(s)`);
|
|
169
|
+
|
|
170
|
+
// --- trend ----------------------------------------------------------------
|
|
171
|
+
const gap = ctx.gap || {};
|
|
172
|
+
const decision = decideForGap(gap, { coverage: openItems.length });
|
|
173
|
+
if (decision.action !== "create") {
|
|
174
|
+
return fail("trend", decision.reason);
|
|
175
|
+
}
|
|
176
|
+
pass("trend", decision.reason);
|
|
177
|
+
|
|
178
|
+
// --- wip ------------------------------------------------------------------
|
|
179
|
+
const maxOpen = Number.isFinite(ctx.maxOpenPerObjective) ? ctx.maxOpenPerObjective : MAX_OPEN_PER_OBJECTIVE;
|
|
180
|
+
if (openItems.length >= maxOpen) {
|
|
181
|
+
return fail("wip", `${openItems.length} open item(s) on "${obj.key}" ≥ cap ${maxOpen}`);
|
|
182
|
+
}
|
|
183
|
+
const maxNew = Number.isFinite(ctx.maxNewPerRun) ? ctx.maxNewPerRun : MAX_NEW_PER_RUN;
|
|
184
|
+
const admittedThisRun = Number.isFinite(ctx.admittedThisRun) ? ctx.admittedThisRun : 0;
|
|
185
|
+
if (admittedThisRun >= maxNew) {
|
|
186
|
+
return fail("wip", `${admittedThisRun} item(s) already admitted this run ≥ cap ${maxNew}`);
|
|
187
|
+
}
|
|
188
|
+
pass("wip", `${openItems.length}/${maxOpen} open, ${admittedThisRun}/${maxNew} admitted this run`);
|
|
189
|
+
|
|
190
|
+
// --- budget ---------------------------------------------------------------
|
|
191
|
+
const est = Number(candidate.estCents);
|
|
192
|
+
const remaining = Number(ctx.remainingCents);
|
|
193
|
+
if (Number.isFinite(est) && Number.isFinite(remaining) && est > remaining) {
|
|
194
|
+
return fail("budget", `estimate ${est}c exceeds the remaining envelope ${remaining}c`);
|
|
195
|
+
}
|
|
196
|
+
pass("budget", Number.isFinite(est) ? `estimate ${est}c within envelope` : "no cost estimate supplied");
|
|
197
|
+
|
|
198
|
+
return { admitted: true, gate: null, reason: "all gates passed", checks };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export default {
|
|
202
|
+
GATES,
|
|
203
|
+
MAX_OPEN_PER_OBJECTIVE,
|
|
204
|
+
MAX_NEW_PER_RUN,
|
|
205
|
+
DUPLICATE_THRESHOLD,
|
|
206
|
+
titleSimilarity,
|
|
207
|
+
decideForGap,
|
|
208
|
+
admit,
|
|
209
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admission.test.mjs — the gates between "the number is off" and "work exists".
|
|
3
|
+
* Run: `node --test lib/goals/admission.test.mjs`
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
|
|
11
|
+
import { admit, decideForGap, titleSimilarity, GATES, DUPLICATE_THRESHOLD } from "./admission.mjs";
|
|
12
|
+
|
|
13
|
+
const OBJ = { key: "pipeline", state: "active", metric: "pipeline_coverage_x", target: 3, cadence: "weekly" };
|
|
14
|
+
const GAP = { gap: 1, target: 3, value: 2, withinTolerance: false, trend: "flat", normalizedGap: 0.33, stale: false };
|
|
15
|
+
const SAMPLE = { source: "method", value: 2, window: "2026-W33" };
|
|
16
|
+
|
|
17
|
+
function candidate(over = {}) {
|
|
18
|
+
return {
|
|
19
|
+
title: "Close the pipeline coverage gap",
|
|
20
|
+
advances: ["pipeline"],
|
|
21
|
+
estCents: 40,
|
|
22
|
+
why: { reason: "kpi-gap", objectiveKey: "pipeline" },
|
|
23
|
+
...over,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function ctx(over = {}) {
|
|
27
|
+
return {
|
|
28
|
+
objective: OBJ,
|
|
29
|
+
gap: GAP,
|
|
30
|
+
latestSample: SAMPLE,
|
|
31
|
+
openItems: [],
|
|
32
|
+
admittedThisRun: 0,
|
|
33
|
+
remainingCents: 500,
|
|
34
|
+
staleness: { outcome: "full" },
|
|
35
|
+
...over,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// the decision table
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
test("decision table: within tolerance → nothing; no coverage → create", () => {
|
|
44
|
+
assert.equal(decideForGap({ ...GAP, withinTolerance: true }).action, "none");
|
|
45
|
+
assert.equal(decideForGap(GAP, { coverage: 0 }).action, "create");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("decision table: work in flight + flat/worsening → escalate, NOT more tasks", () => {
|
|
49
|
+
assert.equal(decideForGap({ ...GAP, trend: "flat" }, { coverage: 2 }).action, "escalate");
|
|
50
|
+
assert.equal(decideForGap({ ...GAP, trend: "worsening" }, { coverage: 1 }).action, "escalate");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("decision table: work in flight + improving → log and be patient", () => {
|
|
54
|
+
assert.equal(decideForGap({ ...GAP, trend: "improving" }, { coverage: 1 }).action, "log");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("decision table: a stale sensor is a drift to fix, never a gap to work", () => {
|
|
58
|
+
const d = decideForGap({ ...GAP, stale: true, stalenessPeriods: 3.2 }, { coverage: 0 });
|
|
59
|
+
assert.equal(d.action, "drift");
|
|
60
|
+
assert.match(d.reason, /stale/);
|
|
61
|
+
assert.equal(decideForGap({ gap: null }).action, "drift");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// the gates
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
test("a clean candidate passes every gate, in the declared order", () => {
|
|
69
|
+
const v = admit(candidate(), ctx());
|
|
70
|
+
assert.equal(v.admitted, true, v.reason);
|
|
71
|
+
assert.deepEqual(v.checks.map((c) => c.gate), GATES);
|
|
72
|
+
assert.ok(v.checks.every((c) => c.ok));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("GATE honesty — an llm-sourced sample is advisory and can NEVER create work", () => {
|
|
76
|
+
const v = admit(candidate(), ctx({ latestSample: { source: "llm", value: 2 } }));
|
|
77
|
+
assert.equal(v.admitted, false);
|
|
78
|
+
assert.equal(v.gate, "honesty");
|
|
79
|
+
assert.match(v.reason, /only method\/human samples may create work/);
|
|
80
|
+
|
|
81
|
+
// human-entered numbers DO count.
|
|
82
|
+
assert.equal(admit(candidate(), ctx({ latestSample: { source: "human", value: 2 } })).admitted, true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("GATE adoption — a proposed objective admits nothing", () => {
|
|
86
|
+
const v = admit(candidate(), ctx({ objective: { ...OBJ, state: "proposed" } }));
|
|
87
|
+
assert.equal(v.admitted, false);
|
|
88
|
+
assert.equal(v.gate, "adoption");
|
|
89
|
+
assert.match(v.reason, /proposed objective admits no work/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("GATE freshness — a stale mandate suspends self-directed work entirely", () => {
|
|
93
|
+
const v = admit(candidate(), ctx({ staleness: { outcome: "suspended" } }));
|
|
94
|
+
assert.equal(v.admitted, false);
|
|
95
|
+
assert.equal(v.gate, "freshness");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("GATE provenance — no advances[] and no why.reason are both refusals", () => {
|
|
99
|
+
assert.equal(admit(candidate({ advances: [] }), ctx()).gate, "provenance");
|
|
100
|
+
assert.equal(admit(candidate({ why: {} }), ctx()).gate, "provenance");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("GATE wip — the per-objective cap and the per-run cap both bite", () => {
|
|
104
|
+
const open = [{ title: "a" }, { title: "b" }, { title: "c" }];
|
|
105
|
+
const capped = admit(candidate(), ctx({ openItems: open, gap: { ...GAP, trend: "improving" } }));
|
|
106
|
+
assert.equal(capped.gate, "trend", "coverage>0 + improving is caught by the trend gate first");
|
|
107
|
+
|
|
108
|
+
const wip = admit(candidate({ title: "totally unrelated new push" }), ctx({ openItems: open, gap: { ...GAP, trend: "unknown" } }));
|
|
109
|
+
assert.equal(wip.admitted, false);
|
|
110
|
+
|
|
111
|
+
const perRun = admit(candidate(), ctx({ admittedThisRun: 2 }));
|
|
112
|
+
assert.equal(perRun.gate, "wip");
|
|
113
|
+
assert.match(perRun.reason, /already admitted this run/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("GATE dedupe — a near-duplicate of an open item is refused", () => {
|
|
117
|
+
// coverage>0 would trip the trend gate, so give it an unknown trend to reach dedupe.
|
|
118
|
+
const v = admit(
|
|
119
|
+
candidate({ title: "Close the pipeline coverage gap" }),
|
|
120
|
+
ctx({ openItems: [{ title: "Close the pipeline coverage gap now" }], gap: { ...GAP, trend: "unknown" } })
|
|
121
|
+
);
|
|
122
|
+
assert.equal(v.admitted, false);
|
|
123
|
+
assert.equal(v.gate, "dedupe");
|
|
124
|
+
assert.ok(titleSimilarity("Close the pipeline coverage gap", "Close the pipeline coverage gap now") >= DUPLICATE_THRESHOLD);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("NEGATIVE — GATE budget: no budget left means no work, and the reason names both numbers", () => {
|
|
128
|
+
const v = admit(candidate({ estCents: 900 }), ctx({ remainingCents: 100 }));
|
|
129
|
+
assert.equal(v.admitted, false);
|
|
130
|
+
assert.equal(v.gate, "budget");
|
|
131
|
+
assert.match(v.reason, /900c exceeds the remaining envelope 100c/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("every rejection carries its gate and the full check trail — nothing is dropped silently", () => {
|
|
135
|
+
const v = admit(candidate(), ctx({ latestSample: { source: "llm" } }));
|
|
136
|
+
assert.ok(v.checks.length > 0);
|
|
137
|
+
assert.equal(v.checks[v.checks.length - 1].ok, false);
|
|
138
|
+
assert.equal(v.checks[v.checks.length - 1].gate, v.gate);
|
|
139
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/goals/classify.mjs — SELF / COLLABORATE / HANDOFF / NEEDS_REVIEW.
|
|
3
|
+
*
|
|
4
|
+
* A KPI gap produces candidate work. Before any of it is dispatched, each
|
|
5
|
+
* candidate must answer one question: **whose hands does this need?** That is
|
|
6
|
+
* this module. It is deliberately a pure classifier — it decides, it does not
|
|
7
|
+
* act; `goals/collaborate.mjs` does the acting, and it does it exclusively
|
|
8
|
+
* through primitives that already exist (handoff envelopes, work-claim leases,
|
|
9
|
+
* board assignment, approvals, escalations). No new collaboration mechanism is
|
|
10
|
+
* introduced anywhere in this feature.
|
|
11
|
+
*
|
|
12
|
+
* SELF mine alone. Straight to the backlog at the routed rung.
|
|
13
|
+
* COLLABORATE mine, but a named peer contributes in parallel → work-claim
|
|
14
|
+
* lease so two agents cannot both drive it, + board link.
|
|
15
|
+
* HANDOFF not mine to do: another seat owns it, or only another seat can
|
|
16
|
+
* reach the capability → handoff envelope (handoff.offer).
|
|
17
|
+
* NEEDS_REVIEW mine to do, but the OUTPUT needs someone else's signature —
|
|
18
|
+
* a gated action class (approval.request) or a named reviewer
|
|
19
|
+
* (board review request).
|
|
20
|
+
*
|
|
21
|
+
* Note the vocabulary is distinct from `lib/execution/surface-policy.mjs`'s
|
|
22
|
+
* DISPOSITIONS (`react_now|schedule|delegate|escalate|ignore`). Those classify
|
|
23
|
+
* an INBOUND EVENT — should I respond at all. These classify OUTBOUND WORK the
|
|
24
|
+
* agent generated for itself — whose hands does it need. Both exist; conflating
|
|
25
|
+
* them is what produced "everyone replies at once" the first time.
|
|
26
|
+
*
|
|
27
|
+
* PURE. No fs, no network, no clock.
|
|
28
|
+
*
|
|
29
|
+
* @module lib/goals/classify
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
"use strict";
|
|
33
|
+
|
|
34
|
+
import { GATED_CLASSES } from "../org/approvals.mjs";
|
|
35
|
+
import { collaboratorsOf } from "../mandate/model.mjs";
|
|
36
|
+
|
|
37
|
+
/** The four ways a piece of self-directed work can be staffed. */
|
|
38
|
+
export const WORK_DISPOSITIONS = Object.freeze(["SELF", "COLLABORATE", "HANDOFF", "NEEDS_REVIEW"]);
|
|
39
|
+
|
|
40
|
+
/** True when `d` is one of the four. */
|
|
41
|
+
export function isWorkDisposition(d) {
|
|
42
|
+
return typeof d === "string" && WORK_DISPOSITIONS.includes(d);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Roles that mean "somebody else does the doing". */
|
|
46
|
+
const DOER_ROLES = new Set(["DOER"]);
|
|
47
|
+
/** Roles that mean "somebody else signs it off". */
|
|
48
|
+
const REVIEW_ROLES = new Set(["REVIEWER"]);
|
|
49
|
+
const APPROVE_ROLES = new Set(["APPROVER"]);
|
|
50
|
+
/** Roles that mean "somebody else works alongside me". */
|
|
51
|
+
const CONTRIB_ROLES = new Set(["CONTRIBUTOR"]);
|
|
52
|
+
|
|
53
|
+
function otherRoles(list, me, roleSet) {
|
|
54
|
+
return list.filter((c) => roleSet.has(c.role) && c.memberId && c.memberId !== me);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Which of the candidate's required capabilities this seat cannot reach, and
|
|
59
|
+
* which peer (if any) declares each. Pure.
|
|
60
|
+
*
|
|
61
|
+
* @param {string[]} required
|
|
62
|
+
* @param {Set<string>|string[]} reachable ids from config/capability-manifest.json
|
|
63
|
+
* @param {Array<{memberId:string, capabilities?:string[]}>} peers
|
|
64
|
+
* @returns {{missing:string[], coveredByPeer:Array<{capability:string, memberId:string}>, uncovered:string[]}}
|
|
65
|
+
*/
|
|
66
|
+
export function capabilityGaps(required, reachable, peers) {
|
|
67
|
+
const need = [].concat(required || []).filter(Boolean).map(String);
|
|
68
|
+
const have = reachable instanceof Set ? reachable : new Set(reachable || []);
|
|
69
|
+
const missing = need.filter((c) => !have.has(c));
|
|
70
|
+
const coveredByPeer = [];
|
|
71
|
+
const uncovered = [];
|
|
72
|
+
for (const cap of missing) {
|
|
73
|
+
// Deterministic: first peer in input order that declares it.
|
|
74
|
+
const peer = (peers || []).find((p) => p && Array.isArray(p.capabilities) && p.capabilities.includes(cap));
|
|
75
|
+
if (peer) coveredByPeer.push({ capability: cap, memberId: peer.memberId });
|
|
76
|
+
else uncovered.push(cap);
|
|
77
|
+
}
|
|
78
|
+
return { missing, coveredByPeer, uncovered };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Classify one candidate.
|
|
83
|
+
*
|
|
84
|
+
* @param {object} candidate - {
|
|
85
|
+
* title, objectiveKey, ownerMemberId?, collaborators?[], requiredCapabilities?[],
|
|
86
|
+
* actionClasses?[], reviewBy?
|
|
87
|
+
* }
|
|
88
|
+
* @param {object} ctx - {
|
|
89
|
+
* memberId, objective?, reachable?:Set<string>|string[], peers?[], gatedClasses?[]
|
|
90
|
+
* }
|
|
91
|
+
* @returns {{
|
|
92
|
+
* disposition:string, reason:string, target:string|null,
|
|
93
|
+
* reviewers:string[], approvers:string[], contributors:string[],
|
|
94
|
+
* gatedClasses:string[], capabilityGap:{missing:string[], uncovered:string[]}|null,
|
|
95
|
+
* why:string[]
|
|
96
|
+
* }}
|
|
97
|
+
*/
|
|
98
|
+
export function classifyWorkItem(candidate = {}, ctx = {}) {
|
|
99
|
+
const me = ctx.memberId || null;
|
|
100
|
+
const why = [];
|
|
101
|
+
|
|
102
|
+
// Collaborators come from the candidate AND from the objective it advances —
|
|
103
|
+
// an objective-level REVIEWER applies to every task under it.
|
|
104
|
+
const list = [...collaboratorsOf(candidate), ...collaboratorsOf(ctx.objective)];
|
|
105
|
+
const seen = new Set();
|
|
106
|
+
const collaborators = list.filter((c) => {
|
|
107
|
+
const k = `${c.memberId}:${c.role}`;
|
|
108
|
+
if (seen.has(k)) return false;
|
|
109
|
+
seen.add(k);
|
|
110
|
+
return true;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const doers = otherRoles(collaborators, me, DOER_ROLES);
|
|
114
|
+
const reviewers = otherRoles(collaborators, me, REVIEW_ROLES);
|
|
115
|
+
const approvers = otherRoles(collaborators, me, APPROVE_ROLES);
|
|
116
|
+
const contributors = otherRoles(collaborators, me, CONTRIB_ROLES);
|
|
117
|
+
|
|
118
|
+
const gatedList = (ctx.gatedClasses || GATED_CLASSES).map((c) => String(c).toLowerCase());
|
|
119
|
+
const gated = [].concat(candidate.actionClasses || [])
|
|
120
|
+
.map((c) => String(c).toLowerCase())
|
|
121
|
+
.filter((c) => gatedList.includes(c));
|
|
122
|
+
|
|
123
|
+
const gaps = capabilityGaps(candidate.requiredCapabilities, ctx.reachable, ctx.peers);
|
|
124
|
+
const capabilityGap = gaps.missing.length ? { missing: gaps.missing, uncovered: gaps.uncovered } : null;
|
|
125
|
+
|
|
126
|
+
const base = {
|
|
127
|
+
disposition: "SELF",
|
|
128
|
+
reason: "",
|
|
129
|
+
target: null,
|
|
130
|
+
reviewers: reviewers.map((c) => c.memberId),
|
|
131
|
+
approvers: approvers.map((c) => c.memberId),
|
|
132
|
+
contributors: contributors.map((c) => c.memberId),
|
|
133
|
+
gatedClasses: gated,
|
|
134
|
+
capabilityGap,
|
|
135
|
+
why,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// 1 — someone else owns the work outright.
|
|
139
|
+
if (candidate.ownerMemberId && me && candidate.ownerMemberId !== me) {
|
|
140
|
+
why.push(`ownerMemberId=${candidate.ownerMemberId} is not this seat`);
|
|
141
|
+
return { ...base, disposition: "HANDOFF", reason: "owned-by-another-seat", target: candidate.ownerMemberId };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 2 — an explicit DOER collaborator: the mandate says they do it, I steward it.
|
|
145
|
+
if (doers.length > 0) {
|
|
146
|
+
why.push(`collaborator ${doers[0].memberId} carries role DOER`);
|
|
147
|
+
return { ...base, disposition: "HANDOFF", reason: "collaborator-is-doer", target: doers[0].memberId };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 3 — I cannot reach a required capability but a peer declares it. Handing off
|
|
151
|
+
// is the ladder's answer, not refusing and not pretending.
|
|
152
|
+
if (gaps.coveredByPeer.length > 0) {
|
|
153
|
+
const first = gaps.coveredByPeer[0];
|
|
154
|
+
why.push(`capability "${first.capability}" is unreachable here but declared by ${first.memberId}`);
|
|
155
|
+
return {
|
|
156
|
+
...base,
|
|
157
|
+
disposition: "HANDOFF",
|
|
158
|
+
reason: "capability-only-reachable-by-peer",
|
|
159
|
+
target: first.memberId,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 4 — blast radius or a named APPROVER: the output needs a signature BEFORE it
|
|
164
|
+
// executes. Approval outranks collaboration; a gated action that is also
|
|
165
|
+
// collaborative is still gated.
|
|
166
|
+
if (gated.length > 0 || approvers.length > 0) {
|
|
167
|
+
why.push(
|
|
168
|
+
gated.length
|
|
169
|
+
? `action classes ${gated.join("+")} are gated`
|
|
170
|
+
: `collaborator ${approvers[0].memberId} carries role APPROVER`
|
|
171
|
+
);
|
|
172
|
+
return {
|
|
173
|
+
...base,
|
|
174
|
+
disposition: "NEEDS_REVIEW",
|
|
175
|
+
reason: gated.length ? "gated-action-class" : "collaborator-is-approver",
|
|
176
|
+
target: approvers.length ? approvers[0].memberId : null,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 5 — a named reviewer (candidate- or objective-level).
|
|
181
|
+
const reviewBy = candidate.reviewBy || (ctx.objective && ctx.objective.reviewBy) || null;
|
|
182
|
+
if (reviewers.length > 0 || (reviewBy && reviewBy !== me)) {
|
|
183
|
+
const target = reviewers.length ? reviewers[0].memberId : reviewBy;
|
|
184
|
+
why.push(`review required by ${target}`);
|
|
185
|
+
return { ...base, disposition: "NEEDS_REVIEW", reason: "review-required", target };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 6 — a peer works alongside me: shared work, so the work-claim lease matters.
|
|
189
|
+
if (contributors.length > 0) {
|
|
190
|
+
why.push(`contributor(s) ${contributors.map((c) => c.memberId).join(", ")} work alongside this seat`);
|
|
191
|
+
return { ...base, disposition: "COLLABORATE", reason: "named-contributors", target: contributors[0].memberId };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 7 — mine alone. If a capability is missing with no peer cover, that is a
|
|
195
|
+
// real gap: still SELF (the ladder's rung 2 asks for the integration), but
|
|
196
|
+
// the gap travels with the classification so it is never lost.
|
|
197
|
+
if (capabilityGap && capabilityGap.uncovered.length > 0) {
|
|
198
|
+
why.push(`capability gap with no peer cover: ${capabilityGap.uncovered.join(", ")}`);
|
|
199
|
+
return { ...base, disposition: "SELF", reason: "self-with-capability-gap" };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
why.push("no collaborator, no gate, no capability gap");
|
|
203
|
+
return { ...base, disposition: "SELF", reason: "self" };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export default { WORK_DISPOSITIONS, isWorkDisposition, capabilityGaps, classifyWorkItem };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* classify.test.mjs — SELF / COLLABORATE / HANDOFF / NEEDS_REVIEW.
|
|
3
|
+
* Run: `node --test lib/goals/classify.test.mjs`
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
|
|
11
|
+
import { classifyWorkItem, capabilityGaps, WORK_DISPOSITIONS, isWorkDisposition } from "./classify.mjs";
|
|
12
|
+
|
|
13
|
+
const ME = "mem_self";
|
|
14
|
+
const PEER = "mem_peer";
|
|
15
|
+
const BOSS = "mem_boss";
|
|
16
|
+
|
|
17
|
+
const OBJ = { key: "pipeline", state: "active", collaborators: [] };
|
|
18
|
+
const REACHABLE = new Set(["crm_list_deals", "task_create"]);
|
|
19
|
+
|
|
20
|
+
function ctx(over = {}) {
|
|
21
|
+
return { memberId: ME, objective: OBJ, reachable: REACHABLE, peers: [], ...over };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("the four dispositions are the vocabulary and nothing else is", () => {
|
|
25
|
+
assert.deepEqual(WORK_DISPOSITIONS, ["SELF", "COLLABORATE", "HANDOFF", "NEEDS_REVIEW"]);
|
|
26
|
+
assert.equal(isWorkDisposition("SELF"), true);
|
|
27
|
+
assert.equal(isWorkDisposition("react_now"), false, "inbound dispositions are a different vocabulary");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("SELF — mine alone, nothing gated, every capability reachable", () => {
|
|
31
|
+
const c = classifyWorkItem({ title: "Raise coverage", requiredCapabilities: ["crm_list_deals"] }, ctx());
|
|
32
|
+
assert.equal(c.disposition, "SELF");
|
|
33
|
+
assert.equal(c.reason, "self");
|
|
34
|
+
assert.equal(c.capabilityGap, null);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("HANDOFF — another seat owns the work outright", () => {
|
|
38
|
+
const c = classifyWorkItem({ title: "x", ownerMemberId: PEER }, ctx());
|
|
39
|
+
assert.equal(c.disposition, "HANDOFF");
|
|
40
|
+
assert.equal(c.target, PEER);
|
|
41
|
+
assert.equal(c.reason, "owned-by-another-seat");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("HANDOFF — a collaborator carrying role DOER is the doer, not me", () => {
|
|
45
|
+
const c = classifyWorkItem({ title: "x", collaborators: [{ memberId: PEER, role: "DOER" }] }, ctx());
|
|
46
|
+
assert.equal(c.disposition, "HANDOFF");
|
|
47
|
+
assert.equal(c.target, PEER);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("HANDOFF — a capability I cannot reach but a peer declares goes to the peer, not to a refusal", () => {
|
|
51
|
+
const c = classifyWorkItem(
|
|
52
|
+
{ title: "Post to the ledger", requiredCapabilities: ["books_invoices"] },
|
|
53
|
+
ctx({ peers: [{ memberId: PEER, capabilities: ["books_invoices"] }] })
|
|
54
|
+
);
|
|
55
|
+
assert.equal(c.disposition, "HANDOFF");
|
|
56
|
+
assert.equal(c.target, PEER);
|
|
57
|
+
assert.equal(c.reason, "capability-only-reachable-by-peer");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("NEEDS_REVIEW — a gated action class outranks collaboration", () => {
|
|
61
|
+
const c = classifyWorkItem(
|
|
62
|
+
{ title: "Email the customer base", actionClasses: ["external"], collaborators: [{ memberId: PEER, role: "CONTRIBUTOR" }] },
|
|
63
|
+
ctx()
|
|
64
|
+
);
|
|
65
|
+
assert.equal(c.disposition, "NEEDS_REVIEW");
|
|
66
|
+
assert.deepEqual(c.gatedClasses, ["external"]);
|
|
67
|
+
assert.equal(c.reason, "gated-action-class");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("NEEDS_REVIEW — a named APPROVER, and a named REVIEWER on the OBJECTIVE", () => {
|
|
71
|
+
const approver = classifyWorkItem({ title: "x", collaborators: [{ memberId: BOSS, role: "APPROVER" }] }, ctx());
|
|
72
|
+
assert.equal(approver.disposition, "NEEDS_REVIEW");
|
|
73
|
+
assert.equal(approver.target, BOSS);
|
|
74
|
+
|
|
75
|
+
// Objective-level reviewers apply to every task under the objective.
|
|
76
|
+
const reviewer = classifyWorkItem({ title: "x" }, ctx({ objective: { ...OBJ, collaborators: [{ memberId: PEER, role: "REVIEWER" }] } }));
|
|
77
|
+
assert.equal(reviewer.disposition, "NEEDS_REVIEW");
|
|
78
|
+
assert.equal(reviewer.target, PEER);
|
|
79
|
+
assert.equal(reviewer.reason, "review-required");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("COLLABORATE — a named contributor works alongside me", () => {
|
|
83
|
+
const c = classifyWorkItem({ title: "x", collaborators: [{ memberId: PEER, role: "CONTRIBUTOR" }] }, ctx());
|
|
84
|
+
assert.equal(c.disposition, "COLLABORATE");
|
|
85
|
+
assert.deepEqual(c.contributors, [PEER]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("a collaborator that IS me never turns my own work into a handoff", () => {
|
|
89
|
+
const c = classifyWorkItem({ title: "x", collaborators: [{ memberId: ME, role: "DOER" }, { memberId: ME, role: "REVIEWER" }] }, ctx());
|
|
90
|
+
assert.equal(c.disposition, "SELF");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("NEGATIVE — a missing capability with NO peer cover stays SELF but carries the gap forward", () => {
|
|
94
|
+
const c = classifyWorkItem({ title: "x", requiredCapabilities: ["stripe_refund"] }, ctx());
|
|
95
|
+
assert.equal(c.disposition, "SELF");
|
|
96
|
+
assert.equal(c.reason, "self-with-capability-gap");
|
|
97
|
+
assert.deepEqual(c.capabilityGap.uncovered, ["stripe_refund"]);
|
|
98
|
+
assert.match(c.why.join(" "), /capability gap with no peer cover/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("capabilityGaps splits missing into peer-covered and uncovered, deterministically", () => {
|
|
102
|
+
const g = capabilityGaps(["a", "b", "c"], ["a"], [
|
|
103
|
+
{ memberId: "p1", capabilities: ["b"] },
|
|
104
|
+
{ memberId: "p2", capabilities: ["b", "c"] },
|
|
105
|
+
]);
|
|
106
|
+
assert.deepEqual(g.missing, ["b", "c"]);
|
|
107
|
+
assert.deepEqual(g.coveredByPeer, [{ capability: "b", memberId: "p1" }, { capability: "c", memberId: "p2" }]);
|
|
108
|
+
assert.deepEqual(g.uncovered, []);
|
|
109
|
+
});
|