@cohortapp/agent-sdk 2.4.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/maestro.mjs +9 -0
- package/lib/backlog.mjs +35 -0
- package/lib/backlog.test.mjs +36 -0
- package/lib/channels/contract.mjs +1 -0
- package/lib/channels/contract.test.mjs +2 -1
- package/lib/channels/inbox-item.mjs +54 -0
- package/lib/comms/send-gate.mjs +56 -1
- package/lib/comms/send-gate.test.mjs +56 -0
- package/lib/execution/disposition.mjs +62 -2
- package/lib/execution/disposition.test.mjs +54 -0
- package/lib/execution/drive.mjs +1 -1
- package/lib/execution/effects.mjs +282 -24
- package/lib/execution/effects.test.mjs +112 -0
- package/lib/execution/index.mjs +1 -0
- package/lib/execution/intake.mjs +43 -9
- package/lib/execution/intake.test.mjs +46 -0
- package/lib/execution/pipeline.mjs +5 -0
- package/lib/execution/surface-policy.mjs +80 -30
- package/lib/goals/classify.mjs +49 -5
- package/lib/goals/classify.test.mjs +58 -0
- package/lib/goals/collaborate.mjs +131 -17
- package/lib/goals/collaborate.test.mjs +16 -4
- package/lib/goals/loop.mjs +160 -9
- package/lib/goals/loop.test.mjs +129 -3
- package/lib/kpi-sensors.mjs +666 -0
- package/lib/kpi-sensors.test.mjs +275 -0
- package/lib/kpi.mjs +23 -0
- package/lib/mandate/audit.mjs +3 -0
- package/lib/mandate/contract.mjs +277 -0
- package/lib/mandate/contract.test.mjs +185 -0
- package/lib/mandate/derive.mjs +49 -5
- package/lib/mandate/derive.test.mjs +7 -1
- package/lib/mandate/model.mjs +10 -1
- package/lib/mandate/model.test.mjs +22 -3
- package/lib/mandate/refresh.mjs +53 -5
- package/lib/mandate/refresh.test.mjs +83 -1
- package/lib/org/doctor.mjs +66 -0
- package/lib/org/doctor.test.mjs +73 -1
- package/lib/org/inbound/directedness.mjs +119 -1
- package/lib/org/inbound/directedness.test.mjs +67 -0
- package/lib/org/inbound/facts.mjs +132 -9
- package/lib/org/inbound/facts.test.mjs +96 -0
- package/lib/org/inbound/hydrate.mjs +40 -0
- package/lib/org/inbound/index.test.mjs +83 -0
- package/lib/org/inbound/project.mjs +8 -0
- package/lib/org/inbound/surfaces.mjs +20 -0
- package/lib/org/param-contract.mjs +16 -2
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +214 -2
- package/lib/org/protocol.test.mjs +11 -2
- package/lib/org/push.mjs +213 -49
- package/lib/org/push.test.mjs +112 -10
- package/lib/plan/compile.mjs +85 -8
- package/lib/plan/compile.test.mjs +82 -0
- package/lib/plan/emit.test.mjs +6 -1
- package/lib/setup/enroll-from-cohort.mjs +22 -2
- package/lib/setup/enroll-from-cohort.test.mjs +25 -0
- package/lib/setup/sections/mandate.mjs +43 -1
- package/lib/subagents/schema.mjs +14 -2
- package/lib/subagents/schema.test.mjs +22 -0
- package/package.json +1 -1
- package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
- package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/ci/conformance-org-api.mjs +16 -0
- package/scripts/ci/journey-approval-escalation.mjs +341 -0
- package/scripts/daemon/agent-daemon.mjs +582 -28
- package/scripts/daemon/cadence-handlers.mjs +273 -17
- package/scripts/daemon/cadence-handlers.test.mjs +101 -0
- package/scripts/daemon/execution-ladder.test.mjs +430 -0
- package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
- package/scripts/daemon/maestro-daemon.mjs +53 -0
- package/scripts/daemon/prompt-builder.mjs +47 -0
- package/scripts/daemon/responder.mjs +70 -3
- package/scripts/poller/imap-client.mjs +20 -1
- package/scripts/poller/inbox-scan-poller.mjs +15 -0
- package/scripts/poller/utils.mjs +51 -0
- package/scripts/setup/generate-capability.mjs +120 -11
- package/scripts/setup/generate-capability.test.mjs +134 -0
- package/scripts/setup/generate-plan.mjs +6 -1
- package/scripts/setup/repair-subagent-frontmatter.mjs +231 -0
|
@@ -38,6 +38,20 @@
|
|
|
38
38
|
* this surface — approvals and decisions land as `escalate`
|
|
39
39
|
* even when the payload names the agent as the approver. This
|
|
40
40
|
* is the same anti-self-grading law as `mandate.adopt`.
|
|
41
|
+
* `escalationOptions`
|
|
42
|
+
* The concrete ways a `selfApprove:false` escalation can go,
|
|
43
|
+
* for surfaces where they are STRUCTURALLY KNOWN rather than
|
|
44
|
+
* situational. Required, in practice, by the thing on the far
|
|
45
|
+
* end: hq's `escalation.ask` refuses fewer than two options
|
|
46
|
+
* ("a question with fewer is a message, not a decision"), and
|
|
47
|
+
* nothing supplies `facts.ambiguousOptions` for an approval —
|
|
48
|
+
* the daemon has no way to invent them and no reason to,
|
|
49
|
+
* because an approval can only ever be approved, rejected or
|
|
50
|
+
* held. Without this column the ladder decided `escalate` and
|
|
51
|
+
* the effect then had nothing to ask, so the escalation lane
|
|
52
|
+
* produced nothing at all. Omitted where the options really do
|
|
53
|
+
* depend on the situation; `facts.ambiguousOptions` still wins
|
|
54
|
+
* over it when a caller knows better.
|
|
41
55
|
* `maxChainDepth` How many consecutive agent turns are allowed in one thread
|
|
42
56
|
* before the ladder calls it a ping-pong loop and stops. Lower
|
|
43
57
|
* for broadcast surfaces (a space) than for a private DM.
|
|
@@ -148,6 +162,13 @@ export const ORG_SURFACE_POLICY = Object.freeze({
|
|
|
148
162
|
call: Object.freeze({
|
|
149
163
|
respondable: false,
|
|
150
164
|
replyMethod: null,
|
|
165
|
+
// The two verbs the row above describes in prose, as data. Read ONLY as a
|
|
166
|
+
// fallback when neither the router nor a compiled obligation named a method
|
|
167
|
+
// (see `effects.usesFor`), and used ONLY to furnish the queue row — it does
|
|
168
|
+
// not feed `need.methods`, so it moves no rung. Without it a live huddle
|
|
169
|
+
// (`calling/start` seq 8194) queued a row whose `uses` was empty: the drain
|
|
170
|
+
// was handed a call id and left to guess what one does with it.
|
|
171
|
+
queueUses: Object.freeze(["calling.getDetails", "calling.join"]),
|
|
151
172
|
latency: "now",
|
|
152
173
|
actionClasses: Object.freeze([]),
|
|
153
174
|
offlineSafe: false,
|
|
@@ -222,6 +243,11 @@ export const ORG_SURFACE_POLICY = Object.freeze({
|
|
|
222
243
|
actionClasses: Object.freeze(["irreversible"]),
|
|
223
244
|
offlineSafe: false,
|
|
224
245
|
selfApprove: false,
|
|
246
|
+
escalationOptions: Object.freeze([
|
|
247
|
+
"approve it",
|
|
248
|
+
"reject it",
|
|
249
|
+
"hold it — I need more context first",
|
|
250
|
+
]),
|
|
225
251
|
maxChainDepth: 1,
|
|
226
252
|
ambientDrop: true,
|
|
227
253
|
}),
|
|
@@ -233,6 +259,11 @@ export const ORG_SURFACE_POLICY = Object.freeze({
|
|
|
233
259
|
actionClasses: Object.freeze(["irreversible"]),
|
|
234
260
|
offlineSafe: false,
|
|
235
261
|
selfApprove: false,
|
|
262
|
+
escalationOptions: Object.freeze([
|
|
263
|
+
"adopt it as proposed",
|
|
264
|
+
"request an adjustment",
|
|
265
|
+
"reject it",
|
|
266
|
+
]),
|
|
236
267
|
maxChainDepth: 1,
|
|
237
268
|
ambientDrop: true,
|
|
238
269
|
}),
|
|
@@ -263,6 +294,47 @@ export const ORG_SURFACE_POLICY = Object.freeze({
|
|
|
263
294
|
ambientDrop: true,
|
|
264
295
|
}),
|
|
265
296
|
|
|
297
|
+
// ── calendar ────────────────────────────────────────────────────────────
|
|
298
|
+
/**
|
|
299
|
+
* ADOPTED 2026-08 (JOINT 3). This row lived in EXTENDED_SURFACE_POLICY while
|
|
300
|
+
* `directedness.classifyEvent` had no `calendar` branch. It now has one, and
|
|
301
|
+
* `SURFACES.calendar` ships, so the row moves here — which is exactly what
|
|
302
|
+
* the `adoptedExtendedSurfaces` drift guard exists to force.
|
|
303
|
+
*
|
|
304
|
+
* A meeting invite / reschedule / cancellation naming me as an attendee. Not
|
|
305
|
+
* respondable: the answer is an RSVP (`calendar.rsvp`), not a sentence. It is
|
|
306
|
+
* `now` latency because a 9am invite answered on tomorrow's batch tick is a
|
|
307
|
+
* missed meeting, and NOT offline-safe because accepting commits the
|
|
308
|
+
* principal's time — and `calendar.write` queues real invite/update/cancel
|
|
309
|
+
* mail, which leaves the org.
|
|
310
|
+
*/
|
|
311
|
+
calendar: Object.freeze({
|
|
312
|
+
// `replyMethod` is null because it is null for every non-respondable row:
|
|
313
|
+
// it names the method a CONVERSATIONAL reply would use, and an RSVP is an
|
|
314
|
+
// action the ladder routes, not a turn in a conversation. The routing hint
|
|
315
|
+
// lives on the obligation's `uses[]`, where it belongs.
|
|
316
|
+
respondable: false,
|
|
317
|
+
replyMethod: null,
|
|
318
|
+
// …EXCEPT that an un-adopted agent has no compiled plan, so there is no
|
|
319
|
+
// obligation and therefore no `uses[]`. That is the common case, not the
|
|
320
|
+
// exotic one: a live seat with no `config/plan.yaml` queued a real invite
|
|
321
|
+
// with `action.method: null`, and the row it wrote said only "Handle the
|
|
322
|
+
// calendar event calendar.calendar#8261" — no meeting id, no method. The
|
|
323
|
+
// session that drains it cannot RSVP something it cannot name.
|
|
324
|
+
//
|
|
325
|
+
// `queueUses` is the FALLBACK, never the override: it is read only when
|
|
326
|
+
// nothing else named a method, and only to furnish the queue row. It does
|
|
327
|
+
// NOT feed `need.methods`, so it cannot move a rung — the blast-radius and
|
|
328
|
+
// approval gates decide exactly as before.
|
|
329
|
+
queueUses: Object.freeze(["calendar.rsvp"]),
|
|
330
|
+
latency: "now",
|
|
331
|
+
actionClasses: Object.freeze(["external"]),
|
|
332
|
+
offlineSafe: false,
|
|
333
|
+
selfApprove: true,
|
|
334
|
+
maxChainDepth: 1,
|
|
335
|
+
ambientDrop: true,
|
|
336
|
+
}),
|
|
337
|
+
|
|
266
338
|
// ── outside the org ─────────────────────────────────────────────────────
|
|
267
339
|
/**
|
|
268
340
|
* Inbound email. The only default surface whose reply LEAVES the org, so it
|
|
@@ -284,13 +356,12 @@ export const ORG_SURFACE_POLICY = Object.freeze({
|
|
|
284
356
|
/**
|
|
285
357
|
* Surfaces this layer decides for that the org inbound table does NOT name yet.
|
|
286
358
|
*
|
|
287
|
-
*
|
|
288
|
-
* today with no policy at all
|
|
359
|
+
* Two of them exist, and each is a real event class that reaches an agent
|
|
360
|
+
* today with no policy at all. (`calendar` used to be the third; JOINT 3 gave
|
|
361
|
+
* `directedness.classifyEvent` a `calendar` branch and `SURFACES` a `calendar`
|
|
362
|
+
* entry, the drift guard fired, and its row MOVED into ORG_SURFACE_POLICY — the
|
|
363
|
+
* mechanism working as designed rather than two definitions accumulating.)
|
|
289
364
|
*
|
|
290
|
-
* `calendar` the protocol ships nine `calendar.*` methods and hq's classifier
|
|
291
|
-
* gains a `calendar` topic (SPEC §3), but `directedness.classifyEvent`
|
|
292
|
-
* has no `calendar` branch — an invite where I am an attendee is
|
|
293
|
-
* currently policy-less.
|
|
294
365
|
* `alert` a LOCAL fact, not an org event: `lib/diagnostics/alerts.mjs` and
|
|
295
366
|
* `lib/telemetry/alerts.mjs` derive `{id, severity, detail}` records
|
|
296
367
|
* that have never had a route to a decision — they were emitted to a
|
|
@@ -300,33 +371,12 @@ export const ORG_SURFACE_POLICY = Object.freeze({
|
|
|
300
371
|
*
|
|
301
372
|
* Kept in a SEPARATE table rather than merged into the literal above so the
|
|
302
373
|
* "every org surface has a row" coverage assertion stays exact, and so the day
|
|
303
|
-
* the
|
|
374
|
+
* the inbound layer adopts one of these the drift guard
|
|
304
375
|
* {@link adoptedExtendedSurfaces} fires and someone MOVES the row instead of
|
|
305
|
-
* quietly ending up with two definitions of the same surface.
|
|
376
|
+
* quietly ending up with two definitions of the same surface. That is precisely
|
|
377
|
+
* how `calendar` left this table.
|
|
306
378
|
*/
|
|
307
379
|
export const EXTENDED_SURFACE_POLICY = Object.freeze({
|
|
308
|
-
/**
|
|
309
|
-
* A meeting invite / reschedule / cancellation naming me as an attendee. Not
|
|
310
|
-
* respondable: the answer is an RSVP (`calendar.rsvp`), not a sentence. It is
|
|
311
|
-
* `now` latency because a 9am invite answered on tomorrow's batch tick is a
|
|
312
|
-
* missed meeting, and NOT offline-safe because accepting commits the
|
|
313
|
-
* principal's time — and `calendar.write` queues real invite/update/cancel
|
|
314
|
-
* mail, which leaves the org.
|
|
315
|
-
*/
|
|
316
|
-
calendar: Object.freeze({
|
|
317
|
-
// `replyMethod` is null because it is null for every non-respondable row:
|
|
318
|
-
// it names the method a CONVERSATIONAL reply would use, and an RSVP is an
|
|
319
|
-
// action the ladder routes, not a turn in a conversation. The routing hint
|
|
320
|
-
// lives on the obligation's `uses[]`, where it belongs.
|
|
321
|
-
respondable: false,
|
|
322
|
-
replyMethod: null,
|
|
323
|
-
latency: "now",
|
|
324
|
-
actionClasses: Object.freeze(["external"]),
|
|
325
|
-
offlineSafe: false,
|
|
326
|
-
selfApprove: true,
|
|
327
|
-
maxChainDepth: 1,
|
|
328
|
-
ambientDrop: true,
|
|
329
|
-
}),
|
|
330
380
|
/**
|
|
331
381
|
* A local health / budget / freshness alert about this agent. `offlineSafe` is
|
|
332
382
|
* TRUE and deliberately so: a partition is exactly when alerts matter, and an
|
package/lib/goals/classify.mjs
CHANGED
|
@@ -54,6 +54,41 @@ function otherRoles(list, me, roleSet) {
|
|
|
54
54
|
return list.filter((c) => roleSet.has(c.role) && c.memberId && c.memberId !== me);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Collaborator edges hq DERIVES from org structure rather than from anyone
|
|
59
|
+
* naming a reviewer for this work.
|
|
60
|
+
*
|
|
61
|
+
* ── WHY THIS SET EXISTS (a live, universal misroute) ──
|
|
62
|
+
* `mandate.adopt` enforces the anti-Goodhart law: the adopter can never be the
|
|
63
|
+
* objective's owner. `projectObjective` then emits that adopter as
|
|
64
|
+
* `{role:"APPROVER", source:"objective.adoptedById"}` on EVERY adopted
|
|
65
|
+
* objective. Rule 4 below ("a named APPROVER means the output needs a signature
|
|
66
|
+
* before it executes") therefore fired on **every candidate the goal steward
|
|
67
|
+
* has ever produced from a real hq mandate** — 100% of self-directed work
|
|
68
|
+
* classified NEEDS_REVIEW and went out for signature. Confirmed end-to-end
|
|
69
|
+
* against the adopted-snapshot wire shape.
|
|
70
|
+
*
|
|
71
|
+
* Adopting an objective is standing sign-off on the NUMBER, not per-task
|
|
72
|
+
* sign-off on every action taken to move it. The thing that genuinely needs a
|
|
73
|
+
* signature before execution is a gated action class (external / irreversible /
|
|
74
|
+
* financial), and that check is untouched. A human who wants per-task review
|
|
75
|
+
* says so by naming a reviewer — an edge with no derived `source`, or a
|
|
76
|
+
* `reviewBy` — and that still routes to NEEDS_REVIEW.
|
|
77
|
+
*
|
|
78
|
+
* These seats stay in `approvers[]`/`reviewers[]` regardless, so when a gate DOES
|
|
79
|
+
* fire, `goals/collaborate.routeNeedsReview` still has a real person to send it
|
|
80
|
+
* to. They just no longer MANUFACTURE the gate.
|
|
81
|
+
*/
|
|
82
|
+
const DERIVED_GOVERNANCE_SOURCES = new Set([
|
|
83
|
+
"objective.adoptedById",
|
|
84
|
+
"workforceMember.supervisorId",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/** True when this edge was named for the work, not inferred from org structure. */
|
|
88
|
+
function isNamedForTheWork(c) {
|
|
89
|
+
return !(c && c.source && DERIVED_GOVERNANCE_SOURCES.has(String(c.source)));
|
|
90
|
+
}
|
|
91
|
+
|
|
57
92
|
/**
|
|
58
93
|
* Which of the candidate's required capabilities this seat cannot reach, and
|
|
59
94
|
* which peer (if any) declares each. Pure.
|
|
@@ -163,24 +198,33 @@ export function classifyWorkItem(candidate = {}, ctx = {}) {
|
|
|
163
198
|
// 4 — blast radius or a named APPROVER: the output needs a signature BEFORE it
|
|
164
199
|
// executes. Approval outranks collaboration; a gated action that is also
|
|
165
200
|
// collaborative is still gated.
|
|
166
|
-
|
|
201
|
+
// Only an edge someone NAMED for this work manufactures a review gate; a
|
|
202
|
+
// derived governance edge (the objective's adopter, this seat's supervisor)
|
|
203
|
+
// does not. See DERIVED_GOVERNANCE_SOURCES.
|
|
204
|
+
const namedApprovers = approvers.filter(isNamedForTheWork);
|
|
205
|
+
const namedReviewers = reviewers.filter(isNamedForTheWork);
|
|
206
|
+
|
|
207
|
+
if (gated.length > 0 || namedApprovers.length > 0) {
|
|
167
208
|
why.push(
|
|
168
209
|
gated.length
|
|
169
210
|
? `action classes ${gated.join("+")} are gated`
|
|
170
|
-
: `collaborator ${
|
|
211
|
+
: `collaborator ${namedApprovers[0].memberId} carries role APPROVER`
|
|
171
212
|
);
|
|
172
213
|
return {
|
|
173
214
|
...base,
|
|
174
215
|
disposition: "NEEDS_REVIEW",
|
|
175
216
|
reason: gated.length ? "gated-action-class" : "collaborator-is-approver",
|
|
176
|
-
|
|
217
|
+
// A gated class still routes to the objective's adopter when that is the
|
|
218
|
+
// only approver on file — the derived edge is a fallback TARGET, never a
|
|
219
|
+
// trigger.
|
|
220
|
+
target: (namedApprovers[0] || approvers[0] || {}).memberId || null,
|
|
177
221
|
};
|
|
178
222
|
}
|
|
179
223
|
|
|
180
224
|
// 5 — a named reviewer (candidate- or objective-level).
|
|
181
225
|
const reviewBy = candidate.reviewBy || (ctx.objective && ctx.objective.reviewBy) || null;
|
|
182
|
-
if (
|
|
183
|
-
const target =
|
|
226
|
+
if (namedReviewers.length > 0 || (reviewBy && reviewBy !== me)) {
|
|
227
|
+
const target = namedReviewers.length ? namedReviewers[0].memberId : reviewBy;
|
|
184
228
|
why.push(`review required by ${target}`);
|
|
185
229
|
return { ...base, disposition: "NEEDS_REVIEW", reason: "review-required", target };
|
|
186
230
|
}
|
|
@@ -107,3 +107,61 @@ test("capabilityGaps splits missing into peer-covered and uncovered, determinist
|
|
|
107
107
|
assert.deepEqual(g.coveredByPeer, [{ capability: "b", memberId: "p1" }, { capability: "c", memberId: "p2" }]);
|
|
108
108
|
assert.deepEqual(g.uncovered, []);
|
|
109
109
|
});
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// The derived-governance-edge regression (found end-to-end, 2026-08-11)
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
test("REGRESSION — an objective's ADOPTER does not by itself force NEEDS_REVIEW", () => {
|
|
116
|
+
// hq's mandate.adopt refuses self-adoption, and projectObjective then emits the
|
|
117
|
+
// adopter as {role:"APPROVER", source:"objective.adoptedById"} on EVERY adopted
|
|
118
|
+
// objective. Rule 4 fired on it, so 100% of goal-steward candidates went out
|
|
119
|
+
// for signature and none was ever done SELF. Adopting the NUMBER is not
|
|
120
|
+
// per-task sign-off on the actions taken to move it.
|
|
121
|
+
const objective = {
|
|
122
|
+
key: "board-on-time-delivery",
|
|
123
|
+
collaborators: [{ memberId: "boss", role: "APPROVER", source: "objective.adoptedById" }],
|
|
124
|
+
};
|
|
125
|
+
const c = classifyWorkItem({ title: "Close the gap" }, { memberId: "me", objective, reachable: [] });
|
|
126
|
+
assert.equal(c.disposition, "SELF");
|
|
127
|
+
assert.equal(c.reason, "self");
|
|
128
|
+
// The edge is not discarded — it is still on file as an approver.
|
|
129
|
+
assert.deepEqual(c.approvers, ["boss"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("REGRESSION — the seat's SUPERVISOR does not force NEEDS_REVIEW either", () => {
|
|
133
|
+
const c = classifyWorkItem(
|
|
134
|
+
{ title: "Close the gap" },
|
|
135
|
+
{
|
|
136
|
+
memberId: "me",
|
|
137
|
+
objective: { collaborators: [{ memberId: "boss", role: "REVIEWER", source: "workforceMember.supervisorId" }] },
|
|
138
|
+
reachable: [],
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
assert.equal(c.disposition, "SELF");
|
|
142
|
+
assert.deepEqual(c.reviewers, ["boss"]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("a DELIBERATELY named reviewer (no derived source) still routes to NEEDS_REVIEW", () => {
|
|
146
|
+
const c = classifyWorkItem(
|
|
147
|
+
{ title: "Close the gap", collaborators: [{ memberId: "peer", role: "REVIEWER" }] },
|
|
148
|
+
{ memberId: "me", reachable: [] }
|
|
149
|
+
);
|
|
150
|
+
assert.equal(c.disposition, "NEEDS_REVIEW");
|
|
151
|
+
assert.equal(c.reason, "review-required");
|
|
152
|
+
assert.equal(c.target, "peer");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("a GATED action class still gates — and falls back to the adopter as the target", () => {
|
|
156
|
+
const c = classifyWorkItem(
|
|
157
|
+
{ title: "Wire the payment", actionClasses: ["financial"] },
|
|
158
|
+
{
|
|
159
|
+
memberId: "me",
|
|
160
|
+
objective: { collaborators: [{ memberId: "boss", role: "APPROVER", source: "objective.adoptedById" }] },
|
|
161
|
+
reachable: [],
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
assert.equal(c.disposition, "NEEDS_REVIEW");
|
|
165
|
+
assert.equal(c.reason, "gated-action-class");
|
|
166
|
+
assert.equal(c.target, "boss", "the derived edge is a fallback TARGET, never a trigger");
|
|
167
|
+
});
|
|
@@ -54,9 +54,73 @@ export function workResourceId(objectiveKey, title) {
|
|
|
54
54
|
// Lazily-resolved defaults (so a test can inject everything and touch no I/O)
|
|
55
55
|
// ---------------------------------------------------------------------------
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The org connection for this seat.
|
|
59
|
+
*
|
|
60
|
+
* THE BUG THIS EXISTS TO KILL: every default primitive here took `base`/`token`
|
|
61
|
+
* off `deps`, and NOTHING ever put them there — `runGoalSteward`'s deps carry the
|
|
62
|
+
* agent root, the clock and the sensors, not a credential. So `client.call`
|
|
63
|
+
* returned `{ok:false, BAD_REQUEST, "missing base"}` and the callers logged only
|
|
64
|
+
* the CODE, never the message. Every board item, handoff, approval and escalation
|
|
65
|
+
* the goal loop has ever produced died on this machine, and the log line
|
|
66
|
+
* ("board.create failed: BAD_REQUEST — the local backlog item still stands")
|
|
67
|
+
* read exactly like a server-side rejection. Resolve it the same way every other
|
|
68
|
+
* plane does: env first, `config/org.yaml` second, explicit deps win.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} deps @returns {{base:string, token:string, orgId:string}}
|
|
71
|
+
*/
|
|
72
|
+
async function orgConn(deps = {}) {
|
|
73
|
+
if (deps.base && deps.token) return { base: deps.base, token: deps.token, orgId: deps.orgId };
|
|
74
|
+
let resolved = { base: "", token: "", orgId: "" };
|
|
75
|
+
try {
|
|
76
|
+
const { resolveOrgToolConfig } = await import("../org/tool-surface.mjs");
|
|
77
|
+
resolved = resolveOrgToolConfig({ agentRoot: deps.agentRoot, orgConfig: deps.cfg });
|
|
78
|
+
} catch (err) {
|
|
79
|
+
logOf(deps)("warn", `[goals] could not resolve the org connection (${err && err.message ? err.message : err}) — org writes will fail locally`);
|
|
80
|
+
}
|
|
81
|
+
const conn = {
|
|
82
|
+
base: deps.base || resolved.base || "",
|
|
83
|
+
token: deps.token || resolved.token || "",
|
|
84
|
+
orgId: deps.orgId || resolved.orgId || "",
|
|
85
|
+
};
|
|
86
|
+
if (!conn.base || !conn.token) {
|
|
87
|
+
logOf(deps)(
|
|
88
|
+
"warn",
|
|
89
|
+
`[goals] no org credential for this seat (base=${conn.base ? "set" : "MISSING"} token=${conn.token ? "set" : "MISSING"}) — the work stays in the local queue and is invisible on the board`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return conn;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Post the board payload to the RIGHT method.
|
|
97
|
+
*
|
|
98
|
+
* `lib/org/board.createItem` calls **`board.create`**, whose hq schema is
|
|
99
|
+
* `.strict()` and accepts only {title, detail, status, priority, workstreamId,
|
|
100
|
+
* itemId}. It rejects `col`, `assigneeId` and — fatally for machine-created work
|
|
101
|
+
* — `why`. `board.createTask` is the method that carries the provenance block and
|
|
102
|
+
* an owner, so prefer it whenever the vendored protocol has it, and degrade to
|
|
103
|
+
* `board.create` LOUDLY (an unowned, untraceable task is still better than no
|
|
104
|
+
* task, but the operator has to be told which one they got).
|
|
105
|
+
*/
|
|
57
106
|
async function defaultCreateItem(item, deps) {
|
|
107
|
+
const conn = await orgConn(deps);
|
|
108
|
+
if (methodDef("board.createTask")) {
|
|
109
|
+
const { call } = await import("../org/client.mjs");
|
|
110
|
+
// `itemId` is meaningless to board.createTask (non-strict → stripped) and is
|
|
111
|
+
// the idempotency key of the legacy path; carrying it costs nothing.
|
|
112
|
+
const { itemId, ...params } = item || {};
|
|
113
|
+
return call("board.createTask", params, { ...conn, fetchImpl: deps.fetchImpl });
|
|
114
|
+
}
|
|
115
|
+
logOf(deps)(
|
|
116
|
+
"warn",
|
|
117
|
+
"[goals] board.createTask is not in the vendored protocol — falling back to board.create, which drops the assignee and the `why` provenance block",
|
|
118
|
+
);
|
|
58
119
|
const { createItem } = await import("../org/board.mjs");
|
|
59
|
-
|
|
120
|
+
const legacy = { title: item.title, priority: item.priority };
|
|
121
|
+
if (item.detail) legacy.detail = String(item.detail).slice(0, 5000);
|
|
122
|
+
if (item.itemId) legacy.itemId = item.itemId;
|
|
123
|
+
return createItem(legacy, { cfg: deps.cfg, ...conn, fetchImpl: deps.fetchImpl });
|
|
60
124
|
}
|
|
61
125
|
|
|
62
126
|
async function defaultSendHandoff(o, deps) {
|
|
@@ -76,7 +140,8 @@ async function defaultClaimLease(scope, resourceId, opts) {
|
|
|
76
140
|
|
|
77
141
|
async function defaultCall(method, params, deps) {
|
|
78
142
|
const { call } = await import("../org/client.mjs");
|
|
79
|
-
|
|
143
|
+
const conn = await orgConn(deps);
|
|
144
|
+
return call(method, params, { ...conn, fetchImpl: deps.fetchImpl });
|
|
80
145
|
}
|
|
81
146
|
|
|
82
147
|
function pick(deps, name, fallback) {
|
|
@@ -87,6 +152,62 @@ function pick(deps, name, fallback) {
|
|
|
87
152
|
// The board item — every disposition creates one, so the work is visible
|
|
88
153
|
// ---------------------------------------------------------------------------
|
|
89
154
|
|
|
155
|
+
/** Local priority word → hq's `taskPriorityEnum`. P0 stays reserved for humans. */
|
|
156
|
+
export const BOARD_PRIORITY = Object.freeze({ high: "P1", normal: "P2", low: "P3" });
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Shape a candidate into `board.createTask`'s ACTUAL parameters.
|
|
160
|
+
*
|
|
161
|
+
* This used to send `{id, status:"open", priority:"normal", assignee,
|
|
162
|
+
* objectiveKey, obligationKey}`. hq's `createTaskSchema` (src/server/validation/
|
|
163
|
+
* task.ts) accepts none of those spellings: `priority` is the P0..P4 enum, the
|
|
164
|
+
* owner field is `assigneeId`, and `why` is a CLOSED block of
|
|
165
|
+
* {reason, options, clause, wouldChange}. Every self-directed item this loop
|
|
166
|
+
* ever produced was therefore rejected BAD_REQUEST — the work existed in the
|
|
167
|
+
* local queue and was invisible to every human on the board.
|
|
168
|
+
*
|
|
169
|
+
* Provenance hq will not carry in `why` (objectiveKey, obligationKey, the
|
|
170
|
+
* expected delta) is written into `detail` instead, in prose, so the trace
|
|
171
|
+
* survives the round trip even though the columns do not.
|
|
172
|
+
*
|
|
173
|
+
* @param {object} item - { id, title, objectiveKey, obligationKey, why, priority, assignee?, expectedDelta?, successCriteria? }
|
|
174
|
+
* @param {object} [deps] - { memberId, boardCol? }
|
|
175
|
+
* @returns {object} board.createTask params
|
|
176
|
+
*/
|
|
177
|
+
export function boardPayloadFor(item = {}, deps = {}) {
|
|
178
|
+
const why = {};
|
|
179
|
+
const src = item.why || {};
|
|
180
|
+
if (src.reason) why.reason = String(src.reason).slice(0, 2000);
|
|
181
|
+
if (src.clause) why.clause = String(src.clause).slice(0, 500);
|
|
182
|
+
if (src.wouldChange) why.wouldChange = String(src.wouldChange).slice(0, 2000);
|
|
183
|
+
if (Array.isArray(src.options) && src.options.length) why.options = src.options.slice(0, 20);
|
|
184
|
+
|
|
185
|
+
const lines = [];
|
|
186
|
+
if (item.objectiveKey) lines.push(`Advances objective: ${item.objectiveKey}`);
|
|
187
|
+
if (item.obligationKey) lines.push(`Obligation: ${item.obligationKey}`);
|
|
188
|
+
if (item.expectedDelta != null) lines.push(`Expected delta: ${item.expectedDelta}`);
|
|
189
|
+
if (src.metric) lines.push(`Metric: ${src.metric} (gap ${src.gap}, trend ${src.trend})`);
|
|
190
|
+
for (const c of [].concat(item.successCriteria || [])) lines.push(`Success: ${c}`);
|
|
191
|
+
if (item.id) lines.push(`Local queue id: ${item.id}`);
|
|
192
|
+
|
|
193
|
+
const payload = {
|
|
194
|
+
// Stripped by board.createTask (non-strict); the idempotency key of the
|
|
195
|
+
// legacy board.create fallback.
|
|
196
|
+
itemId: item.id || undefined,
|
|
197
|
+
title: String(item.title || "").slice(0, 500),
|
|
198
|
+
// `todo`, not the schema's `triage` default: this work was ADMITTED through
|
|
199
|
+
// eight gates against a measured gap. Dropping it into triage would hand a
|
|
200
|
+
// human the decision the loop just made.
|
|
201
|
+
col: deps.boardCol || "todo",
|
|
202
|
+
priority: BOARD_PRIORITY[String(item.priority || "normal")] || "P2",
|
|
203
|
+
};
|
|
204
|
+
if (lines.length) payload.detail = lines.join("\n").slice(0, 20000);
|
|
205
|
+
const assigneeId = item.assignee || deps.memberId;
|
|
206
|
+
if (assigneeId) payload.assigneeId = assigneeId;
|
|
207
|
+
if (Object.keys(why).length) payload.why = why;
|
|
208
|
+
return payload;
|
|
209
|
+
}
|
|
210
|
+
|
|
90
211
|
/**
|
|
91
212
|
* Create the org board item that carries the work, stamped with the full
|
|
92
213
|
* provenance chain (`why`). Fail-open: an unreachable org returns
|
|
@@ -98,19 +219,7 @@ function pick(deps, name, fallback) {
|
|
|
98
219
|
*/
|
|
99
220
|
export async function createWorkItem(item, deps = {}) {
|
|
100
221
|
const create = pick(deps, "createItem", (i) => defaultCreateItem(i, deps));
|
|
101
|
-
const payload =
|
|
102
|
-
id: item.id,
|
|
103
|
-
title: item.title,
|
|
104
|
-
status: "open",
|
|
105
|
-
priority: item.priority || "normal",
|
|
106
|
-
assignee: item.assignee || deps.memberId || undefined,
|
|
107
|
-
// `why` is mandatory for machine-created work: {reason, objectiveKey,
|
|
108
|
-
// obligationKey, clause, expectedDelta}. A task a human cannot trace is a
|
|
109
|
-
// task a human cannot trust.
|
|
110
|
-
why: item.why || null,
|
|
111
|
-
objectiveKey: item.objectiveKey || null,
|
|
112
|
-
obligationKey: item.obligationKey || null,
|
|
113
|
-
};
|
|
222
|
+
const payload = boardPayloadFor(item, deps);
|
|
114
223
|
let frame;
|
|
115
224
|
try {
|
|
116
225
|
frame = await create(payload);
|
|
@@ -122,7 +231,10 @@ export async function createWorkItem(item, deps = {}) {
|
|
|
122
231
|
if (!ok) {
|
|
123
232
|
logOf(deps)(
|
|
124
233
|
"warn",
|
|
125
|
-
|
|
234
|
+
// The MESSAGE, not just the code. "BAD_REQUEST" on its own is what let
|
|
235
|
+
// "missing base" (a local credential fault) masquerade as a server-side
|
|
236
|
+
// rejection for the whole life of this loop.
|
|
237
|
+
`[goals] ${methodDef("board.createTask") ? "board.createTask" : "board.create"} failed for "${item.title}": ${(frame && frame.error && frame.error.code) || "unknown"}${frame && frame.error && frame.error.message ? ` — ${frame.error.message}` : ""} — the local backlog item still stands`
|
|
126
238
|
);
|
|
127
239
|
}
|
|
128
240
|
return {
|
|
@@ -381,7 +493,7 @@ export async function raiseEscalation(o = {}, deps = {}) {
|
|
|
381
493
|
if (!ok) {
|
|
382
494
|
logOf(deps)(
|
|
383
495
|
"warn",
|
|
384
|
-
`[goals] ${method} failed (${(frame && frame.error && frame.error.code) || "unknown"}) — the question is still recorded in the audit ledger`
|
|
496
|
+
`[goals] ${method} failed (${(frame && frame.error && frame.error.code) || "unknown"}${frame && frame.error && frame.error.message ? `: ${frame.error.message}` : ""}) — the question is still recorded in the audit ledger`
|
|
385
497
|
);
|
|
386
498
|
}
|
|
387
499
|
return { ok, reason: ok ? "raised" : (frame && frame.error && frame.error.code) || "failed", id: ok && frame.result ? frame.result.id || null : null, method };
|
|
@@ -404,7 +516,9 @@ export async function routeWorkItem(item, classification, deps = {}) {
|
|
|
404
516
|
|
|
405
517
|
export default {
|
|
406
518
|
WORK_LEASE_SCOPE,
|
|
519
|
+
BOARD_PRIORITY,
|
|
407
520
|
workResourceId,
|
|
521
|
+
boardPayloadFor,
|
|
408
522
|
createWorkItem,
|
|
409
523
|
routeWorkItem,
|
|
410
524
|
routeSelf,
|
|
@@ -76,10 +76,22 @@ test("every work item carries the full provenance chain onto the board", async (
|
|
|
76
76
|
assert.equal(r.ok, true);
|
|
77
77
|
assert.equal(r.itemId, "bi_1");
|
|
78
78
|
const payload = calls.board[0];
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
// The payload is shaped for hq's REAL schema (board.createTask): the owner is
|
|
80
|
+
// `assigneeId`, the priority is the P0..P4 enum, and `why` is the CLOSED
|
|
81
|
+
// {reason, options, clause, wouldChange} block. `objectiveKey`/`obligationKey`
|
|
82
|
+
// have no column there, so they ride in `detail` — the trace survives even
|
|
83
|
+
// though the fields do not. (The old spellings were rejected BAD_REQUEST by
|
|
84
|
+
// every hq that ever received them.)
|
|
81
85
|
assert.equal(payload.why.clause, "cs_12");
|
|
82
|
-
assert.equal(payload.
|
|
86
|
+
assert.equal(payload.why.reason, "kpi-gap");
|
|
87
|
+
assert.equal(payload.assigneeId, SELF);
|
|
88
|
+
assert.equal(payload.priority, "P1", "the item is high priority → P1, not the word \"high\"");
|
|
89
|
+
assert.equal(payload.col, "todo");
|
|
90
|
+
assert.match(payload.detail, /Advances objective: pipeline/);
|
|
91
|
+
assert.match(payload.detail, /Obligation: outcome\.pipeline/);
|
|
92
|
+
assert.equal(payload.objectiveKey, undefined, "a spelling hq rejects must not be sent");
|
|
93
|
+
assert.equal(payload.assignee, undefined);
|
|
94
|
+
assert.equal(payload.status, undefined);
|
|
83
95
|
});
|
|
84
96
|
|
|
85
97
|
test("SERVER UNREACHABLE: a thrown board.create is reported, logged, and never throws out", async () => {
|
|
@@ -182,7 +194,7 @@ test("HANDOFF assigns the item to the target and sends the envelope with full co
|
|
|
182
194
|
const r = await routeHandoff(ITEM, c, deps);
|
|
183
195
|
assert.equal(r.ok, true);
|
|
184
196
|
assert.equal(r.primitive, "handoff.offer");
|
|
185
|
-
assert.equal(calls.board[0].
|
|
197
|
+
assert.equal(calls.board[0].assigneeId, PEER, "the board item must be assigned to whoever is doing it");
|
|
186
198
|
const env = calls.handoff[0];
|
|
187
199
|
assert.equal(env.to, PEER);
|
|
188
200
|
assert.equal(env.from, SELF);
|